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