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