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