home · contact · privacy
Server/py: More refactoring, documentation.
[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. Non-meta commands are recorded in
60     non-replay mode if do_record is set. The prefix string is inserted into the
61     server's input message between its beginning 'input ' and ':'. All activity
62     is preceded by a call to server_test().
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]()
86             if do_record:
87                 record(command)
88     else:
89         print("Invalid command/argument, or bad number of tokens.")
90
91
92 def record(command):
93     """Append command string plus newline to record file. (Atomic.)"""
94     # This misses some optimizations from the original record(), namely only
95     # finishing the atomic write with expensive flush() and fsync() every 15
96     # seconds unless explicitely forced. Implement as needed.
97     path_tmp = io_db["path_record"] + io_db["tmp_suffix"]
98     if os.access(io_db["path_record"], os.F_OK):
99         shutil.copyfile(io_db["path_record"], path_tmp)
100     file = open(path_tmp, "a")
101     file.write(command + "\n")
102     file.flush()
103     os.fsync(file.fileno())
104     file.close()
105     if os.access(io_db["path_record"], os.F_OK):
106         os.remove(io_db["path_record"])
107     os.rename(path_tmp, io_db["path_record"])
108
109
110 def obey_lines_in_file(path, name, do_record=False):
111     """Call obey() on each line of path's file, use name in input prefix."""
112     file = open(path, "r")
113     line_n = 1
114     for line in file.readlines():
115         obey(line.rstrip(), name + "file line " + str(line_n),
116              do_record=do_record)
117         line_n = line_n + 1
118     file.close()
119
120
121 def parse_command_line_arguments():
122     """Return settings values read from command line arguments."""
123     parser = argparse.ArgumentParser()
124     parser.add_argument('-s', nargs='?', type=int, dest='replay', const=1,
125                         action='store')
126     opts, unknown = parser.parse_known_args()
127     return opts
128
129
130 def server_test():
131     """Ensure valid server out file belonging to current process.
132
133     This is done by comparing io_db["teststring"] to what's found at the start
134     of the current file at io_db["path_out"]. On failure, set
135     io_db["kicked_by_rival"] and raise SystemExit.
136     """
137     if not os.access(io_db["path_out"], os.F_OK):
138         raise SystemExit("Server output file has disappeared.")
139     file = open(io_db["path_out"], "r")
140     test = file.readline().rstrip("\n")
141     file.close()
142     if test != io_db["teststring"]:
143         io_db["kicked_by_rival"] = True
144         msg = "Server test string in server output file does not match. This" \
145               " indicates that the current server process has been " \
146               "superseded by another one."
147         raise SystemExit(msg)
148
149
150 def read_command():
151     """Return next newline-delimited command from server in file.
152
153     Keep building return string until a newline is encountered. Pause between
154     unsuccessful reads, and after too much waiting, run server_test().
155     """
156     wait_on_fail = 1
157     max_wait = 5
158     now = time.time()
159     command = ""
160     while True:
161         add = io_db["file_in"].readline()
162         if len(add) > 0:
163             command = command + add
164             if len(command) > 0 and "\n" == command[-1]:
165                 command = command[:-1]
166                 break
167         else:
168             time.sleep(wait_on_fail)
169             if now + max_wait < time.time():
170                 server_test()
171                 now = time.time()
172     return command
173
174
175 def replay_game():
176     """Replay game from record file.
177
178     Use opts.replay as breakpoint turn to which to replay automatically before
179     switching to manual input by non-meta commands in server input file
180     triggering further reads of record file. Ensure opts.replay is at least 1.
181     """
182     if opts.replay < 1:
183         opts.replay = 1
184     print("Replay mode. Auto-replaying up to turn " + str(opts.replay) +
185           " (if so late a turn is to be found).")
186     if not os.access(io_db["path_record"], os.F_OK):
187         raise SystemExit("No record file found to replay.")
188     io_db["file_record"] = open(io_db["path_record"], "r")
189     io_db["file_record"].prefix = "recod file line "
190     io_db["file_record"].line_n = 1
191     while world_db["turn"] < opts.replay:
192         line = io_db["file_record"].readline()
193         if "" == line:
194             break
195         obey(line.rstrip(), io_db["file_record"].prefix
196              + str(io_db["file_record"].line_n))
197         io_db["file_record"].line_n = io_db["file_record"].line_n + 1
198     while True:
199         obey(read_command(), "in file", replay=True)
200
201
202 def play_game():
203     """Play game by server input file commands. Before, load save file found.
204
205     If no save file is found, a new world is generated from the commands in the
206     world config plus a 'MAKE WORLD [current Unix timestamp]'. Record this
207     command and all that follow via the server input file.
208     """
209     if os.access(io_db["path_save"], os.F_OK):
210         obey_lines_in_file(io_db["path_save"], "save")
211     else:
212         if not os.access(io_db["path_worldconf"], os.F_OK):
213             msg = "No world config file from which to start a new world."
214             raise SystemExit(msg)
215         obey_lines_in_file(io_db["path_worldconf"], "world config ",
216                            do_record=True)
217         obey("MAKE_WORLD " + str(int(time.time())), "in file", do_record=True)
218     while True:
219         obey(read_command(), "in file", do_record=True)
220
221
222 def command_makeworld():
223     """Mere dummy so far."""
224     print("I would build a whole world now if only I knew how.")
225
226
227 def command_ping():
228     """Send PONG line to server output file."""
229     io_db["file_out"].write("PONG\n")
230     io_db["file_out"].flush()
231
232
233 def command_quit():
234     """Abort server process."""
235     raise SystemExit("received QUIT command")
236
237
238 """Commands database.
239
240 Map command start tokens to ([0]) minimum number of expected command arguments,
241 ([1]) the command's meta-ness (i.e. is it to be written to the record file, is
242 it to be ignored in replay mode if read from server input file), and ([2]) a
243 function to be called on it.
244 """
245 commands_db = {
246     "QUIT": (0, True, command_quit),
247     "PING": (0, True, command_ping),
248     "MAKE_WORLD": (1, False, command_makeworld)
249 }
250
251
252 """World state database,"""
253 world_db = {
254     "turn": 0
255 }
256
257
258 """File IO database."""
259 io_db = {
260     "path_save": "save",
261     "path_record": "record",
262     "path_worldconf": "confserver/world",
263     "path_server": "server/",
264     "path_in": "server/in",
265     "path_out": "server/out",
266     "path_worldstate": "server/worldstate",
267     "tmp_suffix": "_tmp",
268     "kicked_by_rival": False
269 }
270
271
272 try:
273     opts = parse_command_line_arguments()
274     setup_server_io()
275     # print("DUMMY: Run game.")
276     if None != opts.replay:
277         replay_game()
278     else:
279         play_game()
280 except SystemExit as exit:
281     print("ABORTING: " + exit.args[0])
282 except:
283     print("SOMETHING WENT WRONG IN UNEXPECTED WAYS")
284     raise
285 finally:
286     cleanup_server_io()
287     # print("DUMMY: (Clean up C heap.)")