home · contact · privacy
7c4a663a617b90ce8bdb7b8c300afdd7ae811297
[plomrogue] / plomrogue-server.py
1 import argparse
2 import errno
3 import os
4 import shlex
5 import shutil
6 import time
7
8
9 def setup_server_io():
10     """Fill IO files DB with proper file( path)s. Write process IO test string.
11
12     Ensure IO files directory at server/. Remove any old input file if found.
13     Set up new input file for reading, and new output file for writing. Start
14     output file with process hash line of format PID + " " + floated UNIX time
15     (io_db["teststring"]). Raise SystemExit if file is found at path of either
16     record or save file plus io_db["tmp_suffix"].
17     """
18     def detect_atomic_leftover(path, tmp_suffix):
19         path_tmp = path + tmp_suffix
20         msg = "Found file '" + path_tmp + "' that may be a leftover from an " \
21               "aborted previous attempt to write '" + path + "'. Aborting " \
22              "until matter is resolved by removing it from its current path."
23         if os.access(path_tmp, os.F_OK):
24             raise SystemExit(msg)
25     io_db["teststring"] = str(os.getpid()) + " " + str(time.time())
26     os.makedirs(io_db["path_server"], exist_ok=True)
27     io_db["file_out"] = open(io_db["path_out"], "w")
28     io_db["file_out"].write(io_db["teststring"] + "\n")
29     io_db["file_out"].flush()
30     if os.access(io_db["path_in"], os.F_OK):
31         os.remove(io_db["path_in"])
32     io_db["file_in"] = open(io_db["path_in"], "w")
33     io_db["file_in"].close()
34     io_db["file_in"] = open(io_db["path_in"], "r")
35     detect_atomic_leftover(io_db["path_save"], io_db["tmp_suffix"])
36     detect_atomic_leftover(io_db["path_record"], io_db["tmp_suffix"])
37
38
39 def cleanup_server_io():
40     """Close and (if io_db["kicked_by_rival"] false) remove files in io_db."""
41     def helper(file_key, path_key):
42         if file_key in io_db:
43             io_db[file_key].close()
44             if not io_db["kicked_by_rival"] \
45                and os.access(io_db[path_key], os.F_OK):
46                 os.remove(io_db[path_key])
47     helper("file_out", "path_out")
48     helper("file_in", "path_in")
49     helper("file_worldstate", "path_worldstate")
50     if "file_record" in io_db:
51         io_db["file_record"].close()
52
53
54 def obey(command, prefix, replay=False, do_record=False):
55     """Call function from commands_db mapped to command's first token.
56
57     The command string is tokenized by shlex.split(comments=True). If replay is
58     set, a non-meta command from the commands_db merely triggers obey() on the
59     next command from the records file. If not, and do do_record is set,
60     non-meta commands are recorded via record(), and save_world() is called.
61     The prefix string is inserted into the server's input message between its
62     beginning 'input ' & ':'. All activity is preceded by a server_test() call.
63     """
64     server_test()
65     print("input " + prefix + ": " + command)
66     try:
67         tokens = shlex.split(command, comments=True)
68     except ValueError as err:
69         print("Can't tokenize command string: " + str(err) + ".")
70         return
71     if len(tokens) > 0 and tokens[0] in commands_db \
72        and len(tokens) == commands_db[tokens[0]][0] + 1:
73         if commands_db[tokens[0]][1]:
74             commands_db[tokens[0]][2]()
75         elif replay:
76             print("Due to replay mode, reading command as 'go on in record'.")
77             line = io_db["file_record"].readline()
78             if len(line) > 0:
79                 obey(line.rstrip(), io_db["file_record"].prefix
80                      + str(io_db["file_record"].line_n))
81                 io_db["file_record"].line_n = io_db["file_record"].line_n + 1
82             else:
83                 print("Reached end of record file.")
84         else:
85             commands_db[tokens[0]][2](*tokens[1:])
86             if do_record:
87                 record(command)
88                 save_world()
89     else:
90         print("Invalid command/argument, or bad number of tokens.")
91
92
93 def atomic_write(path, text, do_append=False):
94     """Atomic write of text to file at path, appended if do_append is set."""
95     path_tmp = path + io_db["tmp_suffix"]
96     mode = "w"
97     if do_append:
98         mode = "a"
99         if os.access(path, os.F_OK):
100             shutil.copyfile(path, path_tmp)
101     file = open(path_tmp, mode)
102     file.write(text)
103     file.flush()
104     os.fsync(file.fileno())
105     file.close()
106     if os.access(path, os.F_OK):
107         os.remove(path)
108     os.rename(path_tmp, path)
109
110
111 def record(command):
112     """Append command string plus newline to record file. (Atomic.)"""
113     # This misses some optimizations from the original record(), namely only
114     # finishing the atomic write with expensive flush() and fsync() every 15
115     # seconds unless explicitely forced. Implement as needed.
116     atomic_write(io_db["path_record"], command + "\n", do_append=True)
117
118
119 def save_world():
120     # Dummy for saving all commands to reconstruct current world state.
121     # Misses same optimizations as record() from the original record().
122     atomic_write(io_db["path_save"],
123                  "WORLD_ACTIVE " + str(world_db["WORLD_ACTIVE"]) + "\n" +
124                  "MAP_LENGTH " + str(world_db["MAP_LENGTH"]) + "\n" +
125                  "PLAYER_TYPE " + str(world_db["PLAYER_TYPE"]) + "\n" +
126                  "TURN " + str(world_db["TURN"]) + "\n" +
127                  "SEED_RANDOMNESS " + str(world_db["SEED_RANDOMNESS"]) + "\n" +
128                  "SEED_MAP " + str(world_db["SEED_MAP"]) + "\n")
129     # TODO: If all this ever does is just writing down what's in world_db, some
130     # loop over its entries should be all that's needed.
131
132
133 def obey_lines_in_file(path, name, do_record=False):
134     """Call obey() on each line of path's file, use name in input prefix."""
135     file = open(path, "r")
136     line_n = 1
137     for line in file.readlines():
138         obey(line.rstrip(), name + "file line " + str(line_n),
139              do_record=do_record)
140         line_n = line_n + 1
141     file.close()
142
143
144 def parse_command_line_arguments():
145     """Return settings values read from command line arguments."""
146     parser = argparse.ArgumentParser()
147     parser.add_argument('-s', nargs='?', type=int, dest='replay', const=1,
148                         action='store')
149     opts, unknown = parser.parse_known_args()
150     return opts
151
152
153 def server_test():
154     """Ensure valid server out file belonging to current process.
155
156     This is done by comparing io_db["teststring"] to what's found at the start
157     of the current file at io_db["path_out"]. On failure, set
158     io_db["kicked_by_rival"] and raise SystemExit.
159     """
160     if not os.access(io_db["path_out"], os.F_OK):
161         raise SystemExit("Server output file has disappeared.")
162     file = open(io_db["path_out"], "r")
163     test = file.readline().rstrip("\n")
164     file.close()
165     if test != io_db["teststring"]:
166         io_db["kicked_by_rival"] = True
167         msg = "Server test string in server output file does not match. This" \
168               " indicates that the current server process has been " \
169               "superseded by another one."
170         raise SystemExit(msg)
171
172
173 def read_command():
174     """Return next newline-delimited command from server in file.
175
176     Keep building return string until a newline is encountered. Pause between
177     unsuccessful reads, and after too much waiting, run server_test().
178     """
179     wait_on_fail = 1
180     max_wait = 5
181     now = time.time()
182     command = ""
183     while True:
184         add = io_db["file_in"].readline()
185         if len(add) > 0:
186             command = command + add
187             if len(command) > 0 and "\n" == command[-1]:
188                 command = command[:-1]
189                 break
190         else:
191             time.sleep(wait_on_fail)
192             if now + max_wait < time.time():
193                 server_test()
194                 now = time.time()
195     return command
196
197
198 def replay_game():
199     """Replay game from record file.
200
201     Use opts.replay as breakpoint turn to which to replay automatically before
202     switching to manual input by non-meta commands in server input file
203     triggering further reads of record file. Ensure opts.replay is at least 1.
204     """
205     if opts.replay < 1:
206         opts.replay = 1
207     print("Replay mode. Auto-replaying up to turn " + str(opts.replay) +
208           " (if so late a turn is to be found).")
209     if not os.access(io_db["path_record"], os.F_OK):
210         raise SystemExit("No record file found to replay.")
211     io_db["file_record"] = open(io_db["path_record"], "r")
212     io_db["file_record"].prefix = "record file line "
213     io_db["file_record"].line_n = 1
214     while world_db["TURN"] < opts.replay:
215         line = io_db["file_record"].readline()
216         if "" == line:
217             break
218         obey(line.rstrip(), io_db["file_record"].prefix
219              + str(io_db["file_record"].line_n))
220         io_db["file_record"].line_n = io_db["file_record"].line_n + 1
221     while True:
222         obey(read_command(), "in file", replay=True)
223
224
225 def play_game():
226     """Play game by server input file commands. Before, load save file found.
227
228     If no save file is found, a new world is generated from the commands in the
229     world config plus a 'MAKE WORLD [current Unix timestamp]'. Record this
230     command and all that follow via the server input file.
231     """
232     if os.access(io_db["path_save"], os.F_OK):
233         obey_lines_in_file(io_db["path_save"], "save")
234     else:
235         if not os.access(io_db["path_worldconf"], os.F_OK):
236             msg = "No world config file from which to start a new world."
237             raise SystemExit(msg)
238         obey_lines_in_file(io_db["path_worldconf"], "world config ",
239                            do_record=True)
240         obey("MAKE_WORLD " + str(int(time.time())), "in file", do_record=True)
241     while True:
242         obey(read_command(), "in file", do_record=True)
243
244
245 def remake_map():
246     # DUMMY.
247     print("I'd (re-)make the map now, if only I knew how.")
248
249
250 def set_world_inactive():
251     """Set world_db["WORLD_ACTIVE"] to 0 and remove worldstate file."""
252     server_test()
253     if os.access(io_db["path_worldstate"], os.F_OK):
254         os.remove(io_db["path_worldstate"])
255     world_db["WORLD_ACTIVE"] = 0
256
257
258 def integer_test(val_string, min, max):
259     """Return val_string if possible integer >= min and <= max, else False."""
260     try:
261         val = int(val_string)
262         if val < min or val > max:
263             raise ValueError
264         return val
265     except ValueError:
266         print("Ignoring: Please use integer >= " + str(min) + " and <= " +
267               str(max) + ".")
268         return False
269
270
271 def worlddb_value_setter(key, min, max):
272     """Generate: Set world_db[key] to int(val_string) if >= min and <= max."""
273     def func(val_string):
274         val = integer_test(val_string, min, max)
275         if val:
276             world_db[key] = val
277     return func
278
279
280 def command_ping():
281     """Send PONG line to server output file."""
282     io_db["file_out"].write("PONG\n")
283     io_db["file_out"].flush()
284
285
286 def command_quit():
287     """Abort server process."""
288     raise SystemExit("received QUIT command")
289
290
291 def command_seedmap(seed_string):
292     """Set world_db["SEED_MAP"] to int(seed_string), then (re-)make map."""
293     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
294     remake_map()
295
296
297 def command_makeworld(seed_string):
298     # DUMMY.
299     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
300     worlddb_value_setter("SEED_RANDOMNESS", 0, 4294967295)(seed_string)
301     # TODO: Test for existence of player thing and 'wait' thing action?
302
303
304 def command_maplength(maplength_string):
305     # DUMMY.
306     set_world_inactive()
307     # TODO: remove things, map
308     worlddb_value_setter("MAP_LENGTH", 1, 256)(maplength_string)
309
310
311 def command_worldactive(worldactive_string):
312     # DUMMY.
313     val = integer_test(worldactive_string, 0, 1)
314     if val:
315         if 0 != world_db["WORLD_ACTIVE"] and 0 == val:
316             set_world_inactive()
317         elif 0 == world_db["WORLD_ACTIVE"]:
318             wait_exists = False
319             player_exists = False
320             map_exists = False
321             # TODO: perform tests:
322             # Is there thing action of name 'wait'?
323             # Is there a player thing?
324             # Is there a map?
325             if wait_exists and player_exists and map_exists:
326                 # TODO: rebuild al things' FOVs, map memories
327                 world_db["WORLD_ACTIVE"] = 1
328
329
330 def command_taid(id_string):
331     """Set ID of ThingAction to manipulate. ID unused? Create new ThingAction.
332
333     The ID of the ThingAction to manipulate is stored as command_taid.id. If
334     the integer of the input value is valid (>= 0 and <= 255), but 0, a new ID
335     is calculated: The lowest unused ID >0 and <= 255. A new ThingAction's
336     "TA_EFFORT" defaults to 1, its "TA_NAME" to "wait".
337     """
338     id = integer_test(id_string, 0, 255)
339     if id:
340         if id in world_db["thing actions"]:
341             command_taid.id = id
342         else:
343
344             if 0 == id:
345                 while 1:
346                     id = id + 1
347                     if id not in world_db["thing actions"]:
348                         break
349                 if id > 255:
350                     print("Ignoring: "
351                           "No unused ID available to add to ID list.")
352                     return
353             world_db["thing actions"][id] = {
354                 "TA_EFFORT": 1,
355                 "TA_NAME": "wait"
356             }
357             command_taid.id = id
358
359
360 """Commands database.
361
362 Map command start tokens to ([0]) number of expected command arguments, ([1])
363 the command's meta-ness (i.e. is it to be written to the record file, is it to
364 be ignored in replay mode if read from server input file), and ([2]) a function
365 to be called on it.
366 """
367 commands_db = {
368     "QUIT": (0, True, command_quit),
369     "PING": (0, True, command_ping),
370     "MAKE_WORLD": (1, False, command_makeworld),
371     "SEED_MAP": (1, False, command_seedmap),
372     "SEED_RANDOMNESS": (1, False, worlddb_value_setter("SEED_RANDOMNESS", 0,
373                                                        4294967295)),
374     "TURN": (1, False, worlddb_value_setter("TURN", 0, 65535)),
375     "PLAYER_TYPE": (1, False, worlddb_value_setter("PLAYER_TYPE", 0, 255)),
376     "MAP_LENGTH": (1, False, command_maplength),
377     "WORLD_ACTIVE": (1, False, command_worldactive),
378     "TA_ID": (1, False, command_taid)
379 }
380
381
382 """World state database. With sane default values."""
383 world_db = {
384     "TURN": 1,
385     "SEED_MAP": 0,
386     "SEED_RANDOMNESS": 0,
387     "PLAYER_TYPE": 0,
388     "MAP_LENGTH": 64,
389     "WORLD_ACTIVE": 0,
390     "thing actions": {},
391     "thing types": {},
392     "things": {}
393 }
394
395
396 """File IO database."""
397 io_db = {
398     "path_save": "save",
399     "path_record": "record",
400     "path_worldconf": "confserver/world",
401     "path_server": "server/",
402     "path_in": "server/in",
403     "path_out": "server/out",
404     "path_worldstate": "server/worldstate",
405     "tmp_suffix": "_tmp",
406     "kicked_by_rival": False
407 }
408
409
410 try:
411     opts = parse_command_line_arguments()
412     setup_server_io()
413     # print("DUMMY: Run game.")
414     if None != opts.replay:
415         replay_game()
416     else:
417         play_game()
418 except SystemExit as exit:
419     print("ABORTING: " + exit.args[0])
420 except:
421     print("SOMETHING WENT WRONG IN UNEXPECTED WAYS")
422     raise
423 finally:
424     cleanup_server_io()
425     # print("DUMMY: (Clean up C heap.)")