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