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