home · contact · privacy
Server: Add save hook.
[plomrogue] / server / io.py
1 # This file is part of PlomRogue. PlomRogue is licensed under the GPL version 3
2 # or any later version. For details on its copyright, license, and warranties,
3 # see the file NOTICE in the root directory of the PlomRogue source package.
4
5
6 import os
7 import time
8
9 from server.config.world_data import world_db
10 from server.config.io import io_db
11
12
13 def server_test():
14     """Ensure valid server out file belonging to current process.
15
16     This is done by comparing io_db["teststring"] to what's found at the start
17     of the current file at io_db["path_out"]. On failure, set
18     io_db["kicked_by_rival"] and raise SystemExit.
19     """
20     if not os.access(io_db["path_out"], os.F_OK):
21         raise SystemExit("Server output file has disappeared.")
22     file = open(io_db["path_out"], "r")
23     test = file.readline().rstrip("\n")
24     file.close()
25     if test != io_db["teststring"]:
26         io_db["kicked_by_rival"] = True
27         msg = "Server test string in server output file does not match. This" \
28               " indicates that the current server process has been " \
29               "superseded by another one."
30         raise SystemExit(msg)
31
32
33 def safely_remove_worldstate_file():
34     from server.io import server_test
35     if os.access(io_db["path_worldstate"], os.F_OK):
36         server_test()
37         os.remove(io_db["path_worldstate"])
38
39
40 def atomic_write(path, text, do_append=False, delete=True):
41     """Atomic write of text to file at path, appended if do_append is set."""
42     path_tmp = path + io_db["tmp_suffix"]
43     mode = "w"
44     if do_append:
45         mode = "a"
46         if os.access(path, os.F_OK):
47             from shutil import copyfile 
48             copyfile(path, path_tmp)
49     file = open(path_tmp, mode)
50     strong_write(file, text)
51     file.close()
52     if delete and os.access(path, os.F_OK):
53         os.remove(path)
54     os.rename(path_tmp, path)
55
56
57 def strong_write(file, string):
58     """Apply write(string), then flush()."""
59     file.write(string)
60     file.flush()
61
62
63 def setup_server_io():
64     """Fill IO files DB with proper file( path)s. Write process IO test string.
65
66     Ensure IO files directory at server/. Remove any old input file if found.
67     Set up new input file for reading, and new output file for appending. Start
68     output file with process hash line of format PID + " " + floated UNIX time
69     (io_db["teststring"]). Raise SystemExit if file is found at path of either
70     record or save file plus io_db["tmp_suffix"].
71     """
72     def detect_atomic_leftover(path, tmp_suffix):
73         path_tmp = path + tmp_suffix
74         msg = "Found file '" + path_tmp + "' that may be a leftover from an " \
75               "aborted previous attempt to write '" + path + "'. Aborting " \
76               "until matter is resolved by removing it from its current path."
77         if os.access(path_tmp, os.F_OK):
78             raise SystemExit(msg)
79     io_db["teststring"] = str(os.getpid()) + " " + str(time.time())
80     io_db["save_wait_start"] = 0
81     io_db["verbose"] = False
82     io_db["record_chunk"] = ""
83     os.makedirs(io_db["path_server"], exist_ok=True)
84     io_db["file_out"] = open(io_db["path_out"], "a")
85     strong_write(io_db["file_out"], io_db["teststring"] + "\n")
86     if os.access(io_db["path_in"], os.F_OK):
87         os.remove(io_db["path_in"])
88     io_db["file_in"] = open(io_db["path_in"], "w")
89     io_db["file_in"].close()
90     io_db["file_in"] = open(io_db["path_in"], "r")
91     detect_atomic_leftover(io_db["path_save"], io_db["tmp_suffix"])
92     detect_atomic_leftover(io_db["path_record"], io_db["tmp_suffix"])
93
94
95 def cleanup_server_io():
96     """Close and (if io_db["kicked_by_rival"] false) remove files in io_db."""
97     def helper(file_key, path_key):
98         if file_key in io_db:
99             io_db[file_key].close()
100         if not io_db["kicked_by_rival"] \
101            and os.access(io_db[path_key], os.F_OK):
102             os.remove(io_db[path_key])
103     helper("file_in", "path_in")
104     helper("file_out", "path_out")
105     helper("file_worldstate", "path_worldstate")
106     if "file_record" in io_db:
107         io_db["file_record"].close()
108
109 def read_command():
110     """Return next newline-delimited command from server in file.
111
112     Keep building return string until a newline is encountered. Pause between
113     unsuccessful reads, and after too much waiting, run server_test().
114     """
115     wait_on_fail = io_db["wait_on_read_fail"]
116     max_wait = io_db["max_wait_on_read_fail"] 
117     now = time.time()
118     command = ""
119     while True:
120         add = io_db["file_in"].readline()
121         if len(add) > 0:
122             command = command + add
123             if len(command) > 0 and "\n" == command[-1]:
124                 command = command[:-1]
125                 break
126         else:
127             time.sleep(wait_on_fail)
128             if now + max_wait < time.time():
129                 server_test()
130                 now = time.time()
131     return command
132
133
134 def log(msg):
135     """Send "msg" to log."""
136     strong_write(io_db["file_out"], "LOG " + msg + "\n")
137
138
139 def save_world():
140     """Save all commands needed to reconstruct current world state."""
141     from server.utils import rand
142
143     def quote_escape(string):
144         string = string.replace("\u005C", '\u005C\u005C')
145         return '"' + string.replace('"', '\u005C"') + '"'
146
147     def mapsetter(key):
148         def helper(id=None):
149             string = ""
150             if key == "MAP" or world_db["Things"][id][key]:
151                 map = world_db["MAP"] if key == "MAP" \
152                                       else world_db["Things"][id][key]
153                 length = world_db["MAP_LENGTH"]
154                 for i in range(length):
155                     line = map[i * length:(i * length) + length].decode()
156                     string = string + key + " " + str(i) + " " + \
157                         quote_escape(line) + "\n"
158             return string
159         return helper
160
161     def memthing(id):
162         string = ""
163         for memthing in world_db["Things"][id]["T_MEMTHING"]:
164             string = string + "T_MEMTHING " + str(memthing[0]) + " " + \
165                 str(memthing[1]) + " " + str(memthing[2]) + "\n"
166         return string
167
168     def helper(category, id_string, special_keys={}):
169         string = ""
170         for _id in sorted(world_db[category].keys()):
171             string = string + id_string + " " + str(_id) + "\n"
172             for key in sorted(world_db[category][_id].keys()):
173                 if key.isupper() and not key in special_keys:
174                     x = world_db[category][_id][key]
175                     argument = quote_escape(x) if str == type(x) else str(x)
176                     string = string + key + " " + argument + "\n"
177         return string
178
179     def helper_things():
180         string = ""
181         memmap = mapsetter("T_MEMMAP")
182         memdepthmap = mapsetter("T_MEMDEPTHMAP")
183         for tid in sorted(world_db["Things"].keys()):
184             string += "T_ID " + str(tid) + "\n"
185             t = world_db["Things"][tid]
186             for key in sorted(t.keys()):
187                 if key not in {"T_CARRIES", "carried", "fovmap", "T_MEMMAP",
188                                "T_MEMTHING", "T_MEMDEPTHMAP", "pos"}:
189                     argument = t[key]
190                     string += key + " " + (quote_escape(argument) if \
191                             str == type(argument) else str(argument)) + "\n"
192             string += memthing(tid) + memmap(tid) + memdepthmap(tid)
193         return string
194
195     string = ""
196     for plugin in world_db["PLUGIN"]:
197         string = string + "PLUGIN " + plugin + "\n"
198     for key in sorted(world_db.keys()):
199         if (not isinstance(world_db[key], dict) and
200             not isinstance(world_db[key], list)) and key != "MAP" and \
201            key != "WORLD_ACTIVE" and key[0].isupper():
202             string = string + key + " " + str(world_db[key]) + "\n"
203     string = string + mapsetter("MAP")()
204     string = string + helper("ThingActions", "TA_ID")
205     string = string + helper("ThingTypes", "TT_ID", {"TT_CORPSE_ID": False})
206     for id in sorted(world_db["ThingTypes"].keys()):
207         string = string + "TT_ID " + str(id) + "\n" + "TT_CORPSE_ID " + \
208             str(world_db["ThingTypes"][id]["TT_CORPSE_ID"]) + "\n"
209     string += helper_things()
210     for tid in sorted(world_db["Things"].keys()):
211         if [] != world_db["Things"][tid]["T_CARRIES"]:
212             string = string + "T_ID " + str(tid) + "\n"
213             for carried in sorted(world_db["Things"][tid]["T_CARRIES"]):
214                 string = string + "T_CARRIES " + str(carried) + "\n"
215     string = string + io_db["hook_save"]()
216     string = string + "SEED_RANDOMNESS " + str(rand.seed) + "\n" + \
217         "WORLD_ACTIVE " + str(world_db["WORLD_ACTIVE"])
218     atomic_write(io_db["path_save"], string)
219
220
221 def obey(command, prefix, replay=False, do_record=False):
222     """Call function from commands_db mapped to command's first token.
223
224     Tokenize command string with shlex.split(comments=True). If replay is set,
225     a non-meta command from the commands_db merely triggers obey() on the next
226     command from the records file. If not, non-meta commands set
227     io_db["worldstate_updateable"] to world_db["WORLD_ACTIVE"], and, if
228     do_record is set, are recorded to io_db["record_chunk"], and save_world()
229     is called (and io_db["record_chunk"] written) if io_db["save_wait"] seconds
230     have passed since the last time it was called. The prefix string is
231     inserted into the server's input message between its beginning 'input ' and
232     ':'. All activity is preceded by a server_test() call. Commands that start
233     with a lowercase letter are ignored when world_db["WORLD_ACTIVE"] is
234     False/0.
235     """
236     import shlex
237     from server.config.commands import commands_db
238     server_test()
239     if io_db["verbose"]:
240         print("input " + prefix + ": " + command)
241     try:
242         tokens = shlex.split(command, comments=True)
243     except ValueError as err:
244         print("Can't tokenize command string: " + str(err) + ".")
245         return
246     if len(tokens) > 0 and tokens[0] in commands_db \
247        and len(tokens) == commands_db[tokens[0]][0] + 1:
248         if commands_db[tokens[0]][1]:
249             commands_db[tokens[0]][2](*tokens[1:])
250         elif tokens[0][0].islower() and not world_db["WORLD_ACTIVE"]:
251             print("Ignoring lowercase-starting commands when world inactive.")
252         elif replay:
253             print("Due to replay mode, reading command as 'go on in record'.")
254             line = io_db["file_record"].readline()
255             if len(line) > 0:
256                 obey(line.rstrip(), io_db["file_record"].prefix
257                      + str(io_db["file_record"].line_n))
258                 io_db["file_record"].line_n = io_db["file_record"].line_n + 1
259             else:
260                 print("Reached end of record file.")
261         else:
262             commands_db[tokens[0]][2](*tokens[1:])
263             if do_record:
264                 io_db["record_chunk"] += command + "\n"
265                 if time.time() > io_db["save_wait_start"] + io_db["save_wait"]:
266                     atomic_write(io_db["path_record"], io_db["record_chunk"],
267                                  do_append=True)
268                     if world_db["WORLD_ACTIVE"]:
269                         save_world()
270                     io_db["record_chunk"] = ""
271                     io_db["save_wait_start"] = time.time()
272             io_db["worldstate_updateable"] = world_db["WORLD_ACTIVE"]
273     elif 0 != len(tokens):
274         print("Invalid command/argument, or bad number of tokens: " + command)
275
276
277 def obey_lines_in_file(path, name, do_record=False):
278     """Call obey() on each line of path's file, use name in input prefix."""
279     file = open(path, "r")
280     line_n = 1
281     for line in file.readlines():
282         obey(line.rstrip(), name + "file line " + str(line_n),
283              do_record=do_record)
284         line_n = line_n + 1
285     file.close()
286
287
288 def try_worldstate_update():
289     """Write worldstate file if io_db["worldstate_updateable"] is set."""
290     if world_db["WORLD_ACTIVE"] and io_db["worldstate_updateable"]:
291         string = ""
292         for entry in io_db["worldstate_write_order"]:
293             if entry[1] == "world_int":
294                 string += str(world_db[entry[0]]) + "\n"
295             elif entry[1] == "player_int":
296                 string += str(world_db["Things"][0][entry[0]]) + "\n"
297             elif entry[1] == "func":
298                 string += entry[0]()
299         atomic_write(io_db["path_worldstate"], string, delete=False)
300         strong_write(io_db["file_out"], "WORLD_UPDATED\n")
301         io_db["worldstate_updateable"] = False