home · contact · privacy
6ccd7a1ca2bd7be5632c1b582c8a71f04ed0d3a8
[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     atomic_write(io_db["path_save"],
123                  "MAP_LENGTH " + str(world_db["MAP_LENGTH"]) + "\n" +
124                  "PLAYER_TYPE " + str(world_db["PLAYER_TYPE"]) + "\n" +
125                  "TURN " + str(world_db["TURN"]) + "\n" +
126                  "SEED_RANDOMNESS " + str(world_db["SEED_RANDOMNESS"]) + "\n" +
127                  "SEED_MAP " + str(world_db["SEED_MAP"]) + "\n")
128
129
130 def obey_lines_in_file(path, name, do_record=False):
131     """Call obey() on each line of path's file, use name in input prefix."""
132     file = open(path, "r")
133     line_n = 1
134     for line in file.readlines():
135         obey(line.rstrip(), name + "file line " + str(line_n),
136              do_record=do_record)
137         line_n = line_n + 1
138     file.close()
139
140
141 def parse_command_line_arguments():
142     """Return settings values read from command line arguments."""
143     parser = argparse.ArgumentParser()
144     parser.add_argument('-s', nargs='?', type=int, dest='replay', const=1,
145                         action='store')
146     opts, unknown = parser.parse_known_args()
147     return opts
148
149
150 def server_test():
151     """Ensure valid server out file belonging to current process.
152
153     This is done by comparing io_db["teststring"] to what's found at the start
154     of the current file at io_db["path_out"]. On failure, set
155     io_db["kicked_by_rival"] and raise SystemExit.
156     """
157     if not os.access(io_db["path_out"], os.F_OK):
158         raise SystemExit("Server output file has disappeared.")
159     file = open(io_db["path_out"], "r")
160     test = file.readline().rstrip("\n")
161     file.close()
162     if test != io_db["teststring"]:
163         io_db["kicked_by_rival"] = True
164         msg = "Server test string in server output file does not match. This" \
165               " indicates that the current server process has been " \
166               "superseded by another one."
167         raise SystemExit(msg)
168
169
170 def read_command():
171     """Return next newline-delimited command from server in file.
172
173     Keep building return string until a newline is encountered. Pause between
174     unsuccessful reads, and after too much waiting, run server_test().
175     """
176     wait_on_fail = 1
177     max_wait = 5
178     now = time.time()
179     command = ""
180     while True:
181         add = io_db["file_in"].readline()
182         if len(add) > 0:
183             command = command + add
184             if len(command) > 0 and "\n" == command[-1]:
185                 command = command[:-1]
186                 break
187         else:
188             time.sleep(wait_on_fail)
189             if now + max_wait < time.time():
190                 server_test()
191                 now = time.time()
192     return command
193
194
195 def replay_game():
196     """Replay game from record file.
197
198     Use opts.replay as breakpoint turn to which to replay automatically before
199     switching to manual input by non-meta commands in server input file
200     triggering further reads of record file. Ensure opts.replay is at least 1.
201     """
202     if opts.replay < 1:
203         opts.replay = 1
204     print("Replay mode. Auto-replaying up to turn " + str(opts.replay) +
205           " (if so late a turn is to be found).")
206     if not os.access(io_db["path_record"], os.F_OK):
207         raise SystemExit("No record file found to replay.")
208     io_db["file_record"] = open(io_db["path_record"], "r")
209     io_db["file_record"].prefix = "record file line "
210     io_db["file_record"].line_n = 1
211     while world_db["TURN"] < opts.replay:
212         line = io_db["file_record"].readline()
213         if "" == line:
214             break
215         obey(line.rstrip(), io_db["file_record"].prefix
216              + str(io_db["file_record"].line_n))
217         io_db["file_record"].line_n = io_db["file_record"].line_n + 1
218     while True:
219         obey(read_command(), "in file", replay=True)
220
221
222 def play_game():
223     """Play game by server input file commands. Before, load save file found.
224
225     If no save file is found, a new world is generated from the commands in the
226     world config plus a 'MAKE WORLD [current Unix timestamp]'. Record this
227     command and all that follow via the server input file.
228     """
229     if os.access(io_db["path_save"], os.F_OK):
230         obey_lines_in_file(io_db["path_save"], "save")
231     else:
232         if not os.access(io_db["path_worldconf"], os.F_OK):
233             msg = "No world config file from which to start a new world."
234             raise SystemExit(msg)
235         obey_lines_in_file(io_db["path_worldconf"], "world config ",
236                            do_record=True)
237         obey("MAKE_WORLD " + str(int(time.time())), "in file", do_record=True)
238     while True:
239         obey(read_command(), "in file", do_record=True)
240
241
242 def remake_map():
243     # DUMMY.
244     print("I'd (re-)make the map now, if only I knew how.")
245
246
247 def worlddb_value_setter(key, min, max):
248     """Generate: Set world_db[key] to int(val_string) if >= min and <= max."""
249     def func(val_string):
250         try:
251             val = int(val_string)
252             if val < min or val > max:
253                 raise ValueError
254             world_db[key] = val
255         except ValueError:
256             print("Ignoring: Please use integer >= " + str(min) + " and <= " +
257                   str(max) + ".")
258     return func
259
260
261 def command_ping():
262     """Send PONG line to server output file."""
263     io_db["file_out"].write("PONG\n")
264     io_db["file_out"].flush()
265
266
267 def command_quit():
268     """Abort server process."""
269     raise SystemExit("received QUIT command")
270
271
272 def command_seedmap(seed_string):
273     """Set world_db["SEED_MAP"] to int(seed_string), then (re-)make map."""
274     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
275     remake_map()
276
277
278 def command_makeworld(seed_string):
279     # DUMMY.
280     worlddb_value_setter("SEED_MAP", 0, 4294967295)(seed_string)
281     worlddb_value_setter("SEED_RANDOMNESS", 0, 4294967295)(seed_string)
282
283
284 def command_maplength(maplength_string):
285     # DUMMY.
286     worlddb_value_setter("MAP_LENGTH", 1, 256)(maplength_string)
287
288
289 """Commands database.
290
291 Map command start tokens to ([0]) number of expected command arguments, ([1])
292 the command's meta-ness (i.e. is it to be written to the record file, is it to
293 be ignored in replay mode if read from server input file), and ([2]) a function
294 to be called on it.
295 """
296 commands_db = {
297     "QUIT": (0, True, command_quit),
298     "PING": (0, True, command_ping),
299     "MAKE_WORLD": (1, False, command_makeworld),
300     "SEED_MAP": (1, False, command_seedmap),
301     "SEED_RANDOMNESS": (1, False, worlddb_value_setter("SEED_RANDOMNESS", 0,
302                                                        4294967295)),
303     "TURN": (1, False, worlddb_value_setter("TURN", 0, 65535)),
304     "PLAYER_TYPE": (1, False, worlddb_value_setter("PLAYER_TYPE", 0, 255)),
305     "MAP_LENGTH": (1, False, command_maplength)
306 }
307
308
309 """World state database,"""
310 world_db = {
311     "TURN": 0,
312     "SEED_MAP": 0,
313     "SEED_RANDOMNESS": 0,
314     "PLAYER_TYPE": 0,
315     "MAP_LENGTH": 64
316 }
317
318
319 """File IO database."""
320 io_db = {
321     "path_save": "save",
322     "path_record": "record",
323     "path_worldconf": "confserver/world",
324     "path_server": "server/",
325     "path_in": "server/in",
326     "path_out": "server/out",
327     "path_worldstate": "server/worldstate",
328     "tmp_suffix": "_tmp",
329     "kicked_by_rival": False
330 }
331
332
333 try:
334     opts = parse_command_line_arguments()
335     setup_server_io()
336     # print("DUMMY: Run game.")
337     if None != opts.replay:
338         replay_game()
339     else:
340         play_game()
341 except SystemExit as exit:
342     print("ABORTING: " + exit.args[0])
343 except:
344     print("SOMETHING WENT WRONG IN UNEXPECTED WAYS")
345     raise
346 finally:
347     cleanup_server_io()
348     # print("DUMMY: (Clean up C heap.)")