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