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