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