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