home · contact · privacy
Server/py: Some refactoring.
[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     elif 0 != len(tokens):
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     string = ""
123     for key in world_db:
124         if dict != type(world_db[key]):
125             string = string + key + " " + str(world_db[key]) + "\n"
126     for id in world_db["ThingActions"]:
127         string = string + "TA_ID " + str(id) + "\n"
128         for key in world_db["ThingActions"][id]:
129             val = world_db["ThingActions"][id][key]
130             argument = "'" + val + "'" if str == type(val) else str(val)
131             string = string + key + " " + argument + "\n"
132     for id in world_db["ThingTypes"]:
133         string = string + "TT_ID " + str(id) + "\n"
134         for key in world_db["ThingTypes"][id]:
135             if not "TT_CORPSE_ID" == key:
136                 val = world_db["ThingTypes"][id][key]
137                 argument = "'" + val + "'" if str == type(val) else str(val)
138                 string = string + key + " " + argument + "\n"
139     for id in world_db["ThingTypes"]:
140         string = string + "TT_ID " + str(id) + "\n" + "TT_CORPSE_ID " + \
141                  str(world_db["ThingTypes"][id]["TT_CORPSE_ID"]) + "\n"
142     atomic_write(io_db["path_save"], string)
143
144
145 def obey_lines_in_file(path, name, do_record=False):
146     """Call obey() on each line of path's file, use name in input prefix."""
147     file = open(path, "r")
148     line_n = 1
149     for line in file.readlines():
150         obey(line.rstrip(), name + "file line " + str(line_n),
151              do_record=do_record)
152         line_n = line_n + 1
153     file.close()
154
155
156 def parse_command_line_arguments():
157     """Return settings values read from command line arguments."""
158     parser = argparse.ArgumentParser()
159     parser.add_argument('-s', nargs='?', type=int, dest='replay', const=1,
160                         action='store')
161     opts, unknown = parser.parse_known_args()
162     return opts
163
164
165 def server_test():
166     """Ensure valid server out file belonging to current process.
167
168     This is done by comparing io_db["teststring"] to what's found at the start
169     of the current file at io_db["path_out"]. On failure, set
170     io_db["kicked_by_rival"] and raise SystemExit.
171     """
172     if not os.access(io_db["path_out"], os.F_OK):
173         raise SystemExit("Server output file has disappeared.")
174     file = open(io_db["path_out"], "r")
175     test = file.readline().rstrip("\n")
176     file.close()
177     if test != io_db["teststring"]:
178         io_db["kicked_by_rival"] = True
179         msg = "Server test string in server output file does not match. This" \
180               " indicates that the current server process has been " \
181               "superseded by another one."
182         raise SystemExit(msg)
183
184
185 def read_command():
186     """Return next newline-delimited command from server in file.
187
188     Keep building return string until a newline is encountered. Pause between
189     unsuccessful reads, and after too much waiting, run server_test().
190     """
191     wait_on_fail = 1
192     max_wait = 5
193     now = time.time()
194     command = ""
195     while True:
196         add = io_db["file_in"].readline()
197         if len(add) > 0:
198             command = command + add
199             if len(command) > 0 and "\n" == command[-1]:
200                 command = command[:-1]
201                 break
202         else:
203             time.sleep(wait_on_fail)
204             if now + max_wait < time.time():
205                 server_test()
206                 now = time.time()
207     return command
208
209
210 def replay_game():
211     """Replay game from record file.
212
213     Use opts.replay as breakpoint turn to which to replay automatically before
214     switching to manual input by non-meta commands in server input file
215     triggering further reads of record file. Ensure opts.replay is at least 1.
216     """
217     if opts.replay < 1:
218         opts.replay = 1
219     print("Replay mode. Auto-replaying up to turn " + str(opts.replay) +
220           " (if so late a turn is to be found).")
221     if not os.access(io_db["path_record"], os.F_OK):
222         raise SystemExit("No record file found to replay.")
223     io_db["file_record"] = open(io_db["path_record"], "r")
224     io_db["file_record"].prefix = "record file line "
225     io_db["file_record"].line_n = 1
226     while world_db["TURN"] < opts.replay:
227         line = io_db["file_record"].readline()
228         if "" == line:
229             break
230         obey(line.rstrip(), io_db["file_record"].prefix
231              + str(io_db["file_record"].line_n))
232         io_db["file_record"].line_n = io_db["file_record"].line_n + 1
233     while True:
234         obey(read_command(), "in file", replay=True)
235
236
237 def play_game():
238     """Play game by server input file commands. Before, load save file found.
239
240     If no save file is found, a new world is generated from the commands in the
241     world config plus a 'MAKE WORLD [current Unix timestamp]'. Record this
242     command and all that follow via the server input file.
243     """
244     if os.access(io_db["path_save"], os.F_OK):
245         obey_lines_in_file(io_db["path_save"], "save")
246     else:
247         if not os.access(io_db["path_worldconf"], os.F_OK):
248             msg = "No world config file from which to start a new world."
249             raise SystemExit(msg)
250         obey_lines_in_file(io_db["path_worldconf"], "world config ",
251                            do_record=True)
252         obey("MAKE_WORLD " + str(int(time.time())), "in file", do_record=True)
253     while True:
254         obey(read_command(), "in file", do_record=True)
255
256
257 def remake_map():
258     # DUMMY.
259     print("I'd (re-)make the map now, if only I knew how.")
260
261
262 def set_world_inactive():
263     """Set world_db["WORLD_ACTIVE"] to 0 and remove worldstate file."""
264     server_test()
265     if os.access(io_db["path_worldstate"], os.F_OK):
266         os.remove(io_db["path_worldstate"])
267     world_db["WORLD_ACTIVE"] = 0
268
269
270 def integer_test(val_string, min, max):
271     """Return val_string if possible integer >= min and <= max, else None."""
272     try:
273         val = int(val_string)
274         if val < min or val > max:
275             raise ValueError
276         return val
277     except ValueError:
278         print("Ignoring: Please use integer >= " + str(min) + " and <= " +
279               str(max) + ".")
280         return None
281
282
283 def worlddb_value_setter(key, min, max):
284     """Generate: Set world_db[key] to int(val_string) if >= min and <= max."""
285     def func(val_string):
286         val = integer_test(val_string, min, max)
287         if val:
288             world_db[key] = val
289     return func
290
291
292 def command_ping():
293     """Send PONG line to server output file."""
294     io_db["file_out"].write("PONG\n")
295     io_db["file_out"].flush()
296
297
298 def command_quit():
299     """Abort server process."""
300     raise SystemExit("received QUIT command")
301
302
303 def command_seedmap(seed_string):
304     """Set world_db["SEED_MAP"] to int(seed_string), then (re-)make map."""
305     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
306     remake_map()
307
308
309 def command_makeworld(seed_string):
310     # DUMMY.
311     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
312     worlddb_value_setter("SEED_RANDOMNESS", 0, 4294967295)(seed_string)
313     # TODO: Test for existence of player thing and 'wait' thing action?
314
315
316 def command_maplength(maplength_string):
317     # DUMMY.
318     set_world_inactive()
319     # TODO: remove things, map
320     worlddb_value_setter("MAP_LENGTH", 1, 256)(maplength_string)
321
322
323 def command_worldactive(worldactive_string):
324     # DUMMY.
325     val = integer_test(worldactive_string, 0, 1)
326     if val:
327         if 0 != world_db["WORLD_ACTIVE"] and 0 == val:
328             set_world_inactive()
329         elif 0 == world_db["WORLD_ACTIVE"]:
330             wait_exists = False
331             player_exists = False
332             map_exists = False
333             # TODO: perform tests:
334             # Is there thing action of name 'wait'?
335             # Is there a player thing?
336             # Is there a map?
337             if wait_exists and player_exists and map_exists:
338                 # TODO: rebuild al things' FOVs, map memories
339                 world_db["WORLD_ACTIVE"] = 1
340
341
342 def command_ttid(id_string):
343     """Set ID of ThingType to manipulate. ID unused? Create new ThingType.
344
345     The ID of the ThingType to manipulate is stored as command_ttid.id. If
346     the integer of the input value is valid (>= -32768 and <= 32767), but <0 or
347     >255, a new ID is calculated: the lowest unused ID >=0 and <= 255.
348     """
349     id = integer_test(id_string, -32768, 32767)
350     if None != id:
351         if id in world_db["ThingTypes"]:
352             command_ttid.id = id
353         else:
354             if id < 0 or id > 255:
355                 id = -1
356                 while 1:
357                     id = id + 1
358                     if id not in world_db["ThingTypes"]:
359                         break
360                 if id > 255:
361                     print("Ignoring: "
362                           "No unused ID available to add to ID list.")
363                     return
364             command_ttid.id = id
365             world_db["ThingTypes"][id] = {
366                 "TT_NAME": "(none)",
367                 "TT_CONSUMABLE": 0,
368                 "TT_LIFEPOINTS": 0,
369                 "TT_PROLIFERATE": 0,
370                 "TT_START_NUMBER": 0,
371                 "TT_SYMBOL": "?",
372                 "TT_CORPSE_ID": id
373             }
374
375
376 def test_for_id_maker(object, category):
377     """Return decorator testing for object having "id" attribute."""
378     def decorator(f):
379         def helper(*args):
380             if hasattr(object, "id"):
381                 f(*args)
382             else:
383                 print("Ignoring: No " + category +
384                       " defined to manipulate yet.")
385         return helper
386     return decorator
387
388
389 test_ThingType_id = test_for_id_maker(command_ttid, "ThingType")
390
391
392 def ThingType_value_setter(key, min, max):
393     """Build: Set selected ThingType's [key] to int(val_string) >=min/<=max."""
394     @test_ThingType_id
395     def f(val_string):
396         val = integer_test(val_string, min, max)
397         if None != val:
398             world_db["ThingTypes"][command_ttid.id][key] = val
399     return f
400
401
402 @test_ThingType_id
403 def command_ttname(name):
404     """Set to name TT_NAME of selected ThingType."""
405     world_db["ThingTypes"][command_ttid.id]["TT_NAME"] = name
406
407
408 @test_ThingType_id
409 def command_ttsymbol(char):
410     """Set to char TT_SYMBOL of selected ThingType. """
411     if 1 == len(char):
412         world_db["ThingTypes"][command_ttid.id]["TT_SYMBOL"] = char
413     else:
414         print("Ignoring: Argument must be single character.")
415
416
417 @test_ThingType_id
418 def command_ttcorpseid(str_int):
419     """Set to int(str_int) TT_CORPSE_ID of selected ThingType."""
420     val = integer_test(str_int, 0, 255)
421     if None != val:
422         if val in world_db["ThingTypes"]:
423             world_db["ThingTypes"][command_ttid.id]["TT_CORPSE_ID"] = val
424         else:
425             print("Corpse ID belongs to no known object type.")
426
427
428 def command_taid(id_string):
429     """Set ID of ThingAction to manipulate. ID unused? Create new ThingAction.
430
431     The ID of the ThingAction to manipulate is stored as command_taid.id. If
432     the integer of the input value is valid (>= 0 and <= 255), but 0, a new ID
433     is calculated: The lowest unused ID >0 and <= 255.
434     """
435     id = integer_test(id_string, 0, 255)
436     if None != id:
437         if id in world_db["ThingActions"]:
438             command_taid.id = id
439         else:
440             if 0 == id:
441                 while 1:
442                     id = id + 1
443                     if id not in world_db["ThingActions"]:
444                         break
445                 if id > 255:
446                     print("Ignoring: "
447                           "No unused ID available to add to ID list.")
448                     return
449             command_taid.id = id
450             world_db["ThingActions"][id] = {
451                 "TA_EFFORT": 1,
452                 "TA_NAME": "wait"
453             }
454
455
456 test_ThingAction_id = test_for_id_maker(command_taid, "ThingAction")
457
458
459 @test_ThingAction_id
460 def command_taeffort(str_int):
461     """Set to int(str_int) TA_EFFORT of selected ThingAction."""
462     val = integer_test(str_int, 0, 255)
463     if None != val:
464         world_db["ThingActions"][command_taid.id]["TA_EFFORT"] = val
465
466
467 @test_ThingAction_id
468 def command_taname(name):
469     """Set to name TA_NAME of selected ThingAction.
470
471     The name must match a valid thing action function. If after the name
472     setting no ThingAction with name "wait" remains, call set_world_inactive().
473     """
474     if name == "wait" or name == "move" or name == "use" or name == "drop" \
475        or name == "pick_up":
476         world_db["ThingActions"][command_taid.id]["TA_NAME"] = name
477         if 1 == world_db["WORLD_ACTIVE"]:
478             wait_defined = False
479             for id in world_db["ThingActions"]:
480                 if "wait" == world_db["ThingActions"][id]["TA_NAME"]:
481                     wait_defined = True
482                     break
483             if not wait_defined:
484                 set_world_inactive()
485     else:
486         print("Ignoring: Invalid action name.")
487     # In contrast to the original,naming won't map a function to a ThingAction.
488
489
490 """Commands database.
491
492 Map command start tokens to ([0]) number of expected command arguments, ([1])
493 the command's meta-ness (i.e. is it to be written to the record file, is it to
494 be ignored in replay mode if read from server input file), and ([2]) a function
495 to be called on it.
496 """
497 commands_db = {
498     "QUIT": (0, True, command_quit),
499     "PING": (0, True, command_ping),
500     "MAKE_WORLD": (1, False, command_makeworld),
501     "SEED_MAP": (1, False, command_seedmap),
502     "SEED_RANDOMNESS": (1, False, worlddb_value_setter("SEED_RANDOMNESS",
503                                                        0, 4294967295)),
504     "TURN": (1, False, worlddb_value_setter("TURN", 0, 65535)),
505     "PLAYER_TYPE": (1, False, worlddb_value_setter("PLAYER_TYPE", 0, 255)),
506     "MAP_LENGTH": (1, False, command_maplength),
507     "WORLD_ACTIVE": (1, False, command_worldactive),
508     "TA_ID": (1, False, command_taid),
509     "TA_EFFORT": (1, False, command_taeffort),
510     "TA_NAME": (1, False, command_taname),
511     "TT_ID": (1, False, command_ttid),
512     "TT_NAME": (1, False, command_ttname),
513     "TT_SYMBOL": (1, False, command_ttsymbol),
514     "TT_CORPSE_ID": (1, False, command_ttcorpseid),
515     "TT_CONSUMABLE": (1, False, ThingType_value_setter("TT_CONSUMABLE",
516                                                        0, 65535)),
517     "TT_START_NUMBER": (1, False, ThingType_value_setter("TT_START_NUMBER",
518                                                          0, 255)),
519     "TT_PROLIFERATE": (1, False, ThingType_value_setter("TT_PROLIFERATE",
520                                                         0, 255)),
521     "TT_LIFEPOINTS": (1, False, ThingType_value_setter("TT_LIFEPOINTS",
522                                                        0, 255))
523 }
524
525
526 """World state database. With sane default values."""
527 world_db = {
528     "TURN": 1,
529     "SEED_MAP": 0,
530     "SEED_RANDOMNESS": 0,
531     "PLAYER_TYPE": 0,
532     "MAP_LENGTH": 64,
533     "WORLD_ACTIVE": 0,
534     "ThingActions": {},
535     "ThingTypes": {},
536     "Things": {}
537 }
538
539
540 """File IO database."""
541 io_db = {
542     "path_save": "save",
543     "path_record": "record",
544     "path_worldconf": "confserver/world",
545     "path_server": "server/",
546     "path_in": "server/in",
547     "path_out": "server/out",
548     "path_worldstate": "server/worldstate",
549     "tmp_suffix": "_tmp",
550     "kicked_by_rival": False
551 }
552
553
554 try:
555     opts = parse_command_line_arguments()
556     setup_server_io()
557     # print("DUMMY: Run game.")
558     if None != opts.replay:
559         replay_game()
560     else:
561         play_game()
562 except SystemExit as exit:
563     print("ABORTING: " + exit.args[0])
564 except:
565     print("SOMETHING WENT WRONG IN UNEXPECTED WAYS")
566     raise
567 finally:
568     cleanup_server_io()
569     # print("DUMMY: (Clean up C heap.)")