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