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