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