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