Track whether loading is underway
[mudpy.git] / mudpy / misc.py
1 """Miscellaneous functions for the mudpy engine."""
2
3 # Copyright (c) 2004-2017 Jeremy Stanley <fungi@yuggoth.org>. Permission
4 # to use, copy, modify, and distribute this software is granted under
5 # terms provided in the LICENSE file distributed with this software.
6
7 import codecs
8 import os
9 import random
10 import re
11 import signal
12 import socket
13 import sys
14 import syslog
15 import time
16 import traceback
17 import unicodedata
18
19 import mudpy
20
21
22 class Element:
23
24     """An element of the universe."""
25
26     def __init__(self, key, universe, filename=None, old_style=False):
27         """Set up a new element."""
28
29         # TODO(fungi): This can be removed after the transition is complete
30         self.old_style = old_style
31
32         # keep track of our key name
33         self.key = key
34
35         # keep track of what universe it's loading into
36         self.universe = universe
37
38         # clone attributes if this is replacing another element
39         if self.old_style and self.key in self.universe.contents:
40             old_element = self.universe.contents[self.key]
41             for attribute in vars(old_element).keys():
42                 exec("self." + attribute + " = old_element." + attribute)
43             if self.owner:
44                 self.owner.avatar = self
45
46         # i guess this is a new element then
47         else:
48
49             # set of facet keys from the universe
50             self.facethash = dict()
51
52             # not owned by a user by default (used for avatars)
53             self.owner = None
54
55             # no contents in here by default
56             self.contents = {}
57
58             # parse out appropriate category and subkey names, add to list
59             if self.key.find(":") > 0:
60                 self.category, self.subkey = self.key.split(":", 1)
61             else:
62                 self.category = "other"
63                 self.subkey = self.key
64             if self.category not in self.universe.categories:
65                 self.category = "other"
66                 self.subkey = self.key
67
68             # get an appropriate filename for the origin
69             if not filename:
70                 filename = self.universe.default_origins[self.category]
71             if not os.path.isabs(filename):
72                 filename = os.path.abspath(filename)
73
74             # add the file if it doesn't exist yet
75             if filename not in self.universe.files:
76                 mudpy.data.DataFile(filename, self.universe)
77
78         # record or reset a pointer to the origin file
79         self.origin = self.universe.files[filename]
80
81         # add a data section to the origin if necessary
82         if self.key not in self.origin.data:
83             self.origin.data[self.key] = {}
84
85         # add or replace this element in the universe
86         self.universe.contents[self.key] = self
87         self.universe.categories[self.category][self.subkey] = self
88
89     def reload(self):
90         """Create a new element and replace this one."""
91         Element(self.key, self.universe, self.origin.filename,
92                 old_style=self.old_style)
93         del(self)
94
95     def destroy(self):
96         """Remove an element from the universe and destroy it."""
97         del(self.origin.data[self.key])
98         del self.universe.categories[self.category][self.subkey]
99         del self.universe.contents[self.key]
100         del self
101
102     def facets(self):
103         """Return a list of non-inherited facets for this element."""
104         if self.old_style:
105             try:
106                 return self.origin.data[self.key].keys()
107             except (AttributeError, KeyError):
108                 return []
109         else:
110             return self.facethash
111
112     def has_facet(self, facet):
113         """Return whether the non-inherited facet exists."""
114         return facet in self.facets()
115
116     def remove_facet(self, facet):
117         """Remove a facet from the element."""
118         if self.has_facet(facet):
119             del(self.origin.data[self.key][facet])
120             self.origin.modified = True
121
122     def ancestry(self):
123         """Return a list of the element's inheritance lineage."""
124         if self.has_facet("inherit"):
125             ancestry = self.get("inherit")
126             if not ancestry:
127                 ancestry = []
128             for parent in ancestry[:]:
129                 ancestors = self.universe.contents[parent].ancestry()
130                 for ancestor in ancestors:
131                     if ancestor not in ancestry:
132                         ancestry.append(ancestor)
133             return ancestry
134         else:
135             return []
136
137     def get(self, facet, default=None):
138         """Retrieve values."""
139         if default is None:
140             default = ""
141         try:
142             if self.old_style:
143                 return self.origin.data[self.key][facet]
144             else:
145                 return self.origin.data[".".join((self.key, facet))]
146         except (KeyError, TypeError):
147             pass
148         if self.has_facet("inherit"):
149             for ancestor in self.ancestry():
150                 if self.universe.contents[ancestor].has_facet(facet):
151                     return self.universe.contents[ancestor].get(facet)
152         else:
153             return default
154
155     def set(self, facet, value):
156         """Set values."""
157         if facet in ["loglevel"]:
158             value = int(value)
159         if not self.has_facet(facet) or not self.get(facet) == value:
160             if self.old_style:
161                 if self.key not in self.origin.data:
162                     self.origin.data[self.key] = {}
163                 self.origin.data[self.key][facet] = value
164             else:
165                 node = ".".join((self.key, facet))
166                 self.origin.data[node] = value
167                 self.facethash[facet] = self.origin.data[node]
168             self.origin.modified = True
169
170     def append(self, facet, value):
171         """Append value to a list."""
172         newlist = self.get(facet)
173         if not newlist:
174             newlist = []
175         if type(newlist) is not list:
176             newlist = list(newlist)
177         newlist.append(value)
178         self.set(facet, newlist)
179
180     def send(
181         self,
182         message,
183         eol="$(eol)",
184         raw=False,
185         flush=False,
186         add_prompt=True,
187         just_prompt=False,
188         add_terminator=False,
189         prepend_padding=True
190     ):
191         """Convenience method to pass messages to an owner."""
192         if self.owner:
193             self.owner.send(
194                 message,
195                 eol,
196                 raw,
197                 flush,
198                 add_prompt,
199                 just_prompt,
200                 add_terminator,
201                 prepend_padding
202             )
203
204     def can_run(self, command):
205         """Check if the user can run this command object."""
206
207         # has to be in the commands category
208         if command not in self.universe.categories["command"].values():
209             result = False
210
211         # avatars of administrators can run any command
212         elif self.owner and self.owner.account.get("administrator"):
213             result = True
214
215         # everyone can run non-administrative commands
216         elif not command.get("administrative"):
217             result = True
218
219         # otherwise the command cannot be run by this actor
220         else:
221             result = False
222
223         # pass back the result
224         return result
225
226     def update_location(self):
227         """Make sure the location's contents contain this element."""
228         area = self.get("location")
229         if area in self.universe.contents:
230             self.universe.contents[area].contents[self.key] = self
231
232     def clean_contents(self):
233         """Make sure the element's contents aren't bogus."""
234         for element in self.contents.values():
235             if element.get("location") != self.key:
236                 del self.contents[element.key]
237
238     def go_to(self, area):
239         """Relocate the element to a specific area."""
240         current = self.get("location")
241         if current and self.key in self.universe.contents[current].contents:
242             del universe.contents[current].contents[self.key]
243         if area in self.universe.contents:
244             self.set("location", area)
245         self.universe.contents[area].contents[self.key] = self
246         self.look_at(area)
247
248     def go_home(self):
249         """Relocate the element to its default location."""
250         self.go_to(self.get("default_location"))
251         self.echo_to_location(
252             "You suddenly realize that " + self.get("name") + " is here."
253         )
254
255     def move_direction(self, direction):
256         """Relocate the element in a specified direction."""
257         motion = self.universe.contents["mudpy.movement.%s" % direction]
258         enter_term = motion.get("enter_term")
259         exit_term = motion.get("exit_term")
260         self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
261         self.send("You exit %s." % exit_term, add_prompt=False)
262         self.go_to(
263             self.universe.contents[
264                 self.get("location")].link_neighbor(direction)
265         )
266         self.echo_to_location("%s arrives from %s." % (
267             self.get("name"), enter_term))
268
269     def look_at(self, key):
270         """Show an element to another element."""
271         if self.owner:
272             element = self.universe.contents[key]
273             message = ""
274             name = element.get("name")
275             if name:
276                 message += "$(cyn)" + name + "$(nrm)$(eol)"
277             description = element.get("description")
278             if description:
279                 message += description + "$(eol)"
280             portal_list = list(element.portals().keys())
281             if portal_list:
282                 portal_list.sort()
283                 message += "$(cyn)[ Exits: " + ", ".join(
284                     portal_list
285                 ) + " ]$(nrm)$(eol)"
286             for element in self.universe.contents[
287                 self.get("location")
288             ].contents.values():
289                 if element.get("is_actor") and element is not self:
290                     message += "$(yel)" + element.get(
291                         "name"
292                     ) + " is here.$(nrm)$(eol)"
293                 elif element is not self:
294                     message += "$(grn)" + element.get(
295                         "impression"
296                     ) + "$(nrm)$(eol)"
297             self.send(message)
298
299     def portals(self):
300         """Map the portal directions for an area to neighbors."""
301         portals = {}
302         if re.match(r"""^area:-?\d+,-?\d+,-?\d+$""", self.key):
303             coordinates = [(int(x))
304                            for x in self.key.split(":")[1].split(",")]
305             offsets = dict(
306                 (x,
307                  self.universe.contents["mudpy.movement.%s" % x].get("vector")
308                  ) for x in self.universe.directions)
309             for portal in self.get("gridlinks"):
310                 adjacent = map(lambda c, o: c + o,
311                                coordinates, offsets[portal])
312                 neighbor = "area:" + ",".join(
313                     [(str(x)) for x in adjacent]
314                 )
315                 if neighbor in self.universe.contents:
316                     portals[portal] = neighbor
317         for facet in self.facets():
318             if facet.startswith("link_"):
319                 neighbor = self.get(facet)
320                 if neighbor in self.universe.contents:
321                     portal = facet.split("_")[1]
322                     portals[portal] = neighbor
323         return portals
324
325     def link_neighbor(self, direction):
326         """Return the element linked in a given direction."""
327         portals = self.portals()
328         if direction in portals:
329             return portals[direction]
330
331     def echo_to_location(self, message):
332         """Show a message to other elements in the current location."""
333         for element in self.universe.contents[
334             self.get("location")
335         ].contents.values():
336             if element is not self:
337                 element.send(message)
338
339
340 class Universe:
341
342     """The universe."""
343
344     def __init__(self, filename="", load=False):
345         """Initialize the universe."""
346         self.categories = {}
347         self.contents = {}
348         self.default_origins = {}
349         self.directions = set()
350         self.loading = False
351         self.loglines = []
352         self.private_files = []
353         self.reload_flag = False
354         self.setup_loglines = []
355         self.startdir = os.getcwd()
356         self.terminate_flag = False
357         self.userlist = []
358         if not filename:
359             possible_filenames = [
360                 "etc/mudpy.yaml",
361                 "/usr/local/mudpy/etc/mudpy.yaml",
362                 "/usr/local/etc/mudpy.yaml",
363                 "/etc/mudpy/mudpy.yaml",
364                 "/etc/mudpy.yaml"
365             ]
366             for filename in possible_filenames:
367                 if os.access(filename, os.R_OK):
368                     break
369         if not os.path.isabs(filename):
370             filename = os.path.join(self.startdir, filename)
371         self.filename = filename
372         if load:
373             # make sure to preserve any accumulated log entries during load
374             self.setup_loglines += self.load()
375
376     def load(self):
377         """Load universe data from persistent storage."""
378
379         # while loading, it's safe to update elements from read-only files
380         self.loading = True
381
382         # it's possible for this to enter before logging configuration is read
383         pending_loglines = []
384
385         # the files dict must exist and filename needs to be read-only
386         if not hasattr(
387            self, "files"
388            ) or not (
389             self.filename in self.files and self.files[
390                 self.filename
391             ].is_writeable()
392         ):
393
394             # clear out all read-only files
395             if hasattr(self, "files"):
396                 for data_filename in list(self.files.keys()):
397                     if not self.files[data_filename].is_writeable():
398                         del self.files[data_filename]
399
400             # start loading from the initial file
401             mudpy.data.DataFile(self.filename, self)
402
403         # make a list of inactive avatars
404         inactive_avatars = []
405         for account in self.categories["account"].values():
406             for avatar in account.get("avatars"):
407                 try:
408                     inactive_avatars.append(self.contents[avatar])
409                 except KeyError:
410                     pending_loglines.append((
411                         'Missing avatar "%s", possible data corruption' %
412                         avatar, 6))
413         for user in self.userlist:
414             if user.avatar in inactive_avatars:
415                 inactive_avatars.remove(user.avatar)
416
417         # go through all elements to clear out inactive avatar locations
418         for element in self.contents.values():
419             area = element.get("location")
420             if element in inactive_avatars and area:
421                 if area in self.contents and element.key in self.contents[
422                    area
423                    ].contents:
424                     del self.contents[area].contents[element.key]
425                 element.set("default_location", area)
426                 element.remove_facet("location")
427
428         # another pass to straighten out all the element contents
429         for element in self.contents.values():
430             element.update_location()
431             element.clean_contents()
432
433         # done loading, so disallow updating elements from read-only files
434         self.loading = False
435
436         return pending_loglines
437
438     def new(self):
439         """Create a new, empty Universe (the Big Bang)."""
440         new_universe = Universe()
441         for attribute in vars(self).keys():
442             exec("new_universe." + attribute + " = self." + attribute)
443         new_universe.reload_flag = False
444         del self
445         return new_universe
446
447     def save(self):
448         """Save the universe to persistent storage."""
449         for key in self.files:
450             self.files[key].save()
451
452     def initialize_server_socket(self):
453         """Create and open the listening socket."""
454
455         # need to know the local address and port number for the listener
456         host = self.contents["mudpy.network"].get("host")
457         port = self.contents["mudpy.network"].get("port")
458
459         # if no host was specified, bind to all local addresses (preferring
460         # ipv6)
461         if not host:
462             if socket.has_ipv6:
463                 host = "::"
464             else:
465                 host = "0.0.0.0"
466
467         # figure out if this is ipv4 or v6
468         family = socket.getaddrinfo(host, port)[0][0]
469         if family is socket.AF_INET6 and not socket.has_ipv6:
470             log("No support for IPv6 address %s (use IPv4 instead)." % host)
471
472         # create a new stream-type socket object
473         self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
474
475         # set the socket options to allow existing open ones to be
476         # reused (fixes a bug where the server can't bind for a minute
477         # when restarting on linux systems)
478         self.listening_socket.setsockopt(
479             socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
480         )
481
482         # bind the socket to to our desired server ipa and port
483         self.listening_socket.bind((host, port))
484
485         # disable blocking so we can proceed whether or not we can
486         # send/receive
487         self.listening_socket.setblocking(0)
488
489         # start listening on the socket
490         self.listening_socket.listen(1)
491
492         # note that we're now ready for user connections
493         log(
494             "Listening for Telnet connections on: " +
495             host + ":" + str(port)
496         )
497
498     def get_time(self):
499         """Convenience method to get the elapsed time counter."""
500         return self.categories["internal"]["counters"].get("elapsed")
501
502
503 class User:
504
505     """This is a connected user."""
506
507     def __init__(self):
508         """Default values for the in-memory user variables."""
509         self.account = None
510         self.address = ""
511         self.authenticated = False
512         self.avatar = None
513         self.columns = 79
514         self.connection = None
515         self.error = ""
516         self.input_queue = []
517         self.last_address = ""
518         self.last_input = universe.get_time()
519         self.menu_choices = {}
520         self.menu_seen = False
521         self.negotiation_pause = 0
522         self.output_queue = []
523         self.partial_input = b""
524         self.password_tries = 0
525         self.state = "initial"
526         self.telopts = {}
527
528     def quit(self):
529         """Log, close the connection and remove."""
530         if self.account:
531             name = self.account.get("name")
532         else:
533             name = ""
534         if name:
535             message = "User " + name
536         else:
537             message = "An unnamed user"
538         message += " logged out."
539         log(message, 2)
540         self.deactivate_avatar()
541         self.connection.close()
542         self.remove()
543
544     def check_idle(self):
545         """Warn or disconnect idle users as appropriate."""
546         idletime = universe.get_time() - self.last_input
547         linkdead_dict = universe.contents[
548             "mudpy.timing.idle.disconnect"].facets()
549         if self.state in linkdead_dict:
550             linkdead_state = self.state
551         else:
552             linkdead_state = "default"
553         if idletime > linkdead_dict[linkdead_state]:
554             self.send(
555                 "$(eol)$(red)You've done nothing for far too long... goodbye!"
556                 + "$(nrm)$(eol)",
557                 flush=True,
558                 add_prompt=False
559             )
560             logline = "Disconnecting "
561             if self.account and self.account.get("name"):
562                 logline += self.account.get("name")
563             else:
564                 logline += "an unknown user"
565             logline += (" after idling too long in the " + self.state
566                         + " state.")
567             log(logline, 2)
568             self.state = "disconnecting"
569             self.menu_seen = False
570         idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
571         if self.state in idle_dict:
572             idle_state = self.state
573         else:
574             idle_state = "default"
575         if idletime == idle_dict[idle_state]:
576             self.send(
577                 "$(eol)$(red)If you continue to be unproductive, "
578                 + "you'll be shown the door...$(nrm)$(eol)"
579             )
580
581     def reload(self):
582         """Save, load a new user and relocate the connection."""
583
584         # get out of the list
585         self.remove()
586
587         # create a new user object
588         new_user = User()
589
590         # set everything equivalent
591         for attribute in vars(self).keys():
592             exec("new_user." + attribute + " = self." + attribute)
593
594         # the avatar needs a new owner
595         if new_user.avatar:
596             new_user.avatar.owner = new_user
597
598         # add it to the list
599         universe.userlist.append(new_user)
600
601         # get rid of the old user object
602         del(self)
603
604     def replace_old_connections(self):
605         """Disconnect active users with the same name."""
606
607         # the default return value
608         return_value = False
609
610         # iterate over each user in the list
611         for old_user in universe.userlist:
612
613             # the name is the same but it's not us
614             if hasattr(
615                old_user, "account"
616                ) and old_user.account and old_user.account.get(
617                 "name"
618             ) == self.account.get(
619                 "name"
620             ) and old_user is not self:
621
622                 # make a note of it
623                 log(
624                     "User " + self.account.get(
625                         "name"
626                     ) + " reconnected--closing old connection to "
627                     + old_user.address + ".",
628                     2
629                 )
630                 old_user.send(
631                     "$(eol)$(red)New connection from " + self.address
632                     + ". Terminating old connection...$(nrm)$(eol)",
633                     flush=True,
634                     add_prompt=False
635                 )
636
637                 # close the old connection
638                 old_user.connection.close()
639
640                 # replace the old connection with this one
641                 old_user.send(
642                     "$(eol)$(red)Taking over old connection from "
643                     + old_user.address + ".$(nrm)"
644                 )
645                 old_user.connection = self.connection
646                 old_user.last_address = old_user.address
647                 old_user.address = self.address
648
649                 # take this one out of the list and delete
650                 self.remove()
651                 del(self)
652                 return_value = True
653                 break
654
655         # true if an old connection was replaced, false if not
656         return return_value
657
658     def authenticate(self):
659         """Flag the user as authenticated and disconnect duplicates."""
660         if self.state is not "authenticated":
661             log("User " + self.account.get("name") + " logged in.", 2)
662             self.authenticated = True
663             if ("mudpy.limit" in universe.contents and self.account.subkey in
664                     universe.contents["mudpy.limit"].get("admins")):
665                 self.account.set("administrator", "True")
666
667     def show_menu(self):
668         """Send the user their current menu."""
669         if not self.menu_seen:
670             self.menu_choices = get_menu_choices(self)
671             self.send(
672                 get_menu(self.state, self.error, self.menu_choices),
673                 "",
674                 add_terminator=True
675             )
676             self.menu_seen = True
677             self.error = False
678             self.adjust_echoing()
679
680     def adjust_echoing(self):
681         """Adjust echoing to match state menu requirements."""
682         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
683                                    mudpy.telnet.US):
684             if menu_echo_on(self.state):
685                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
686                                      mudpy.telnet.US)
687         elif not menu_echo_on(self.state):
688             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
689                                 mudpy.telnet.US)
690
691     def remove(self):
692         """Remove a user from the list of connected users."""
693         universe.userlist.remove(self)
694
695     def send(
696         self,
697         output,
698         eol="$(eol)",
699         raw=False,
700         flush=False,
701         add_prompt=True,
702         just_prompt=False,
703         add_terminator=False,
704         prepend_padding=True
705     ):
706         """Send arbitrary text to a connected user."""
707
708         # unless raw mode is on, clean it up all nice and pretty
709         if not raw:
710
711             # strip extra $(eol) off if present
712             while output.startswith("$(eol)"):
713                 output = output[6:]
714             while output.endswith("$(eol)"):
715                 output = output[:-6]
716             extra_lines = output.find("$(eol)$(eol)$(eol)")
717             while extra_lines > -1:
718                 output = output[:extra_lines] + output[extra_lines + 6:]
719                 extra_lines = output.find("$(eol)$(eol)$(eol)")
720
721             # start with a newline, append the message, then end
722             # with the optional eol string passed to this function
723             # and the ansi escape to return to normal text
724             if not just_prompt and prepend_padding:
725                 if (not self.output_queue or not
726                         self.output_queue[-1].endswith(b"\r\n")):
727                     output = "$(eol)" + output
728                 elif not self.output_queue[-1].endswith(
729                     b"\r\n\x1b[0m\r\n"
730                 ) and not self.output_queue[-1].endswith(
731                     b"\r\n\r\n"
732                 ):
733                     output = "$(eol)" + output
734             output += eol + chr(27) + "[0m"
735
736             # tack on a prompt if active
737             if self.state == "active":
738                 if not just_prompt:
739                     output += "$(eol)"
740                 if add_prompt:
741                     output += "> "
742                     mode = self.avatar.get("mode")
743                     if mode:
744                         output += "(" + mode + ") "
745
746             # find and replace macros in the output
747             output = replace_macros(self, output)
748
749             # wrap the text at the client's width (min 40, 0 disables)
750             if self.columns:
751                 if self.columns < 40:
752                     wrap = 40
753                 else:
754                     wrap = self.columns
755                 output = wrap_ansi_text(output, wrap)
756
757             # if supported by the client, encode it utf-8
758             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
759                                        mudpy.telnet.US):
760                 encoded_output = output.encode("utf-8")
761
762             # otherwise just send ascii
763             else:
764                 encoded_output = output.encode("ascii", "replace")
765
766             # end with a terminator if requested
767             if add_prompt or add_terminator:
768                 if mudpy.telnet.is_enabled(
769                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
770                     encoded_output += mudpy.telnet.telnet_proto(
771                         mudpy.telnet.IAC, mudpy.telnet.EOR)
772                 elif not mudpy.telnet.is_enabled(
773                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
774                     encoded_output += mudpy.telnet.telnet_proto(
775                         mudpy.telnet.IAC, mudpy.telnet.GA)
776
777             # and tack it onto the queue
778             self.output_queue.append(encoded_output)
779
780             # if this is urgent, flush all pending output
781             if flush:
782                 self.flush()
783
784         # just dump raw bytes as requested
785         else:
786             self.output_queue.append(output)
787             self.flush()
788
789     def pulse(self):
790         """All the things to do to the user per increment."""
791
792         # if the world is terminating, disconnect
793         if universe.terminate_flag:
794             self.state = "disconnecting"
795             self.menu_seen = False
796
797         # check for an idle connection and act appropriately
798         else:
799             self.check_idle()
800
801         # if output is paused, decrement the counter
802         if self.state == "initial":
803             if self.negotiation_pause:
804                 self.negotiation_pause -= 1
805             else:
806                 self.state = "entering_account_name"
807
808         # show the user a menu as needed
809         elif not self.state == "active":
810             self.show_menu()
811
812         # flush any pending output in the queue
813         self.flush()
814
815         # disconnect users with the appropriate state
816         if self.state == "disconnecting":
817             self.quit()
818
819         # check for input and add it to the queue
820         self.enqueue_input()
821
822         # there is input waiting in the queue
823         if self.input_queue:
824             handle_user_input(self)
825
826     def flush(self):
827         """Try to send the last item in the queue and remove it."""
828         if self.output_queue:
829             try:
830                 self.connection.send(self.output_queue[0])
831             except BrokenPipeError:
832                 if self.account and self.account.get("name"):
833                     account = self.account.get("name")
834                 else:
835                     account = "an unknown user"
836                 self.state = "disconnecting"
837                 log("Broken pipe sending to %s." % account, 7)
838             del self.output_queue[0]
839
840     def enqueue_input(self):
841         """Process and enqueue any new input."""
842
843         # check for some input
844         try:
845             raw_input = self.connection.recv(1024)
846         except (BlockingIOError, OSError):
847             raw_input = b""
848
849         # we got something
850         if raw_input:
851
852             # tack this on to any previous partial
853             self.partial_input += raw_input
854
855             # reply to and remove any IAC negotiation codes
856             mudpy.telnet.negotiate_telnet_options(self)
857
858             # separate multiple input lines
859             new_input_lines = self.partial_input.split(b"\n")
860
861             # if input doesn't end in a newline, replace the
862             # held partial input with the last line of it
863             if not self.partial_input.endswith(b"\n"):
864                 self.partial_input = new_input_lines.pop()
865
866             # otherwise, chop off the extra null input and reset
867             # the held partial input
868             else:
869                 new_input_lines.pop()
870                 self.partial_input = b""
871
872             # iterate over the remaining lines
873             for line in new_input_lines:
874
875                 # strip off extra whitespace
876                 line = line.strip()
877
878                 # log non-printable characters remaining
879                 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
880                                            mudpy.telnet.HIM):
881                     asciiline = bytes([x for x in line if 32 <= x <= 126])
882                     if line != asciiline:
883                         logline = "Non-ASCII characters from "
884                         if self.account and self.account.get("name"):
885                             logline += self.account.get("name") + ": "
886                         else:
887                             logline += "unknown user: "
888                         logline += repr(line)
889                         log(logline, 4)
890                         line = asciiline
891
892                 try:
893                     line = line.decode("utf-8")
894                 except UnicodeDecodeError:
895                     logline = "Non-UTF-8 characters from "
896                     if self.account and self.account.get("name"):
897                         logline += self.account.get("name") + ": "
898                     else:
899                         logline += "unknown user: "
900                     logline += repr(line)
901                     log(logline, 4)
902                     return
903
904                 line = unicodedata.normalize("NFKC", line)
905
906                 # put on the end of the queue
907                 self.input_queue.append(line)
908
909     def new_avatar(self):
910         """Instantiate a new, unconfigured avatar for this user."""
911         counter = 0
912         while "avatar:" + self.account.get("name") + ":" + str(
913             counter
914         ) in universe.categories["actor"].keys():
915             counter += 1
916         self.avatar = Element(
917             "actor:avatar:" + self.account.get("name") + ":" + str(
918                 counter
919             ),
920             universe, old_style=True
921         )
922         self.avatar.append("inherit", "archetype:avatar")
923         self.account.append("avatars", self.avatar.key)
924
925     def delete_avatar(self, avatar):
926         """Remove an avatar from the world and from the user's list."""
927         if self.avatar is universe.contents[avatar]:
928             self.avatar = None
929         universe.contents[avatar].destroy()
930         avatars = self.account.get("avatars")
931         avatars.remove(avatar)
932         self.account.set("avatars", avatars)
933
934     def activate_avatar_by_index(self, index):
935         """Enter the world with a particular indexed avatar."""
936         self.avatar = universe.contents[
937             self.account.get("avatars")[index]]
938         self.avatar.owner = self
939         self.state = "active"
940         self.avatar.go_home()
941
942     def deactivate_avatar(self):
943         """Have the active avatar leave the world."""
944         if self.avatar:
945             current = self.avatar.get("location")
946             if current:
947                 self.avatar.set("default_location", current)
948                 self.avatar.echo_to_location(
949                     "You suddenly wonder where " + self.avatar.get(
950                         "name"
951                     ) + " went."
952                 )
953                 del universe.contents[current].contents[self.avatar.key]
954                 self.avatar.remove_facet("location")
955             self.avatar.owner = None
956             self.avatar = None
957
958     def destroy(self):
959         """Destroy the user and associated avatars."""
960         for avatar in self.account.get("avatars"):
961             self.delete_avatar(avatar)
962         self.account.destroy()
963
964     def list_avatar_names(self):
965         """List names of assigned avatars."""
966         avatars = []
967         for avatar in self.account.get("avatars"):
968             try:
969                 avatars.append(universe.contents[avatar].get("name"))
970             except KeyError:
971                 log('Missing avatar "%s", possible data corruption.' %
972                     avatar, 6)
973         return avatars
974
975
976 def broadcast(message, add_prompt=True):
977     """Send a message to all connected users."""
978     for each_user in universe.userlist:
979         each_user.send("$(eol)" + message, add_prompt=add_prompt)
980
981
982 def log(message, level=0):
983     """Log a message."""
984
985     # a couple references we need
986     if "mudpy.log" in universe.contents:
987         file_name = universe.contents["mudpy.log"].get("file", "")
988         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
989         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
990     else:
991         file_name = ""
992         max_log_lines = 0
993         syslog_name = ""
994     timestamp = time.asctime()[4:19]
995
996     # turn the message into a list of nonempty lines
997     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
998
999     # send the timestamp and line to a file
1000     if file_name:
1001         if not os.path.isabs(file_name):
1002             file_name = os.path.join(universe.startdir, file_name)
1003         file_descriptor = codecs.open(file_name, "a", "utf-8")
1004         for line in lines:
1005             file_descriptor.write(timestamp + " " + line + "\n")
1006         file_descriptor.flush()
1007         file_descriptor.close()
1008
1009     # send the timestamp and line to standard output
1010     if ("mudpy.log" in universe.contents and
1011             universe.contents["mudpy.log"].get("stdout")):
1012         for line in lines:
1013             print(timestamp + " " + line)
1014
1015     # send the line to the system log
1016     if syslog_name:
1017         syslog.openlog(
1018             syslog_name.encode("utf-8"),
1019             syslog.LOG_PID,
1020             syslog.LOG_INFO | syslog.LOG_DAEMON
1021         )
1022         for line in lines:
1023             syslog.syslog(line)
1024         syslog.closelog()
1025
1026     # display to connected administrators
1027     for user in universe.userlist:
1028         if user.state == "active" and user.account.get(
1029            "administrator"
1030            ) and user.account.get("loglevel", 0) <= level:
1031             # iterate over every line in the message
1032             full_message = ""
1033             for line in lines:
1034                 full_message += (
1035                     "$(bld)$(red)" + timestamp + " "
1036                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1037             user.send(full_message, flush=True)
1038
1039     # add to the recent log list
1040     for line in lines:
1041         while 0 < len(universe.loglines) >= max_log_lines:
1042             del universe.loglines[0]
1043         universe.loglines.append((level, timestamp + " " + line))
1044
1045
1046 def get_loglines(level, start, stop):
1047     """Return a specific range of loglines filtered by level."""
1048
1049     # filter the log lines
1050     loglines = [x for x in universe.loglines if x[0] >= level]
1051
1052     # we need these in several places
1053     total_count = str(len(universe.loglines))
1054     filtered_count = len(loglines)
1055
1056     # don't proceed if there are no lines
1057     if filtered_count:
1058
1059         # can't start before the begining or at the end
1060         if start > filtered_count:
1061             start = filtered_count
1062         if start < 1:
1063             start = 1
1064
1065         # can't stop before we start
1066         if stop > start:
1067             stop = start
1068         elif stop < 1:
1069             stop = 1
1070
1071         # some preamble
1072         message = "There are " + str(total_count)
1073         message += " log lines in memory and " + str(filtered_count)
1074         message += " at or above level " + str(level) + "."
1075         message += " The matching lines from " + str(stop) + " to "
1076         message += str(start) + " are:$(eol)$(eol)"
1077
1078         # add the text from the selected lines
1079         if stop > 1:
1080             range_lines = loglines[-start:-(stop - 1)]
1081         else:
1082             range_lines = loglines[-start:]
1083         for line in range_lines:
1084             message += "   (" + str(line[0]) + ") " + line[1].replace(
1085                 "$(", "$_("
1086             ) + "$(eol)"
1087
1088     # there were no lines
1089     else:
1090         message = "None of the " + str(total_count)
1091         message += " lines in memory matches your request."
1092
1093     # pass it back
1094     return message
1095
1096
1097 def glyph_columns(character):
1098     """Convenience function to return the column width of a glyph."""
1099     if unicodedata.east_asian_width(character) in "FW":
1100         return 2
1101     else:
1102         return 1
1103
1104
1105 def wrap_ansi_text(text, width):
1106     """Wrap text with arbitrary width while ignoring ANSI colors."""
1107
1108     # the current position in the entire text string, including all
1109     # characters, printable or otherwise
1110     abs_pos = 0
1111
1112     # the current text position relative to the begining of the line,
1113     # ignoring color escape sequences
1114     rel_pos = 0
1115
1116     # the absolute position of the most recent whitespace character
1117     last_whitespace = 0
1118
1119     # whether the current character is part of a color escape sequence
1120     escape = False
1121
1122     # normalize any potentially composited unicode before we count it
1123     text = unicodedata.normalize("NFKC", text)
1124
1125     # iterate over each character from the begining of the text
1126     for each_character in text:
1127
1128         # the current character is the escape character
1129         if each_character == "\x1b" and not escape:
1130             escape = True
1131
1132         # the current character is within an escape sequence
1133         elif escape:
1134
1135             # the current character is m, which terminates the
1136             # escape sequence
1137             if each_character == "m":
1138                 escape = False
1139
1140         # the current character is a newline, so reset the relative
1141         # position (start a new line)
1142         elif each_character == "\n":
1143             rel_pos = 0
1144             last_whitespace = abs_pos
1145
1146         # the current character meets the requested maximum line width,
1147         # so we need to backtrack and find a space at which to wrap;
1148         # special care is taken to avoid an off-by-one in case the
1149         # current character is a double-width glyph
1150         elif each_character != "\r" and (
1151             rel_pos >= width or (
1152                 rel_pos >= width - 1 and glyph_columns(
1153                     each_character
1154                 ) == 2
1155             )
1156         ):
1157
1158             # it's always possible we landed on whitespace
1159             if unicodedata.category(each_character) in ("Cc", "Zs"):
1160                 last_whitespace = abs_pos
1161
1162             # insert an eol in place of the space
1163             text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
1164
1165             # increase the absolute position because an eol is two
1166             # characters but the space it replaced was only one
1167             abs_pos += 1
1168
1169             # now we're at the begining of a new line, plus the
1170             # number of characters wrapped from the previous line
1171             rel_pos = 0
1172             for remaining_characters in text[last_whitespace:abs_pos]:
1173                 rel_pos += glyph_columns(remaining_characters)
1174
1175         # as long as the character is not a carriage return and the
1176         # other above conditions haven't been met, count it as a
1177         # printable character
1178         elif each_character != "\r":
1179             rel_pos += glyph_columns(each_character)
1180             if unicodedata.category(each_character) in ("Cc", "Zs"):
1181                 last_whitespace = abs_pos
1182
1183         # increase the absolute position for every character
1184         abs_pos += 1
1185
1186     # return the newly-wrapped text
1187     return text
1188
1189
1190 def weighted_choice(data):
1191     """Takes a dict weighted by value and returns a random key."""
1192
1193     # this will hold our expanded list of keys from the data
1194     expanded = []
1195
1196     # create the expanded list of keys
1197     for key in data.keys():
1198         for count in range(data[key]):
1199             expanded.append(key)
1200
1201     # return one at random
1202     return random.choice(expanded)
1203
1204
1205 def random_name():
1206     """Returns a random character name."""
1207
1208     # the vowels and consonants needed to create romaji syllables
1209     vowels = [
1210         "a",
1211         "i",
1212         "u",
1213         "e",
1214         "o"
1215     ]
1216     consonants = [
1217         "'",
1218         "k",
1219         "z",
1220         "s",
1221         "sh",
1222         "z",
1223         "j",
1224         "t",
1225         "ch",
1226         "ts",
1227         "d",
1228         "n",
1229         "h",
1230         "f",
1231         "m",
1232         "y",
1233         "r",
1234         "w"
1235     ]
1236
1237     # this dict will hold our weighted list of syllables
1238     syllables = {}
1239
1240     # generate the list with an even weighting
1241     for consonant in consonants:
1242         for vowel in vowels:
1243             syllables[consonant + vowel] = 1
1244
1245     # we'll build the name into this string
1246     name = ""
1247
1248     # create a name of random length from the syllables
1249     for syllable in range(random.randrange(2, 6)):
1250         name += weighted_choice(syllables)
1251
1252     # strip any leading quotemark, capitalize and return the name
1253     return name.strip("'").capitalize()
1254
1255
1256 def replace_macros(user, text, is_input=False):
1257     """Replaces macros in text output."""
1258
1259     # third person pronouns
1260     pronouns = {
1261         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1262         "male": {"obj": "him", "pos": "his", "sub": "he"},
1263         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1264     }
1265
1266     # a dict of replacement macros
1267     macros = {
1268         "eol": "\r\n",
1269         "bld": chr(27) + "[1m",
1270         "nrm": chr(27) + "[0m",
1271         "blk": chr(27) + "[30m",
1272         "blu": chr(27) + "[34m",
1273         "cyn": chr(27) + "[36m",
1274         "grn": chr(27) + "[32m",
1275         "mgt": chr(27) + "[35m",
1276         "red": chr(27) + "[31m",
1277         "yel": chr(27) + "[33m",
1278     }
1279
1280     # add dynamic macros where possible
1281     if user.account:
1282         account_name = user.account.get("name")
1283         if account_name:
1284             macros["account"] = account_name
1285     if user.avatar:
1286         avatar_gender = user.avatar.get("gender")
1287         if avatar_gender:
1288             macros["tpop"] = pronouns[avatar_gender]["obj"]
1289             macros["tppp"] = pronouns[avatar_gender]["pos"]
1290             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1291
1292     # loop until broken
1293     while True:
1294
1295         # find and replace per the macros dict
1296         macro_start = text.find("$(")
1297         if macro_start == -1:
1298             break
1299         macro_end = text.find(")", macro_start) + 1
1300         macro = text[macro_start + 2:macro_end - 1]
1301         if macro in macros.keys():
1302             replacement = macros[macro]
1303
1304         # this is how we handle local file inclusion (dangerous!)
1305         elif macro.startswith("inc:"):
1306             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1307             if os.path.exists(incfile):
1308                 incfd = codecs.open(incfile, "r", "utf-8")
1309                 replacement = ""
1310                 for line in incfd:
1311                     if line.endswith("\n") and not line.endswith("\r\n"):
1312                         line = line.replace("\n", "\r\n")
1313                     replacement += line
1314                 # lose the trailing eol
1315                 replacement = replacement[:-2]
1316             else:
1317                 replacement = ""
1318                 log("Couldn't read included " + incfile + " file.", 7)
1319
1320         # if we get here, log and replace it with null
1321         else:
1322             replacement = ""
1323             if not is_input:
1324                 log("Unexpected replacement macro " +
1325                     macro + " encountered.", 6)
1326
1327         # and now we act on the replacement
1328         text = text.replace("$(" + macro + ")", replacement)
1329
1330     # replace the look-like-a-macro sequence
1331     text = text.replace("$_(", "$(")
1332
1333     return text
1334
1335
1336 def escape_macros(value):
1337     """Escapes replacement macros in text."""
1338     if type(value) is str:
1339         return value.replace("$(", "$_(")
1340     else:
1341         return value
1342
1343
1344 def first_word(text, separator=" "):
1345     """Returns a tuple of the first word and the rest."""
1346     if text:
1347         if text.find(separator) > 0:
1348             return text.split(separator, 1)
1349         else:
1350             return text, ""
1351     else:
1352         return "", ""
1353
1354
1355 def on_pulse():
1356     """The things which should happen on each pulse, aside from reloads."""
1357
1358     # open the listening socket if it hasn't been already
1359     if not hasattr(universe, "listening_socket"):
1360         universe.initialize_server_socket()
1361
1362     # assign a user if a new connection is waiting
1363     user = check_for_connection(universe.listening_socket)
1364     if user:
1365         universe.userlist.append(user)
1366
1367     # iterate over the connected users
1368     for user in universe.userlist:
1369         user.pulse()
1370
1371     # add an element for counters if it doesn't exist
1372     if "counters" not in universe.categories["internal"]:
1373         universe.categories["internal"]["counters"] = Element(
1374             "internal:counters", universe, old_style=True
1375         )
1376
1377     # update the log every now and then
1378     if not universe.categories["internal"]["counters"].get("mark"):
1379         log(str(len(universe.userlist)) + " connection(s)")
1380         universe.categories["internal"]["counters"].set(
1381             "mark", universe.contents["mudpy.timing"].get("status")
1382         )
1383     else:
1384         universe.categories["internal"]["counters"].set(
1385             "mark", universe.categories["internal"]["counters"].get(
1386                 "mark"
1387             ) - 1
1388         )
1389
1390     # periodically save everything
1391     if not universe.categories["internal"]["counters"].get("save"):
1392         universe.save()
1393         universe.categories["internal"]["counters"].set(
1394             "save", universe.contents["mudpy.timing"].get("save")
1395         )
1396     else:
1397         universe.categories["internal"]["counters"].set(
1398             "save", universe.categories["internal"]["counters"].get(
1399                 "save"
1400             ) - 1
1401         )
1402
1403     # pause for a configurable amount of time (decimal seconds)
1404     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1405
1406     # increase the elapsed increment counter
1407     universe.categories["internal"]["counters"].set(
1408         "elapsed", universe.categories["internal"]["counters"].get(
1409             "elapsed", 0
1410         ) + 1
1411     )
1412
1413
1414 def reload_data():
1415     """Reload all relevant objects."""
1416     for user in universe.userlist[:]:
1417         user.reload()
1418     for element in universe.contents.values():
1419         if element.origin.is_writeable():
1420             element.reload()
1421     universe.load()
1422
1423
1424 def check_for_connection(listening_socket):
1425     """Check for a waiting connection and return a new user object."""
1426
1427     # try to accept a new connection
1428     try:
1429         connection, address = listening_socket.accept()
1430     except BlockingIOError:
1431         return None
1432
1433     # note that we got one
1434     log("Connection from " + address[0], 2)
1435
1436     # disable blocking so we can proceed whether or not we can send/receive
1437     connection.setblocking(0)
1438
1439     # create a new user object
1440     user = User()
1441
1442     # associate this connection with it
1443     user.connection = connection
1444
1445     # set the user's ipa from the connection's ipa
1446     user.address = address[0]
1447
1448     # let the client know we WILL EOR (RFC 885)
1449     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1450     user.negotiation_pause = 2
1451
1452     # return the new user object
1453     return user
1454
1455
1456 def get_menu(state, error=None, choices=None):
1457     """Show the correct menu text to a user."""
1458
1459     # make sure we don't reuse a mutable sequence by default
1460     if choices is None:
1461         choices = {}
1462
1463     # get the description or error text
1464     message = get_menu_description(state, error)
1465
1466     # get menu choices for the current state
1467     message += get_formatted_menu_choices(state, choices)
1468
1469     # try to get a prompt, if it was defined
1470     message += get_menu_prompt(state)
1471
1472     # throw in the default choice, if it exists
1473     message += get_formatted_default_menu_choice(state)
1474
1475     # display a message indicating if echo is off
1476     message += get_echo_message(state)
1477
1478     # return the assembly of various strings defined above
1479     return message
1480
1481
1482 def menu_echo_on(state):
1483     """True if echo is on, false if it is off."""
1484     return universe.categories["menu"][state].get("echo", True)
1485
1486
1487 def get_echo_message(state):
1488     """Return a message indicating that echo is off."""
1489     if menu_echo_on(state):
1490         return ""
1491     else:
1492         return "(won't echo) "
1493
1494
1495 def get_default_menu_choice(state):
1496     """Return the default choice for a menu."""
1497     return universe.categories["menu"][state].get("default")
1498
1499
1500 def get_formatted_default_menu_choice(state):
1501     """Default menu choice foratted for inclusion in a prompt string."""
1502     default_choice = get_default_menu_choice(state)
1503     if default_choice:
1504         return "[$(red)" + default_choice + "$(nrm)] "
1505     else:
1506         return ""
1507
1508
1509 def get_menu_description(state, error):
1510     """Get the description or error text."""
1511
1512     # an error condition was raised by the handler
1513     if error:
1514
1515         # try to get an error message matching the condition
1516         # and current state
1517         description = universe.categories[
1518             "menu"][state].get("error_" + error)
1519         if not description:
1520             description = "That is not a valid choice..."
1521         description = "$(red)" + description + "$(nrm)"
1522
1523     # there was no error condition
1524     else:
1525
1526         # try to get a menu description for the current state
1527         description = universe.categories["menu"][state].get("description")
1528
1529     # return the description or error message
1530     if description:
1531         description += "$(eol)$(eol)"
1532     return description
1533
1534
1535 def get_menu_prompt(state):
1536     """Try to get a prompt, if it was defined."""
1537     prompt = universe.categories["menu"][state].get("prompt")
1538     if prompt:
1539         prompt += " "
1540     return prompt
1541
1542
1543 def get_menu_choices(user):
1544     """Return a dict of choice:meaning."""
1545     menu = universe.categories["menu"][user.state]
1546     create_choices = menu.get("create")
1547     if create_choices:
1548         choices = eval(create_choices)
1549     else:
1550         choices = {}
1551     ignores = []
1552     options = {}
1553     creates = {}
1554     for facet in menu.facets():
1555         if facet.startswith("demand_") and not eval(
1556            universe.categories["menu"][user.state].get(facet)
1557            ):
1558             ignores.append(facet.split("_", 2)[1])
1559         elif facet.startswith("create_"):
1560             creates[facet] = facet.split("_", 2)[1]
1561         elif facet.startswith("choice_"):
1562             options[facet] = facet.split("_", 2)[1]
1563     for facet in creates.keys():
1564         if not creates[facet] in ignores:
1565             choices[creates[facet]] = eval(menu.get(facet))
1566     for facet in options.keys():
1567         if not options[facet] in ignores:
1568             choices[options[facet]] = menu.get(facet)
1569     return choices
1570
1571
1572 def get_formatted_menu_choices(state, choices):
1573     """Returns a formatted string of menu choices."""
1574     choice_output = ""
1575     choice_keys = list(choices.keys())
1576     choice_keys.sort()
1577     for choice in choice_keys:
1578         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1579             choice
1580         ] + "$(eol)"
1581     if choice_output:
1582         choice_output += "$(eol)"
1583     return choice_output
1584
1585
1586 def get_menu_branches(state):
1587     """Return a dict of choice:branch."""
1588     branches = {}
1589     for facet in universe.categories["menu"][state].facets():
1590         if facet.startswith("branch_"):
1591             branches[
1592                 facet.split("_", 2)[1]
1593             ] = universe.categories["menu"][state].get(facet)
1594     return branches
1595
1596
1597 def get_default_branch(state):
1598     """Return the default branch."""
1599     return universe.categories["menu"][state].get("branch")
1600
1601
1602 def get_choice_branch(user, choice):
1603     """Returns the new state matching the given choice."""
1604     branches = get_menu_branches(user.state)
1605     if choice in branches.keys():
1606         return branches[choice]
1607     elif choice in user.menu_choices.keys():
1608         return get_default_branch(user.state)
1609     else:
1610         return ""
1611
1612
1613 def get_menu_actions(state):
1614     """Return a dict of choice:branch."""
1615     actions = {}
1616     for facet in universe.categories["menu"][state].facets():
1617         if facet.startswith("action_"):
1618             actions[
1619                 facet.split("_", 2)[1]
1620             ] = universe.categories["menu"][state].get(facet)
1621     return actions
1622
1623
1624 def get_default_action(state):
1625     """Return the default action."""
1626     return universe.categories["menu"][state].get("action")
1627
1628
1629 def get_choice_action(user, choice):
1630     """Run any indicated script for the given choice."""
1631     actions = get_menu_actions(user.state)
1632     if choice in actions.keys():
1633         return actions[choice]
1634     elif choice in user.menu_choices.keys():
1635         return get_default_action(user.state)
1636     else:
1637         return ""
1638
1639
1640 def handle_user_input(user):
1641     """The main handler, branches to a state-specific handler."""
1642
1643     # if the user's client echo is off, send a blank line for aesthetics
1644     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1645                                mudpy.telnet.US):
1646         user.send("", add_prompt=False, prepend_padding=False)
1647
1648     # check to make sure the state is expected, then call that handler
1649     if "handler_" + user.state in globals():
1650         exec("handler_" + user.state + "(user)")
1651     else:
1652         generic_menu_handler(user)
1653
1654     # since we got input, flag that the menu/prompt needs to be redisplayed
1655     user.menu_seen = False
1656
1657     # update the last_input timestamp while we're at it
1658     user.last_input = universe.get_time()
1659
1660
1661 def generic_menu_handler(user):
1662     """A generic menu choice handler."""
1663
1664     # get a lower-case representation of the next line of input
1665     if user.input_queue:
1666         choice = user.input_queue.pop(0)
1667         if choice:
1668             choice = choice.lower()
1669     else:
1670         choice = ""
1671     if not choice:
1672         choice = get_default_menu_choice(user.state)
1673     if choice in user.menu_choices:
1674         exec(get_choice_action(user, choice))
1675         new_state = get_choice_branch(user, choice)
1676         if new_state:
1677             user.state = new_state
1678     else:
1679         user.error = "default"
1680
1681
1682 def handler_entering_account_name(user):
1683     """Handle the login account name."""
1684
1685     # get the next waiting line of input
1686     input_data = user.input_queue.pop(0)
1687
1688     # did the user enter anything?
1689     if input_data:
1690
1691         # keep only the first word and convert to lower-case
1692         name = input_data.lower()
1693
1694         # fail if there are non-alphanumeric characters
1695         if name != "".join(filter(
1696                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1697                 name)):
1698             user.error = "bad_name"
1699
1700         # if that account exists, time to request a password
1701         elif name in universe.categories["account"]:
1702             user.account = universe.categories["account"][name]
1703             user.state = "checking_password"
1704
1705         # otherwise, this could be a brand new user
1706         else:
1707             user.account = Element("account:" + name, universe, old_style=True)
1708             user.account.set("name", name)
1709             log("New user: " + name, 2)
1710             user.state = "checking_new_account_name"
1711
1712     # if the user entered nothing for a name, then buhbye
1713     else:
1714         user.state = "disconnecting"
1715
1716
1717 def handler_checking_password(user):
1718     """Handle the login account password."""
1719
1720     # get the next waiting line of input
1721     input_data = user.input_queue.pop(0)
1722
1723     if "mudpy.limit" in universe.contents:
1724         max_password_tries = universe.contents["mudpy.limit"].get(
1725             "password_tries", 3)
1726     else:
1727         max_password_tries = 3
1728
1729     # does the hashed input equal the stored hash?
1730     if mudpy.password.verify(input_data, user.account.get("passhash")):
1731
1732         # if so, set the username and load from cold storage
1733         if not user.replace_old_connections():
1734             user.authenticate()
1735             user.state = "main_utility"
1736
1737     # if at first your hashes don't match, try, try again
1738     elif user.password_tries < max_password_tries - 1:
1739         user.password_tries += 1
1740         user.error = "incorrect"
1741
1742     # we've exceeded the maximum number of password failures, so disconnect
1743     else:
1744         user.send(
1745             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1746         )
1747         user.state = "disconnecting"
1748
1749
1750 def handler_entering_new_password(user):
1751     """Handle a new password entry."""
1752
1753     # get the next waiting line of input
1754     input_data = user.input_queue.pop(0)
1755
1756     if "mudpy.limit" in universe.contents:
1757         max_password_tries = universe.contents["mudpy.limit"].get(
1758             "password_tries", 3)
1759     else:
1760         max_password_tries = 3
1761
1762     # make sure the password is strong--at least one upper, one lower and
1763     # one digit, seven or more characters in length
1764     if len(input_data) > 6 and len(
1765        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1766        ) and len(
1767         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1768     ) and len(
1769         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1770     ):
1771
1772         # hash and store it, then move on to verification
1773         user.account.set("passhash", mudpy.password.create(input_data))
1774         user.state = "verifying_new_password"
1775
1776     # the password was weak, try again if you haven't tried too many times
1777     elif user.password_tries < max_password_tries - 1:
1778         user.password_tries += 1
1779         user.error = "weak"
1780
1781     # too many tries, so adios
1782     else:
1783         user.send(
1784             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1785         )
1786         user.account.destroy()
1787         user.state = "disconnecting"
1788
1789
1790 def handler_verifying_new_password(user):
1791     """Handle the re-entered new password for verification."""
1792
1793     # get the next waiting line of input
1794     input_data = user.input_queue.pop(0)
1795
1796     if "mudpy.limit" in universe.contents:
1797         max_password_tries = universe.contents["mudpy.limit"].get(
1798             "password_tries", 3)
1799     else:
1800         max_password_tries = 3
1801
1802     # hash the input and match it to storage
1803     if mudpy.password.verify(input_data, user.account.get("passhash")):
1804         user.authenticate()
1805
1806         # the hashes matched, so go active
1807         if not user.replace_old_connections():
1808             user.state = "main_utility"
1809
1810     # go back to entering the new password as long as you haven't tried
1811     # too many times
1812     elif user.password_tries < max_password_tries - 1:
1813         user.password_tries += 1
1814         user.error = "differs"
1815         user.state = "entering_new_password"
1816
1817     # otherwise, sayonara
1818     else:
1819         user.send(
1820             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1821         )
1822         user.account.destroy()
1823         user.state = "disconnecting"
1824
1825
1826 def handler_active(user):
1827     """Handle input for active users."""
1828
1829     # get the next waiting line of input
1830     input_data = user.input_queue.pop(0)
1831
1832     # is there input?
1833     if input_data:
1834
1835         # split out the command and parameters
1836         actor = user.avatar
1837         mode = actor.get("mode")
1838         if mode and input_data.startswith("!"):
1839             command_name, parameters = first_word(input_data[1:])
1840         elif mode == "chat":
1841             command_name = "say"
1842             parameters = input_data
1843         else:
1844             command_name, parameters = first_word(input_data)
1845
1846         # lowercase the command
1847         command_name = command_name.lower()
1848
1849         # the command matches a command word for which we have data
1850         if command_name in universe.categories["command"]:
1851             command = universe.categories["command"][command_name]
1852         else:
1853             command = None
1854
1855         # if it's allowed, do it
1856         if actor.can_run(command):
1857             exec(command.get("action"))
1858
1859         # otherwise, give an error
1860         elif command_name:
1861             command_error(actor, input_data)
1862
1863     # if no input, just idle back with a prompt
1864     else:
1865         user.send("", just_prompt=True)
1866
1867
1868 def command_halt(actor, parameters):
1869     """Halt the world."""
1870     if actor.owner:
1871
1872         # see if there's a message or use a generic one
1873         if parameters:
1874             message = "Halting: " + parameters
1875         else:
1876             message = "User " + actor.owner.account.get(
1877                 "name"
1878             ) + " halted the world."
1879
1880         # let everyone know
1881         broadcast(message, add_prompt=False)
1882         log(message, 8)
1883
1884         # set a flag to terminate the world
1885         universe.terminate_flag = True
1886
1887
1888 def command_reload(actor):
1889     """Reload all code modules, configs and data."""
1890     if actor.owner:
1891
1892         # let the user know and log
1893         actor.send("Reloading all code modules, configs and data.")
1894         log(
1895             "User " +
1896             actor.owner.account.get("name") + " reloaded the world.",
1897             6
1898         )
1899
1900         # set a flag to reload
1901         universe.reload_flag = True
1902
1903
1904 def command_quit(actor):
1905     """Leave the world and go back to the main menu."""
1906     if actor.owner:
1907         actor.owner.state = "main_utility"
1908         actor.owner.deactivate_avatar()
1909
1910
1911 def command_help(actor, parameters):
1912     """List available commands and provide help for commands."""
1913
1914     # did the user ask for help on a specific command word?
1915     if parameters and actor.owner:
1916
1917         # is the command word one for which we have data?
1918         if parameters in universe.categories["command"]:
1919             command = universe.categories["command"][parameters]
1920         else:
1921             command = None
1922
1923         # only for allowed commands
1924         if actor.can_run(command):
1925
1926             # add a description if provided
1927             description = command.get("description")
1928             if not description:
1929                 description = "(no short description provided)"
1930             if command.get("administrative"):
1931                 output = "$(red)"
1932             else:
1933                 output = "$(grn)"
1934             output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1935
1936             # add the help text if provided
1937             help_text = command.get("help")
1938             if not help_text:
1939                 help_text = "No help is provided for this command."
1940             output += help_text
1941
1942             # list related commands
1943             see_also = command.get("see_also")
1944             if see_also:
1945                 really_see_also = ""
1946                 for item in see_also:
1947                     if item in universe.categories["command"]:
1948                         command = universe.categories["command"][item]
1949                         if actor.can_run(command):
1950                             if really_see_also:
1951                                 really_see_also += ", "
1952                             if command.get("administrative"):
1953                                 really_see_also += "$(red)"
1954                             else:
1955                                 really_see_also += "$(grn)"
1956                             really_see_also += item + "$(nrm)"
1957                 if really_see_also:
1958                     output += "$(eol)$(eol)See also: " + really_see_also
1959
1960         # no data for the requested command word
1961         else:
1962             output = "That is not an available command."
1963
1964     # no specific command word was indicated
1965     else:
1966
1967         # give a sorted list of commands with descriptions if provided
1968         output = "These are the commands available to you:$(eol)$(eol)"
1969         sorted_commands = list(universe.categories["command"].keys())
1970         sorted_commands.sort()
1971         for item in sorted_commands:
1972             command = universe.categories["command"][item]
1973             if actor.can_run(command):
1974                 description = command.get("description")
1975                 if not description:
1976                     description = "(no short description provided)"
1977                 if command.get("administrative"):
1978                     output += "   $(red)"
1979                 else:
1980                     output += "   $(grn)"
1981                 output += item + "$(nrm) - " + description + "$(eol)"
1982         output += ('$(eol)Enter "help COMMAND" for help on a command '
1983                    'named "COMMAND".')
1984
1985     # send the accumulated output to the user
1986     actor.send(output)
1987
1988
1989 def command_move(actor, parameters):
1990     """Move the avatar in a given direction."""
1991     if parameters in universe.contents[actor.get("location")].portals():
1992         actor.move_direction(parameters)
1993     else:
1994         actor.send("You cannot go that way.")
1995
1996
1997 def command_look(actor, parameters):
1998     """Look around."""
1999     if parameters:
2000         actor.send("You can't look at or in anything yet.")
2001     else:
2002         actor.look_at(actor.get("location"))
2003
2004
2005 def command_say(actor, parameters):
2006     """Speak to others in the same area."""
2007
2008     # check for replacement macros and escape them
2009     parameters = escape_macros(parameters)
2010
2011     # if the message is wrapped in quotes, remove them and leave contents
2012     # intact
2013     if parameters.startswith('"') and parameters.endswith('"'):
2014         message = parameters[1:-1]
2015         literal = True
2016
2017     # otherwise, get rid of stray quote marks on the ends of the message
2018     else:
2019         message = parameters.strip('''"'`''')
2020         literal = False
2021
2022     # the user entered a message
2023     if message:
2024
2025         # match the punctuation used, if any, to an action
2026         if "mudpy.linguistic" in universe.contents:
2027             actions = universe.contents["mudpy.linguistic"].get("actions", {})
2028             default_punctuation = (universe.contents["mudpy.linguistic"].get(
2029                 "default_punctuation", "."))
2030         else:
2031             actions = {}
2032             default_punctuation = "."
2033         action = ""
2034
2035         # reverse sort punctuation options so the longest match wins
2036         for mark in sorted(actions.keys(), reverse=True):
2037             if not literal and message.endswith(mark):
2038                 action = actions[mark]
2039                 break
2040
2041         # add punctuation if needed
2042         if not action:
2043             action = actions[default_punctuation]
2044             if message and not (
2045                literal or unicodedata.category(message[-1]) == "Po"
2046                ):
2047                 message += default_punctuation
2048
2049         # failsafe checks to avoid unwanted reformatting and null strings
2050         if message and not literal:
2051
2052             # decapitalize the first letter to improve matching
2053             message = message[0].lower() + message[1:]
2054
2055             # iterate over all words in message, replacing typos
2056             if "mudpy.linguistic" in universe.contents:
2057                 typos = universe.contents["mudpy.linguistic"].get("typos", {})
2058             else:
2059                 typos = {}
2060             words = message.split()
2061             for index in range(len(words)):
2062                 word = words[index]
2063                 while unicodedata.category(word[0]) == "Po":
2064                     word = word[1:]
2065                 while unicodedata.category(word[-1]) == "Po":
2066                     word = word[:-1]
2067                 if word in typos.keys():
2068                     words[index] = words[index].replace(word, typos[word])
2069             message = " ".join(words)
2070
2071             # capitalize the first letter
2072             message = message[0].upper() + message[1:]
2073
2074     # tell the area
2075     if message:
2076         actor.echo_to_location(
2077             actor.get("name") + " " + action + 's, "' + message + '"'
2078         )
2079         actor.send("You " + action + ', "' + message + '"')
2080
2081     # there was no message
2082     else:
2083         actor.send("What do you want to say?")
2084
2085
2086 def command_chat(actor):
2087     """Toggle chat mode."""
2088     mode = actor.get("mode")
2089     if not mode:
2090         actor.set("mode", "chat")
2091         actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2092     elif mode == "chat":
2093         actor.remove_facet("mode")
2094         actor.send("Exiting chat mode.")
2095     else:
2096         actor.send("Sorry, but you're already busy with something else!")
2097
2098
2099 def command_show(actor, parameters):
2100     """Show program data."""
2101     message = ""
2102     arguments = parameters.split()
2103     if not parameters:
2104         message = "What do you want to show?"
2105     elif arguments[0] == "time":
2106         message = universe.categories["internal"]["counters"].get(
2107             "elapsed"
2108         ) + " increments elapsed since the world was created."
2109     elif arguments[0] == "categories":
2110         message = "These are the element categories:$(eol)"
2111         categories = list(universe.categories.keys())
2112         categories.sort()
2113         for category in categories:
2114             message += "$(eol)   $(grn)" + category + "$(nrm)"
2115     elif arguments[0] == "files":
2116         message = "These are the current files containing the universe:$(eol)"
2117         filenames = list(universe.files.keys())
2118         filenames.sort()
2119         for filename in filenames:
2120             if universe.files[filename].is_writeable():
2121                 status = "rw"
2122             else:
2123                 status = "ro"
2124             message += ("$(eol)   $(red)(" + status + ") $(grn)" + filename
2125                         + "$(nrm)")
2126     elif arguments[0] == "category":
2127         if len(arguments) != 2:
2128             message = "You must specify one category."
2129         elif arguments[1] in universe.categories:
2130             message = ('These are the elements in the "' + arguments[1]
2131                        + '" category:$(eol)')
2132             elements = [
2133                 (
2134                     universe.categories[arguments[1]][x].key
2135                 ) for x in universe.categories[arguments[1]].keys()
2136             ]
2137             elements.sort()
2138             for element in elements:
2139                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2140         else:
2141             message = 'Category "' + arguments[1] + '" does not exist.'
2142     elif arguments[0] == "file":
2143         if len(arguments) != 2:
2144             message = "You must specify one file."
2145         elif arguments[1] in universe.files:
2146             message = ('These are the elements in the "' + arguments[1]
2147                        + '" file:$(eol)')
2148             elements = universe.files[arguments[1]].data.keys()
2149             elements.sort()
2150             for element in elements:
2151                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2152         else:
2153             message = 'Category "' + arguments[1] + '" does not exist.'
2154     elif arguments[0] == "element":
2155         if len(arguments) != 2:
2156             message = "You must specify one element."
2157         elif arguments[1].strip(".") in universe.contents:
2158             element = universe.contents[arguments[1].strip(".")]
2159             message = ('These are the properties of the "' + arguments[1]
2160                        + '" element (in "' + element.origin.filename
2161                        + '"):$(eol)')
2162             facets = element.facets()
2163             for facet in sorted(facets):
2164                 if element.old_style:
2165                     message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
2166                                 (facet, escape_macros(element.get(facet))))
2167                 else:
2168                     message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
2169                                 (facet, str(facets[facet])))
2170         else:
2171             message = 'Element "' + arguments[1] + '" does not exist.'
2172     elif arguments[0] == "result":
2173         if len(arguments) < 2:
2174             message = "You need to specify an expression."
2175         else:
2176             try:
2177                 message = repr(eval(" ".join(arguments[1:])))
2178             except Exception as e:
2179                 message = ("$(red)Your expression raised an exception...$(eol)"
2180                            "$(eol)$(bld)%s$(nrm)" % e)
2181     elif arguments[0] == "log":
2182         if len(arguments) == 4:
2183             if re.match(r"^\d+$", arguments[3]) and int(arguments[3]) >= 0:
2184                 stop = int(arguments[3])
2185             else:
2186                 stop = -1
2187         else:
2188             stop = 0
2189         if len(arguments) >= 3:
2190             if re.match(r"^\d+$", arguments[2]) and int(arguments[2]) > 0:
2191                 start = int(arguments[2])
2192             else:
2193                 start = -1
2194         else:
2195             start = 10
2196         if len(arguments) >= 2:
2197             if (re.match(r"^\d+$", arguments[1])
2198                     and 0 <= int(arguments[1]) <= 9):
2199                 level = int(arguments[1])
2200             else:
2201                 level = -1
2202         elif 0 <= actor.owner.account.get("loglevel", 0) <= 9:
2203             level = actor.owner.account.get("loglevel", 0)
2204         else:
2205             level = 1
2206         if level > -1 and start > -1 and stop > -1:
2207             message = get_loglines(level, start, stop)
2208         else:
2209             message = ("When specified, level must be 0-9 (default 1), "
2210                        "start and stop must be >=1 (default 10 and 1).")
2211     else:
2212         message = '''I don't know what "''' + parameters + '" is.'
2213     actor.send(message)
2214
2215
2216 def command_create(actor, parameters):
2217     """Create an element if it does not exist."""
2218     if not parameters:
2219         message = "You must at least specify an element to create."
2220     elif not actor.owner:
2221         message = ""
2222     else:
2223         arguments = parameters.split()
2224         if len(arguments) == 1:
2225             arguments.append("")
2226         if len(arguments) == 2:
2227             element, filename = arguments
2228             if element in universe.contents:
2229                 message = 'The "' + element + '" element already exists.'
2230             else:
2231                 message = ('You create "' +
2232                            element + '" within the universe.')
2233                 logline = actor.owner.account.get(
2234                     "name"
2235                 ) + " created an element: " + element
2236                 if filename:
2237                     logline += " in file " + filename
2238                     if filename not in universe.files:
2239                         message += (
2240                             ' Warning: "' + filename + '" is not yet '
2241                             "included in any other file and will not be read "
2242                             "on startup unless this is remedied.")
2243                 Element(element, universe, filename, old_style=True)
2244                 log(logline, 6)
2245         elif len(arguments) > 2:
2246             message = "You can only specify an element and a filename."
2247     actor.send(message)
2248
2249
2250 def command_destroy(actor, parameters):
2251     """Destroy an element if it exists."""
2252     if actor.owner:
2253         if not parameters:
2254             message = "You must specify an element to destroy."
2255         else:
2256             if parameters not in universe.contents:
2257                 message = 'The "' + parameters + '" element does not exist.'
2258             else:
2259                 universe.contents[parameters].destroy()
2260                 message = ('You destroy "' + parameters
2261                            + '" within the universe.')
2262                 log(
2263                     actor.owner.account.get(
2264                         "name"
2265                     ) + " destroyed an element: " + parameters,
2266                     6
2267                 )
2268         actor.send(message)
2269
2270
2271 def command_set(actor, parameters):
2272     """Set a facet of an element."""
2273     if not parameters:
2274         message = "You must specify an element, a facet and a value."
2275     else:
2276         arguments = parameters.split(" ", 2)
2277         if len(arguments) == 1:
2278             message = ('What facet of element "' + arguments[0]
2279                        + '" would you like to set?')
2280         elif len(arguments) == 2:
2281             message = ('What value would you like to set for the "' +
2282                        arguments[1] + '" facet of the "' + arguments[0]
2283                        + '" element?')
2284         else:
2285             element, facet, value = arguments
2286             if element not in universe.contents:
2287                 message = 'The "' + element + '" element does not exist.'
2288             else:
2289                 try:
2290                     universe.contents[element].set(facet, value)
2291                 except ValueError:
2292                     message = ('Value "%s" of type "%s" cannot be coerced '
2293                                'to the correct datatype for facet "%s".' %
2294                                (value, type(value), facet))
2295                 else:
2296                     message = ('You have successfully (re)set the "' + facet
2297                                + '" facet of element "' + element
2298                                + '". Try "show element ' +
2299                                element + '" for verification.')
2300     actor.send(message)
2301
2302
2303 def command_delete(actor, parameters):
2304     """Delete a facet from an element."""
2305     if not parameters:
2306         message = "You must specify an element and a facet."
2307     else:
2308         arguments = parameters.split(" ")
2309         if len(arguments) == 1:
2310             message = ('What facet of element "' + arguments[0]
2311                        + '" would you like to delete?')
2312         elif len(arguments) != 2:
2313             message = "You may only specify an element and a facet."
2314         else:
2315             element, facet = arguments
2316             if element not in universe.contents:
2317                 message = 'The "' + element + '" element does not exist.'
2318             elif facet not in universe.contents[element].facets():
2319                 message = ('The "' + element + '" element has no "' + facet
2320                            + '" facet.')
2321             else:
2322                 universe.contents[element].remove_facet(facet)
2323                 message = ('You have successfully deleted the "' + facet
2324                            + '" facet of element "' + element
2325                            + '". Try "show element ' +
2326                            element + '" for verification.')
2327     actor.send(message)
2328
2329
2330 def command_error(actor, input_data):
2331     """Generic error for an unrecognized command word."""
2332
2333     # 90% of the time use a generic error
2334     if random.randrange(10):
2335         message = '''I'm not sure what "''' + input_data + '''" means...'''
2336
2337     # 10% of the time use the classic diku error
2338     else:
2339         message = "Arglebargle, glop-glyf!?!"
2340
2341     # send the error message
2342     actor.send(message)
2343
2344
2345 def daemonize(universe):
2346     """Fork and disassociate from everything."""
2347
2348     # only if this is what we're configured to do
2349     if "mudpy.process" in universe.contents and universe.contents[
2350             "mudpy.process"].get("daemon"):
2351
2352         # log before we start forking around, so the terminal gets the message
2353         log("Disassociating from the controlling terminal.")
2354
2355         # fork off and die, so we free up the controlling terminal
2356         if os.fork():
2357             os._exit(0)
2358
2359         # switch to a new process group
2360         os.setsid()
2361
2362         # fork some more, this time to free us from the old process group
2363         if os.fork():
2364             os._exit(0)
2365
2366         # reset the working directory so we don't needlessly tie up mounts
2367         os.chdir("/")
2368
2369         # clear the file creation mask so we can bend it to our will later
2370         os.umask(0)
2371
2372         # redirect stdin/stdout/stderr and close off their former descriptors
2373         for stdpipe in range(3):
2374             os.close(stdpipe)
2375         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2376         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2377         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2378         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2379         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2380         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2381
2382
2383 def create_pidfile(universe):
2384     """Write a file containing the current process ID."""
2385     pid = str(os.getpid())
2386     log("Process ID: " + pid)
2387     if "mudpy.process" in universe.contents:
2388         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2389     else:
2390         file_name = ""
2391     if file_name:
2392         if not os.path.isabs(file_name):
2393             file_name = os.path.join(universe.startdir, file_name)
2394         file_descriptor = codecs.open(file_name, "w", "utf-8")
2395         file_descriptor.write(pid + "\n")
2396         file_descriptor.flush()
2397         file_descriptor.close()
2398
2399
2400 def remove_pidfile(universe):
2401     """Remove the file containing the current process ID."""
2402     if "mudpy.process" in universe.contents:
2403         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2404     else:
2405         file_name = ""
2406     if file_name:
2407         if not os.path.isabs(file_name):
2408             file_name = os.path.join(universe.startdir, file_name)
2409         if os.access(file_name, os.W_OK):
2410             os.remove(file_name)
2411
2412
2413 def excepthook(excepttype, value, tracebackdata):
2414     """Handle uncaught exceptions."""
2415
2416     # assemble the list of errors into a single string
2417     message = "".join(
2418         traceback.format_exception(excepttype, value, tracebackdata)
2419     )
2420
2421     # try to log it, if possible
2422     try:
2423         log(message, 9)
2424     except Exception as e:
2425         # try to write it to stderr, if possible
2426         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2427
2428
2429 def sighook(what, where):
2430     """Handle external signals."""
2431
2432     # a generic message
2433     message = "Caught signal: "
2434
2435     # for a hangup signal
2436     if what == signal.SIGHUP:
2437         message += "hangup (reloading)"
2438         universe.reload_flag = True
2439
2440     # for a terminate signal
2441     elif what == signal.SIGTERM:
2442         message += "terminate (halting)"
2443         universe.terminate_flag = True
2444
2445     # catchall for unexpected signals
2446     else:
2447         message += str(what) + " (unhandled)"
2448
2449     # log what happened
2450     log(message, 8)
2451
2452
2453 def override_excepthook():
2454     """Redefine sys.excepthook with our own."""
2455     sys.excepthook = excepthook
2456
2457
2458 def assign_sighook():
2459     """Assign a customized handler for some signals."""
2460     signal.signal(signal.SIGHUP, sighook)
2461     signal.signal(signal.SIGTERM, sighook)
2462
2463
2464 def setup():
2465     """This contains functions to be performed when starting the engine."""
2466
2467     # see if a configuration file was specified
2468     if len(sys.argv) > 1:
2469         conffile = sys.argv[1]
2470     else:
2471         conffile = ""
2472
2473     # the big bang
2474     global universe
2475     universe = Universe(conffile, True)
2476
2477     # report any loglines which accumulated during setup
2478     for logline in universe.setup_loglines:
2479         log(*logline)
2480     universe.setup_loglines = []
2481
2482     # log an initial message
2483     log("Started mudpy with command line: " + " ".join(sys.argv))
2484
2485     # fork and disassociate
2486     daemonize(universe)
2487
2488     # override the default exception handler so we get logging first thing
2489     override_excepthook()
2490
2491     # set up custom signal handlers
2492     assign_sighook()
2493
2494     # make the pidfile
2495     create_pidfile(universe)
2496
2497     # pass the initialized universe back
2498     return universe
2499
2500
2501 def finish():
2502     """These are functions performed when shutting down the engine."""
2503
2504     # the loop has terminated, so save persistent data
2505     universe.save()
2506
2507     # log a final message
2508     log("Shutting down now.")
2509
2510     # get rid of the pidfile
2511     remove_pidfile(universe)