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