home · contact · privacy
Server/py: Add SEED_MAP and SEED_RANDOMNESS commands, refactor.
[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 command_ping():
241     """Send PONG line to server output file."""
242     io_db["file_out"].write("PONG\n")
243     io_db["file_out"].flush()
244
245
246 def command_quit():
247     """Abort server process."""
248     raise SystemExit("received QUIT command")
249
250
251 def set_world_db_integer_value(key, val_string, min, max):
252     """Set world_db[key] to int(val_string) if legal value >= min & <= max."""
253     try:
254         val = int(val_string)
255         if val < min or val > max:
256             raise ValueError
257         world_db[key] = val
258     except ValueError:
259         print("Ignoring: Please use integer >= " + str(min) + " and <= " +
260               "str(max)+ '.")
261
262
263 def command_turn(turn_string):
264     """Set turn to what's described in turn_string."""
265     set_world_db_integer_value("TURN", turn_string, 0, 65535)
266
267
268 def command_seedmap(mapseed_string):
269     """Set map seed to what's described in mapseed_string."""
270     set_world_db_integer_value("SEED_MAP", mapseed_string, 0, 4294967295)
271
272
273 def command_seedrandomness(randseed_string):
274     """Set randomness seed to what's described in randseed_string."""
275     set_world_db_integer_value("SEED_RANDOMNESS", randseed_string, 0,
276                                4294967295)
277
278
279 def command_makeworld(seed_string):
280     # Mere dummy so far.
281     set_world_db_integer_value("SEED_MAP", seed_string, 0, 4294967295)
282     set_world_db_integer_value("SEED_RANDOMNESS", seed_string, 0, 4294967295)
283
284
285 """Commands database.
286
287 Map command start tokens to ([0]) minimum number of expected command arguments,
288 ([1]) the command's meta-ness (i.e. is it to be written to the record file, is
289 it to be ignored in replay mode if read from server input file), and ([2]) a
290 function to be called on it.
291 """
292 commands_db = {
293     "QUIT": (0, True, command_quit),
294     "PING": (0, True, command_ping),
295     "MAKE_WORLD": (1, False, command_makeworld),
296     "SEED_MAP": (1, False, command_seedmap),
297     "SEED_RANDOMNESS": (1, False, command_seedrandomness),
298     "TURN": (1, False, command_turn)
299 }
300
301
302 """World state database,"""
303 world_db = {
304     "TURN": 0,
305     "SEED_MAP": 0,
306     "SEED_RANDOMNESS": 0
307 }
308
309
310 """File IO database."""
311 io_db = {
312     "path_save": "save",
313     "path_record": "record",
314     "path_worldconf": "confserver/world",
315     "path_server": "server/",
316     "path_in": "server/in",
317     "path_out": "server/out",
318     "path_worldstate": "server/worldstate",
319     "tmp_suffix": "_tmp",
320     "kicked_by_rival": False
321 }
322
323
324 try:
325     opts = parse_command_line_arguments()
326     setup_server_io()
327     # print("DUMMY: Run game.")
328     if None != opts.replay:
329         replay_game()
330     else:
331         play_game()
332 except SystemExit as exit:
333     print("ABORTING: " + exit.args[0])
334 except:
335     print("SOMETHING WENT WRONG IN UNEXPECTED WAYS")
336     raise
337 finally:
338     cleanup_server_io()
339     # print("DUMMY: (Clean up C heap.)")