Rename internal:logging: to .mudpy.log
[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.contents["mudpy.log"].get("file")
1012     max_log_lines = universe.contents["mudpy.log"].get("lines")
1013     syslog_name = universe.contents["mudpy.log"].get("syslog")
1014     timestamp = time.asctime()[4:19]
1015
1016     # turn the message into a list of nonempty lines
1017     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1018
1019     # send the timestamp and line to a file
1020     if file_name:
1021         if not os.path.isabs(file_name):
1022             file_name = os.path.join(universe.startdir, file_name)
1023         file_descriptor = codecs.open(file_name, "a", "utf-8")
1024         for line in lines:
1025             file_descriptor.write(timestamp + " " + line + "\n")
1026         file_descriptor.flush()
1027         file_descriptor.close()
1028
1029     # send the timestamp and line to standard output
1030     if universe.contents["mudpy.log"].get("stdout"):
1031         for line in lines:
1032             print(timestamp + " " + line)
1033
1034     # send the line to the system log
1035     if syslog_name:
1036         syslog.openlog(
1037             syslog_name.encode("utf-8"),
1038             syslog.LOG_PID,
1039             syslog.LOG_INFO | syslog.LOG_DAEMON
1040         )
1041         for line in lines:
1042             syslog.syslog(line)
1043         syslog.closelog()
1044
1045     # display to connected administrators
1046     for user in universe.userlist:
1047         if user.state == "active" and user.account.get(
1048            "administrator"
1049            ) and user.account.get("loglevel", 0) <= level:
1050             # iterate over every line in the message
1051             full_message = ""
1052             for line in lines:
1053                 full_message += (
1054                     "$(bld)$(red)" + timestamp + " "
1055                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1056             user.send(full_message, flush=True)
1057
1058     # add to the recent log list
1059     for line in lines:
1060         while 0 < len(universe.loglines) >= max_log_lines:
1061             del universe.loglines[0]
1062         universe.loglines.append((level, timestamp + " " + line))
1063
1064
1065 def get_loglines(level, start, stop):
1066     """Return a specific range of loglines filtered by level."""
1067
1068     # filter the log lines
1069     loglines = [x for x in universe.loglines if x[0] >= level]
1070
1071     # we need these in several places
1072     total_count = str(len(universe.loglines))
1073     filtered_count = len(loglines)
1074
1075     # don't proceed if there are no lines
1076     if filtered_count:
1077
1078         # can't start before the begining or at the end
1079         if start > filtered_count:
1080             start = filtered_count
1081         if start < 1:
1082             start = 1
1083
1084         # can't stop before we start
1085         if stop > start:
1086             stop = start
1087         elif stop < 1:
1088             stop = 1
1089
1090         # some preamble
1091         message = "There are " + str(total_count)
1092         message += " log lines in memory and " + str(filtered_count)
1093         message += " at or above level " + str(level) + "."
1094         message += " The matching lines from " + str(stop) + " to "
1095         message += str(start) + " are:$(eol)$(eol)"
1096
1097         # add the text from the selected lines
1098         if stop > 1:
1099             range_lines = loglines[-start:-(stop - 1)]
1100         else:
1101             range_lines = loglines[-start:]
1102         for line in range_lines:
1103             message += "   (" + str(line[0]) + ") " + line[1].replace(
1104                 "$(", "$_("
1105             ) + "$(eol)"
1106
1107     # there were no lines
1108     else:
1109         message = "None of the " + str(total_count)
1110         message += " lines in memory matches your request."
1111
1112     # pass it back
1113     return message
1114
1115
1116 def glyph_columns(character):
1117     """Convenience function to return the column width of a glyph."""
1118     if unicodedata.east_asian_width(character) in "FW":
1119         return 2
1120     else:
1121         return 1
1122
1123
1124 def wrap_ansi_text(text, width):
1125     """Wrap text with arbitrary width while ignoring ANSI colors."""
1126
1127     # the current position in the entire text string, including all
1128     # characters, printable or otherwise
1129     abs_pos = 0
1130
1131     # the current text position relative to the begining of the line,
1132     # ignoring color escape sequences
1133     rel_pos = 0
1134
1135     # the absolute position of the most recent whitespace character
1136     last_whitespace = 0
1137
1138     # whether the current character is part of a color escape sequence
1139     escape = False
1140
1141     # normalize any potentially composited unicode before we count it
1142     text = unicodedata.normalize("NFKC", text)
1143
1144     # iterate over each character from the begining of the text
1145     for each_character in text:
1146
1147         # the current character is the escape character
1148         if each_character == "\x1b" and not escape:
1149             escape = True
1150
1151         # the current character is within an escape sequence
1152         elif escape:
1153
1154             # the current character is m, which terminates the
1155             # escape sequence
1156             if each_character == "m":
1157                 escape = False
1158
1159         # the current character is a newline, so reset the relative
1160         # position (start a new line)
1161         elif each_character == "\n":
1162             rel_pos = 0
1163             last_whitespace = abs_pos
1164
1165         # the current character meets the requested maximum line width,
1166         # so we need to backtrack and find a space at which to wrap;
1167         # special care is taken to avoid an off-by-one in case the
1168         # current character is a double-width glyph
1169         elif each_character != "\r" and (
1170             rel_pos >= width or (
1171                 rel_pos >= width - 1 and glyph_columns(
1172                     each_character
1173                 ) == 2
1174             )
1175         ):
1176
1177             # it's always possible we landed on whitespace
1178             if unicodedata.category(each_character) in ("Cc", "Zs"):
1179                 last_whitespace = abs_pos
1180
1181             # insert an eol in place of the space
1182             text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
1183
1184             # increase the absolute position because an eol is two
1185             # characters but the space it replaced was only one
1186             abs_pos += 1
1187
1188             # now we're at the begining of a new line, plus the
1189             # number of characters wrapped from the previous line
1190             rel_pos = 0
1191             for remaining_characters in text[last_whitespace:abs_pos]:
1192                 rel_pos += glyph_columns(remaining_characters)
1193
1194         # as long as the character is not a carriage return and the
1195         # other above conditions haven't been met, count it as a
1196         # printable character
1197         elif each_character != "\r":
1198             rel_pos += glyph_columns(each_character)
1199             if unicodedata.category(each_character) in ("Cc", "Zs"):
1200                 last_whitespace = abs_pos
1201
1202         # increase the absolute position for every character
1203         abs_pos += 1
1204
1205     # return the newly-wrapped text
1206     return text
1207
1208
1209 def weighted_choice(data):
1210     """Takes a dict weighted by value and returns a random key."""
1211
1212     # this will hold our expanded list of keys from the data
1213     expanded = []
1214
1215     # create the expanded list of keys
1216     for key in data.keys():
1217         for count in range(data[key]):
1218             expanded.append(key)
1219
1220     # return one at random
1221     return random.choice(expanded)
1222
1223
1224 def random_name():
1225     """Returns a random character name."""
1226
1227     # the vowels and consonants needed to create romaji syllables
1228     vowels = [
1229         "a",
1230         "i",
1231         "u",
1232         "e",
1233         "o"
1234     ]
1235     consonants = [
1236         "'",
1237         "k",
1238         "z",
1239         "s",
1240         "sh",
1241         "z",
1242         "j",
1243         "t",
1244         "ch",
1245         "ts",
1246         "d",
1247         "n",
1248         "h",
1249         "f",
1250         "m",
1251         "y",
1252         "r",
1253         "w"
1254     ]
1255
1256     # this dict will hold our weighted list of syllables
1257     syllables = {}
1258
1259     # generate the list with an even weighting
1260     for consonant in consonants:
1261         for vowel in vowels:
1262             syllables[consonant + vowel] = 1
1263
1264     # we'll build the name into this string
1265     name = ""
1266
1267     # create a name of random length from the syllables
1268     for syllable in range(random.randrange(2, 6)):
1269         name += weighted_choice(syllables)
1270
1271     # strip any leading quotemark, capitalize and return the name
1272     return name.strip("'").capitalize()
1273
1274
1275 def replace_macros(user, text, is_input=False):
1276     """Replaces macros in text output."""
1277
1278     # third person pronouns
1279     pronouns = {
1280         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1281         "male": {"obj": "him", "pos": "his", "sub": "he"},
1282         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1283     }
1284
1285     # a dict of replacement macros
1286     macros = {
1287         "eol": "\r\n",
1288         "bld": chr(27) + "[1m",
1289         "nrm": chr(27) + "[0m",
1290         "blk": chr(27) + "[30m",
1291         "blu": chr(27) + "[34m",
1292         "cyn": chr(27) + "[36m",
1293         "grn": chr(27) + "[32m",
1294         "mgt": chr(27) + "[35m",
1295         "red": chr(27) + "[31m",
1296         "yel": chr(27) + "[33m",
1297     }
1298
1299     # add dynamic macros where possible
1300     if user.account:
1301         account_name = user.account.get("name")
1302         if account_name:
1303             macros["account"] = account_name
1304     if user.avatar:
1305         avatar_gender = user.avatar.get("gender")
1306         if avatar_gender:
1307             macros["tpop"] = pronouns[avatar_gender]["obj"]
1308             macros["tppp"] = pronouns[avatar_gender]["pos"]
1309             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1310
1311     # loop until broken
1312     while True:
1313
1314         # find and replace per the macros dict
1315         macro_start = text.find("$(")
1316         if macro_start == -1:
1317             break
1318         macro_end = text.find(")", macro_start) + 1
1319         macro = text[macro_start + 2:macro_end - 1]
1320         if macro in macros.keys():
1321             replacement = macros[macro]
1322
1323         # this is how we handle local file inclusion (dangerous!)
1324         elif macro.startswith("inc:"):
1325             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1326             if os.path.exists(incfile):
1327                 incfd = codecs.open(incfile, "r", "utf-8")
1328                 replacement = ""
1329                 for line in incfd:
1330                     if line.endswith("\n") and not line.endswith("\r\n"):
1331                         line = line.replace("\n", "\r\n")
1332                     replacement += line
1333                 # lose the trailing eol
1334                 replacement = replacement[:-2]
1335             else:
1336                 replacement = ""
1337                 log("Couldn't read included " + incfile + " file.", 6)
1338
1339         # if we get here, log and replace it with null
1340         else:
1341             replacement = ""
1342             if not is_input:
1343                 log("Unexpected replacement macro " +
1344                     macro + " encountered.", 6)
1345
1346         # and now we act on the replacement
1347         text = text.replace("$(" + macro + ")", replacement)
1348
1349     # replace the look-like-a-macro sequence
1350     text = text.replace("$_(", "$(")
1351
1352     return text
1353
1354
1355 def escape_macros(value):
1356     """Escapes replacement macros in text."""
1357     if type(value) is str:
1358         return value.replace("$(", "$_(")
1359     else:
1360         return value
1361
1362
1363 def first_word(text, separator=" "):
1364     """Returns a tuple of the first word and the rest."""
1365     if text:
1366         if text.find(separator) > 0:
1367             return text.split(separator, 1)
1368         else:
1369             return text, ""
1370     else:
1371         return "", ""
1372
1373
1374 def on_pulse():
1375     """The things which should happen on each pulse, aside from reloads."""
1376
1377     # open the listening socket if it hasn't been already
1378     if not hasattr(universe, "listening_socket"):
1379         universe.initialize_server_socket()
1380
1381     # assign a user if a new connection is waiting
1382     user = check_for_connection(universe.listening_socket)
1383     if user:
1384         universe.userlist.append(user)
1385
1386     # iterate over the connected users
1387     for user in universe.userlist:
1388         user.pulse()
1389
1390     # add an element for counters if it doesn't exist
1391     if "counters" not in universe.categories["internal"]:
1392         universe.categories["internal"]["counters"] = Element(
1393             "internal:counters", universe, old_style=True
1394         )
1395
1396     # update the log every now and then
1397     if not universe.categories["internal"]["counters"].get("mark"):
1398         log(str(len(universe.userlist)) + " connection(s)")
1399         universe.categories["internal"]["counters"].set(
1400             "mark", universe.categories["internal"]["time"].get(
1401                 "frequency_log"
1402             )
1403         )
1404     else:
1405         universe.categories["internal"]["counters"].set(
1406             "mark", universe.categories["internal"]["counters"].get(
1407                 "mark"
1408             ) - 1
1409         )
1410
1411     # periodically save everything
1412     if not universe.categories["internal"]["counters"].get("save"):
1413         universe.save()
1414         universe.categories["internal"]["counters"].set(
1415             "save", universe.categories["internal"]["time"].get(
1416                 "frequency_save"
1417             )
1418         )
1419     else:
1420         universe.categories["internal"]["counters"].set(
1421             "save", universe.categories["internal"]["counters"].get(
1422                 "save"
1423             ) - 1
1424         )
1425
1426     # pause for a configurable amount of time (decimal seconds)
1427     time.sleep(universe.categories["internal"]
1428                ["time"].get("increment"))
1429
1430     # increase the elapsed increment counter
1431     universe.categories["internal"]["counters"].set(
1432         "elapsed", universe.categories["internal"]["counters"].get(
1433             "elapsed", 0
1434         ) + 1
1435     )
1436
1437
1438 def reload_data():
1439     """Reload all relevant objects."""
1440     for user in universe.userlist[:]:
1441         user.reload()
1442     for element in universe.contents.values():
1443         if element.origin.is_writeable():
1444             element.reload()
1445     universe.load()
1446
1447
1448 def check_for_connection(listening_socket):
1449     """Check for a waiting connection and return a new user object."""
1450
1451     # try to accept a new connection
1452     try:
1453         connection, address = listening_socket.accept()
1454     except BlockingIOError:
1455         return None
1456
1457     # note that we got one
1458     log("Connection from " + address[0], 2)
1459
1460     # disable blocking so we can proceed whether or not we can send/receive
1461     connection.setblocking(0)
1462
1463     # create a new user object
1464     user = User()
1465
1466     # associate this connection with it
1467     user.connection = connection
1468
1469     # set the user's ipa from the connection's ipa
1470     user.address = address[0]
1471
1472     # let the client know we WILL EOR (RFC 885)
1473     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1474     user.negotiation_pause = 2
1475
1476     # return the new user object
1477     return user
1478
1479
1480 def get_menu(state, error=None, choices=None):
1481     """Show the correct menu text to a user."""
1482
1483     # make sure we don't reuse a mutable sequence by default
1484     if choices is None:
1485         choices = {}
1486
1487     # get the description or error text
1488     message = get_menu_description(state, error)
1489
1490     # get menu choices for the current state
1491     message += get_formatted_menu_choices(state, choices)
1492
1493     # try to get a prompt, if it was defined
1494     message += get_menu_prompt(state)
1495
1496     # throw in the default choice, if it exists
1497     message += get_formatted_default_menu_choice(state)
1498
1499     # display a message indicating if echo is off
1500     message += get_echo_message(state)
1501
1502     # return the assembly of various strings defined above
1503     return message
1504
1505
1506 def menu_echo_on(state):
1507     """True if echo is on, false if it is off."""
1508     return universe.categories["menu"][state].get("echo", True)
1509
1510
1511 def get_echo_message(state):
1512     """Return a message indicating that echo is off."""
1513     if menu_echo_on(state):
1514         return ""
1515     else:
1516         return "(won't echo) "
1517
1518
1519 def get_default_menu_choice(state):
1520     """Return the default choice for a menu."""
1521     return universe.categories["menu"][state].get("default")
1522
1523
1524 def get_formatted_default_menu_choice(state):
1525     """Default menu choice foratted for inclusion in a prompt string."""
1526     default_choice = get_default_menu_choice(state)
1527     if default_choice:
1528         return "[$(red)" + default_choice + "$(nrm)] "
1529     else:
1530         return ""
1531
1532
1533 def get_menu_description(state, error):
1534     """Get the description or error text."""
1535
1536     # an error condition was raised by the handler
1537     if error:
1538
1539         # try to get an error message matching the condition
1540         # and current state
1541         description = universe.categories[
1542             "menu"][state].get("error_" + error)
1543         if not description:
1544             description = "That is not a valid choice..."
1545         description = "$(red)" + description + "$(nrm)"
1546
1547     # there was no error condition
1548     else:
1549
1550         # try to get a menu description for the current state
1551         description = universe.categories["menu"][state].get("description")
1552
1553     # return the description or error message
1554     if description:
1555         description += "$(eol)$(eol)"
1556     return description
1557
1558
1559 def get_menu_prompt(state):
1560     """Try to get a prompt, if it was defined."""
1561     prompt = universe.categories["menu"][state].get("prompt")
1562     if prompt:
1563         prompt += " "
1564     return prompt
1565
1566
1567 def get_menu_choices(user):
1568     """Return a dict of choice:meaning."""
1569     menu = universe.categories["menu"][user.state]
1570     create_choices = menu.get("create")
1571     if create_choices:
1572         choices = eval(create_choices)
1573     else:
1574         choices = {}
1575     ignores = []
1576     options = {}
1577     creates = {}
1578     for facet in menu.facets():
1579         if facet.startswith("demand_") and not eval(
1580            universe.categories["menu"][user.state].get(facet)
1581            ):
1582             ignores.append(facet.split("_", 2)[1])
1583         elif facet.startswith("create_"):
1584             creates[facet] = facet.split("_", 2)[1]
1585         elif facet.startswith("choice_"):
1586             options[facet] = facet.split("_", 2)[1]
1587     for facet in creates.keys():
1588         if not creates[facet] in ignores:
1589             choices[creates[facet]] = eval(menu.get(facet))
1590     for facet in options.keys():
1591         if not options[facet] in ignores:
1592             choices[options[facet]] = menu.get(facet)
1593     return choices
1594
1595
1596 def get_formatted_menu_choices(state, choices):
1597     """Returns a formatted string of menu choices."""
1598     choice_output = ""
1599     choice_keys = list(choices.keys())
1600     choice_keys.sort()
1601     for choice in choice_keys:
1602         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1603             choice
1604         ] + "$(eol)"
1605     if choice_output:
1606         choice_output += "$(eol)"
1607     return choice_output
1608
1609
1610 def get_menu_branches(state):
1611     """Return a dict of choice:branch."""
1612     branches = {}
1613     for facet in universe.categories["menu"][state].facets():
1614         if facet.startswith("branch_"):
1615             branches[
1616                 facet.split("_", 2)[1]
1617             ] = universe.categories["menu"][state].get(facet)
1618     return branches
1619
1620
1621 def get_default_branch(state):
1622     """Return the default branch."""
1623     return universe.categories["menu"][state].get("branch")
1624
1625
1626 def get_choice_branch(user, choice):
1627     """Returns the new state matching the given choice."""
1628     branches = get_menu_branches(user.state)
1629     if choice in branches.keys():
1630         return branches[choice]
1631     elif choice in user.menu_choices.keys():
1632         return get_default_branch(user.state)
1633     else:
1634         return ""
1635
1636
1637 def get_menu_actions(state):
1638     """Return a dict of choice:branch."""
1639     actions = {}
1640     for facet in universe.categories["menu"][state].facets():
1641         if facet.startswith("action_"):
1642             actions[
1643                 facet.split("_", 2)[1]
1644             ] = universe.categories["menu"][state].get(facet)
1645     return actions
1646
1647
1648 def get_default_action(state):
1649     """Return the default action."""
1650     return universe.categories["menu"][state].get("action")
1651
1652
1653 def get_choice_action(user, choice):
1654     """Run any indicated script for the given choice."""
1655     actions = get_menu_actions(user.state)
1656     if choice in actions.keys():
1657         return actions[choice]
1658     elif choice in user.menu_choices.keys():
1659         return get_default_action(user.state)
1660     else:
1661         return ""
1662
1663
1664 def handle_user_input(user):
1665     """The main handler, branches to a state-specific handler."""
1666
1667     # if the user's client echo is off, send a blank line for aesthetics
1668     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1669                                mudpy.telnet.US):
1670         user.send("", add_prompt=False, prepend_padding=False)
1671
1672     # check to make sure the state is expected, then call that handler
1673     if "handler_" + user.state in globals():
1674         exec("handler_" + user.state + "(user)")
1675     else:
1676         generic_menu_handler(user)
1677
1678     # since we got input, flag that the menu/prompt needs to be redisplayed
1679     user.menu_seen = False
1680
1681     # update the last_input timestamp while we're at it
1682     user.last_input = universe.get_time()
1683
1684
1685 def generic_menu_handler(user):
1686     """A generic menu choice handler."""
1687
1688     # get a lower-case representation of the next line of input
1689     if user.input_queue:
1690         choice = user.input_queue.pop(0)
1691         if choice:
1692             choice = choice.lower()
1693     else:
1694         choice = ""
1695     if not choice:
1696         choice = get_default_menu_choice(user.state)
1697     if choice in user.menu_choices:
1698         exec(get_choice_action(user, choice))
1699         new_state = get_choice_branch(user, choice)
1700         if new_state:
1701             user.state = new_state
1702     else:
1703         user.error = "default"
1704
1705
1706 def handler_entering_account_name(user):
1707     """Handle the login account name."""
1708
1709     # get the next waiting line of input
1710     input_data = user.input_queue.pop(0)
1711
1712     # did the user enter anything?
1713     if input_data:
1714
1715         # keep only the first word and convert to lower-case
1716         name = input_data.lower()
1717
1718         # fail if there are non-alphanumeric characters
1719         if name != "".join(filter(
1720                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1721                 name)):
1722             user.error = "bad_name"
1723
1724         # if that account exists, time to request a password
1725         elif name in universe.categories["account"]:
1726             user.account = universe.categories["account"][name]
1727             user.state = "checking_password"
1728
1729         # otherwise, this could be a brand new user
1730         else:
1731             user.account = Element("account:" + name, universe, old_style=True)
1732             user.account.set("name", name)
1733             log("New user: " + name, 2)
1734             user.state = "checking_new_account_name"
1735
1736     # if the user entered nothing for a name, then buhbye
1737     else:
1738         user.state = "disconnecting"
1739
1740
1741 def handler_checking_password(user):
1742     """Handle the login account password."""
1743
1744     # get the next waiting line of input
1745     input_data = user.input_queue.pop(0)
1746
1747     # does the hashed input equal the stored hash?
1748     if mudpy.password.verify(input_data, user.account.get("passhash")):
1749
1750         # if so, set the username and load from cold storage
1751         if not user.replace_old_connections():
1752             user.authenticate()
1753             user.state = "main_utility"
1754
1755     # if at first your hashes don't match, try, try again
1756     elif user.password_tries < universe.contents["mudpy.limit"].get(
1757             "password_tries") - 1:
1758         user.password_tries += 1
1759         user.error = "incorrect"
1760
1761     # we've exceeded the maximum number of password failures, so disconnect
1762     else:
1763         user.send(
1764             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1765         )
1766         user.state = "disconnecting"
1767
1768
1769 def handler_entering_new_password(user):
1770     """Handle a new password entry."""
1771
1772     # get the next waiting line of input
1773     input_data = user.input_queue.pop(0)
1774
1775     # make sure the password is strong--at least one upper, one lower and
1776     # one digit, seven or more characters in length
1777     if len(input_data) > 6 and len(
1778        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1779        ) and len(
1780         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1781     ) and len(
1782         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1783     ):
1784
1785         # hash and store it, then move on to verification
1786         user.account.set("passhash", mudpy.password.create(input_data))
1787         user.state = "verifying_new_password"
1788
1789     # the password was weak, try again if you haven't tried too many times
1790     elif user.password_tries < universe.contents["mudpy.limit"].get(
1791             "password_tries") - 1:
1792         user.password_tries += 1
1793         user.error = "weak"
1794
1795     # too many tries, so adios
1796     else:
1797         user.send(
1798             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1799         )
1800         user.account.destroy()
1801         user.state = "disconnecting"
1802
1803
1804 def handler_verifying_new_password(user):
1805     """Handle the re-entered new password for verification."""
1806
1807     # get the next waiting line of input
1808     input_data = user.input_queue.pop(0)
1809
1810     # hash the input and match it to storage
1811     if mudpy.password.verify(input_data, user.account.get("passhash")):
1812         user.authenticate()
1813
1814         # the hashes matched, so go active
1815         if not user.replace_old_connections():
1816             user.state = "main_utility"
1817
1818     # go back to entering the new password as long as you haven't tried
1819     # too many times
1820     elif user.password_tries < universe.contents["mudpy.limit"].get(
1821             "password_tries") - 1:
1822         user.password_tries += 1
1823         user.error = "differs"
1824         user.state = "entering_new_password"
1825
1826     # otherwise, sayonara
1827     else:
1828         user.send(
1829             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1830         )
1831         user.account.destroy()
1832         user.state = "disconnecting"
1833
1834
1835 def handler_active(user):
1836     """Handle input for active users."""
1837
1838     # get the next waiting line of input
1839     input_data = user.input_queue.pop(0)
1840
1841     # is there input?
1842     if input_data:
1843
1844         # split out the command and parameters
1845         actor = user.avatar
1846         mode = actor.get("mode")
1847         if mode and input_data.startswith("!"):
1848             command_name, parameters = first_word(input_data[1:])
1849         elif mode == "chat":
1850             command_name = "say"
1851             parameters = input_data
1852         else:
1853             command_name, parameters = first_word(input_data)
1854
1855         # lowercase the command
1856         command_name = command_name.lower()
1857
1858         # the command matches a command word for which we have data
1859         if command_name in universe.categories["command"]:
1860             command = universe.categories["command"][command_name]
1861         else:
1862             command = None
1863
1864         # if it's allowed, do it
1865         if actor.can_run(command):
1866             exec(command.get("action"))
1867
1868         # otherwise, give an error
1869         elif command_name:
1870             command_error(actor, input_data)
1871
1872     # if no input, just idle back with a prompt
1873     else:
1874         user.send("", just_prompt=True)
1875
1876
1877 def command_halt(actor, parameters):
1878     """Halt the world."""
1879     if actor.owner:
1880
1881         # see if there's a message or use a generic one
1882         if parameters:
1883             message = "Halting: " + parameters
1884         else:
1885             message = "User " + actor.owner.account.get(
1886                 "name"
1887             ) + " halted the world."
1888
1889         # let everyone know
1890         broadcast(message, add_prompt=False)
1891         log(message, 8)
1892
1893         # set a flag to terminate the world
1894         universe.terminate_flag = True
1895
1896
1897 def command_reload(actor):
1898     """Reload all code modules, configs and data."""
1899     if actor.owner:
1900
1901         # let the user know and log
1902         actor.send("Reloading all code modules, configs and data.")
1903         log(
1904             "User " +
1905             actor.owner.account.get("name") + " reloaded the world.",
1906             8
1907         )
1908
1909         # set a flag to reload
1910         universe.reload_flag = True
1911
1912
1913 def command_quit(actor):
1914     """Leave the world and go back to the main menu."""
1915     if actor.owner:
1916         actor.owner.state = "main_utility"
1917         actor.owner.deactivate_avatar()
1918
1919
1920 def command_help(actor, parameters):
1921     """List available commands and provide help for commands."""
1922
1923     # did the user ask for help on a specific command word?
1924     if parameters and actor.owner:
1925
1926         # is the command word one for which we have data?
1927         if parameters in universe.categories["command"]:
1928             command = universe.categories["command"][parameters]
1929         else:
1930             command = None
1931
1932         # only for allowed commands
1933         if actor.can_run(command):
1934
1935             # add a description if provided
1936             description = command.get("description")
1937             if not description:
1938                 description = "(no short description provided)"
1939             if command.get("administrative"):
1940                 output = "$(red)"
1941             else:
1942                 output = "$(grn)"
1943             output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1944
1945             # add the help text if provided
1946             help_text = command.get("help")
1947             if not help_text:
1948                 help_text = "No help is provided for this command."
1949             output += help_text
1950
1951             # list related commands
1952             see_also = command.get("see_also")
1953             if see_also:
1954                 really_see_also = ""
1955                 for item in see_also:
1956                     if item in universe.categories["command"]:
1957                         command = universe.categories["command"][item]
1958                         if actor.can_run(command):
1959                             if really_see_also:
1960                                 really_see_also += ", "
1961                             if command.get("administrative"):
1962                                 really_see_also += "$(red)"
1963                             else:
1964                                 really_see_also += "$(grn)"
1965                             really_see_also += item + "$(nrm)"
1966                 if really_see_also:
1967                     output += "$(eol)$(eol)See also: " + really_see_also
1968
1969         # no data for the requested command word
1970         else:
1971             output = "That is not an available command."
1972
1973     # no specific command word was indicated
1974     else:
1975
1976         # give a sorted list of commands with descriptions if provided
1977         output = "These are the commands available to you:$(eol)$(eol)"
1978         sorted_commands = list(universe.categories["command"].keys())
1979         sorted_commands.sort()
1980         for item in sorted_commands:
1981             command = universe.categories["command"][item]
1982             if actor.can_run(command):
1983                 description = command.get("description")
1984                 if not description:
1985                     description = "(no short description provided)"
1986                 if command.get("administrative"):
1987                     output += "   $(red)"
1988                 else:
1989                     output += "   $(grn)"
1990                 output += item + "$(nrm) - " + description + "$(eol)"
1991         output += ("$(eol)Enter \"help COMMAND\" for help on a command "
1992                    "named \"COMMAND\".")
1993
1994     # send the accumulated output to the user
1995     actor.send(output)
1996
1997
1998 def command_move(actor, parameters):
1999     """Move the avatar in a given direction."""
2000     if parameters in universe.contents[actor.get("location")].portals():
2001         actor.move_direction(parameters)
2002     else:
2003         actor.send("You cannot go that way.")
2004
2005
2006 def command_look(actor, parameters):
2007     """Look around."""
2008     if parameters:
2009         actor.send("You can't look at or in anything yet.")
2010     else:
2011         actor.look_at(actor.get("location"))
2012
2013
2014 def command_say(actor, parameters):
2015     """Speak to others in the same area."""
2016
2017     # check for replacement macros and escape them
2018     parameters = escape_macros(parameters)
2019
2020     # if the message is wrapped in quotes, remove them and leave contents
2021     # intact
2022     if parameters.startswith("\"") and parameters.endswith("\""):
2023         message = parameters[1:-1]
2024         literal = True
2025
2026     # otherwise, get rid of stray quote marks on the ends of the message
2027     else:
2028         message = parameters.strip("\"'`")
2029         literal = False
2030
2031     # the user entered a message
2032     if message:
2033
2034         # match the punctuation used, if any, to an action
2035         actions = universe.contents["mudpy.linguistic"].get(
2036             "actions"
2037         )
2038         default_punctuation = (
2039             universe.contents["mudpy.linguistic"].get(
2040                 "default_punctuation"))
2041         action = ""
2042
2043         # reverse sort punctuation options so the longest match wins
2044         for mark in sorted(actions.keys(), reverse=True):
2045             if not literal and message.endswith(mark):
2046                 action = actions[mark]
2047                 break
2048
2049         # add punctuation if needed
2050         if not action:
2051             action = actions[default_punctuation]
2052             if message and not (
2053                literal or unicodedata.category(message[-1]) == "Po"
2054                ):
2055                 message += default_punctuation
2056
2057         # failsafe checks to avoid unwanted reformatting and null strings
2058         if message and not literal:
2059
2060             # decapitalize the first letter to improve matching
2061             message = message[0].lower() + message[1:]
2062
2063             # iterate over all words in message, replacing typos
2064             typos = universe.contents["mudpy.linguistic"].get(
2065                 "typos"
2066             )
2067             words = message.split()
2068             for index in range(len(words)):
2069                 word = words[index]
2070                 while unicodedata.category(word[0]) == "Po":
2071                     word = word[1:]
2072                 while unicodedata.category(word[-1]) == "Po":
2073                     word = word[:-1]
2074                 if word in typos.keys():
2075                     words[index] = words[index].replace(word, typos[word])
2076             message = " ".join(words)
2077
2078             # capitalize the first letter
2079             message = message[0].upper() + message[1:]
2080
2081     # tell the area
2082     if message:
2083         actor.echo_to_location(
2084             actor.get("name") + " " + action + "s, \"" + message + "\""
2085         )
2086         actor.send("You " + action + ", \"" + message + "\"")
2087
2088     # there was no message
2089     else:
2090         actor.send("What do you want to say?")
2091
2092
2093 def command_chat(actor):
2094     """Toggle chat mode."""
2095     mode = actor.get("mode")
2096     if not mode:
2097         actor.set("mode", "chat")
2098         actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2099     elif mode == "chat":
2100         actor.remove_facet("mode")
2101         actor.send("Exiting chat mode.")
2102     else:
2103         actor.send("Sorry, but you're already busy with something else!")
2104
2105
2106 def command_show(actor, parameters):
2107     """Show program data."""
2108     message = ""
2109     arguments = parameters.split()
2110     if not parameters:
2111         message = "What do you want to show?"
2112     elif arguments[0] == "time":
2113         message = universe.categories["internal"]["counters"].get(
2114             "elapsed"
2115         ) + " increments elapsed since the world was created."
2116     elif arguments[0] == "categories":
2117         message = "These are the element categories:$(eol)"
2118         categories = list(universe.categories.keys())
2119         categories.sort()
2120         for category in categories:
2121             message += "$(eol)   $(grn)" + category + "$(nrm)"
2122     elif arguments[0] == "files":
2123         message = "These are the current files containing the universe:$(eol)"
2124         filenames = list(universe.files.keys())
2125         filenames.sort()
2126         for filename in filenames:
2127             if universe.files[filename].is_writeable():
2128                 status = "rw"
2129             else:
2130                 status = "ro"
2131             message += ("$(eol)   $(red)(" + status + ") $(grn)" + filename
2132                         + "$(nrm)")
2133     elif arguments[0] == "category":
2134         if len(arguments) != 2:
2135             message = "You must specify one category."
2136         elif arguments[1] in universe.categories:
2137             message = ("These are the elements in the \"" + arguments[1]
2138                        + "\" category:$(eol)")
2139             elements = [
2140                 (
2141                     universe.categories[arguments[1]][x].key
2142                 ) for x in universe.categories[arguments[1]].keys()
2143             ]
2144             elements.sort()
2145             for element in elements:
2146                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2147         else:
2148             message = "Category \"" + arguments[1] + "\" does not exist."
2149     elif arguments[0] == "file":
2150         if len(arguments) != 2:
2151             message = "You must specify one file."
2152         elif arguments[1] in universe.files:
2153             message = ("These are the elements in the \"" + arguments[1]
2154                        + "\" file:$(eol)")
2155             elements = universe.files[arguments[1]].data.keys()
2156             elements.sort()
2157             for element in elements:
2158                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2159         else:
2160             message = "Category \"" + arguments[1] + "\" does not exist."
2161     elif arguments[0] == "element":
2162         if len(arguments) != 2:
2163             message = "You must specify one element."
2164         elif arguments[1].strip(".") in universe.contents:
2165             element = universe.contents[arguments[1].strip(".")]
2166             message = ("These are the properties of the \"" + arguments[1]
2167                        + "\" element (in \"" + element.origin.filename
2168                        + "\"):$(eol)")
2169             facets = element.facets()
2170             for facet in sorted(facets):
2171                 if element.old_style:
2172                     message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
2173                                 (facet, escape_macros(element.get(facet))))
2174                 else:
2175                     message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
2176                                 (facet, str(facets[facet])))
2177         else:
2178             message = "Element \"" + arguments[1] + "\" does not exist."
2179     elif arguments[0] == "result":
2180         if len(arguments) < 2:
2181             message = "You need to specify an expression."
2182         else:
2183             try:
2184                 message = repr(eval(" ".join(arguments[1:])))
2185             except Exception as e:
2186                 message = ("$(red)Your expression raised an exception...$(eol)"
2187                            "$(eol)$(bld)%s$(nrm)" % e)
2188     elif arguments[0] == "log":
2189         if len(arguments) == 4:
2190             if re.match("^\d+$", arguments[3]) and int(arguments[3]) >= 0:
2191                 stop = int(arguments[3])
2192             else:
2193                 stop = -1
2194         else:
2195             stop = 0
2196         if len(arguments) >= 3:
2197             if re.match("^\d+$", arguments[2]) and int(arguments[2]) > 0:
2198                 start = int(arguments[2])
2199             else:
2200                 start = -1
2201         else:
2202             start = 10
2203         if len(arguments) >= 2:
2204             if (re.match("^\d+$", arguments[1])
2205                     and 0 <= int(arguments[1]) <= 9):
2206                 level = int(arguments[1])
2207             else:
2208                 level = -1
2209         elif 0 <= actor.owner.account.get("loglevel", 0) <= 9:
2210             level = actor.owner.account.get("loglevel", 0)
2211         else:
2212             level = 1
2213         if level > -1 and start > -1 and stop > -1:
2214             message = get_loglines(level, start, stop)
2215         else:
2216             message = ("When specified, level must be 0-9 (default 1), "
2217                        "start and stop must be >=1 (default 10 and 1).")
2218     else:
2219         message = "I don't know what \"" + parameters + "\" is."
2220     actor.send(message)
2221
2222
2223 def command_create(actor, parameters):
2224     """Create an element if it does not exist."""
2225     if not parameters:
2226         message = "You must at least specify an element to create."
2227     elif not actor.owner:
2228         message = ""
2229     else:
2230         arguments = parameters.split()
2231         if len(arguments) == 1:
2232             arguments.append("")
2233         if len(arguments) == 2:
2234             element, filename = arguments
2235             if element in universe.contents:
2236                 message = "The \"" + element + "\" element already exists."
2237             else:
2238                 message = ("You create \"" +
2239                            element + "\" within the universe.")
2240                 logline = actor.owner.account.get(
2241                     "name"
2242                 ) + " created an element: " + element
2243                 if filename:
2244                     logline += " in file " + filename
2245                     if filename not in universe.files:
2246                         message += (
2247                             " Warning: \"" + filename + "\" is not yet "
2248                             "included in any other file and will not be read "
2249                             "on startup unless this is remedied.")
2250                 Element(element, universe, filename, old_style=True)
2251                 log(logline, 6)
2252         elif len(arguments) > 2:
2253             message = "You can only specify an element and a filename."
2254     actor.send(message)
2255
2256
2257 def command_destroy(actor, parameters):
2258     """Destroy an element if it exists."""
2259     if actor.owner:
2260         if not parameters:
2261             message = "You must specify an element to destroy."
2262         else:
2263             if parameters not in universe.contents:
2264                 message = "The \"" + parameters + "\" element does not exist."
2265             else:
2266                 universe.contents[parameters].destroy()
2267                 message = ("You destroy \"" + parameters
2268                            + "\" within the universe.")
2269                 log(
2270                     actor.owner.account.get(
2271                         "name"
2272                     ) + " destroyed an element: " + parameters,
2273                     6
2274                 )
2275         actor.send(message)
2276
2277
2278 def command_set(actor, parameters):
2279     """Set a facet of an element."""
2280     if not parameters:
2281         message = "You must specify an element, a facet and a value."
2282     else:
2283         arguments = parameters.split(" ", 2)
2284         if len(arguments) == 1:
2285             message = ("What facet of element \"" + arguments[0]
2286                        + "\" would you like to set?")
2287         elif len(arguments) == 2:
2288             message = ("What value would you like to set for the \"" +
2289                        arguments[1] + "\" facet of the \"" + arguments[0]
2290                        + "\" element?")
2291         else:
2292             element, facet, value = arguments
2293             if element not in universe.contents:
2294                 message = "The \"" + element + "\" element does not exist."
2295             else:
2296                 universe.contents[element].set(facet, value)
2297                 message = ("You have successfully (re)set the \"" + facet
2298                            + "\" facet of element \"" + element
2299                            + "\". Try \"show element " +
2300                            element + "\" for verification.")
2301     actor.send(message)
2302
2303
2304 def command_delete(actor, parameters):
2305     """Delete a facet from an element."""
2306     if not parameters:
2307         message = "You must specify an element and a facet."
2308     else:
2309         arguments = parameters.split(" ")
2310         if len(arguments) == 1:
2311             message = ("What facet of element \"" + arguments[0]
2312                        + "\" would you like to delete?")
2313         elif len(arguments) != 2:
2314             message = "You may only specify an element and a facet."
2315         else:
2316             element, facet = arguments
2317             if element not in universe.contents:
2318                 message = "The \"" + element + "\" element does not exist."
2319             elif facet not in universe.contents[element].facets():
2320                 message = ("The \"" + element + "\" element has no \"" + facet
2321                            + "\" facet.")
2322             else:
2323                 universe.contents[element].remove_facet(facet)
2324                 message = ("You have successfully deleted the \"" + facet
2325                            + "\" facet of element \"" + element
2326                            + "\". Try \"show element " +
2327                            element + "\" for verification.")
2328     actor.send(message)
2329
2330
2331 def command_error(actor, input_data):
2332     """Generic error for an unrecognized command word."""
2333
2334     # 90% of the time use a generic error
2335     if random.randrange(10):
2336         message = "I'm not sure what \"" + input_data + "\" means..."
2337
2338     # 10% of the time use the classic diku error
2339     else:
2340         message = "Arglebargle, glop-glyf!?!"
2341
2342     # send the error message
2343     actor.send(message)
2344
2345
2346 def daemonize(universe):
2347     """Fork and disassociate from everything."""
2348
2349     # only if this is what we're configured to do
2350     if universe.contents["internal:process"].get("daemon"):
2351
2352         # log before we start forking around, so the terminal gets the message
2353         log("Disassociating from the controlling terminal.")
2354
2355         # fork off and die, so we free up the controlling terminal
2356         if os.fork():
2357             os._exit(0)
2358
2359         # switch to a new process group
2360         os.setsid()
2361
2362         # fork some more, this time to free us from the old process group
2363         if os.fork():
2364             os._exit(0)
2365
2366         # reset the working directory so we don't needlessly tie up mounts
2367         os.chdir("/")
2368
2369         # clear the file creation mask so we can bend it to our will later
2370         os.umask(0)
2371
2372         # redirect stdin/stdout/stderr and close off their former descriptors
2373         for stdpipe in range(3):
2374             os.close(stdpipe)
2375         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2376         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2377         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2378         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2379         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2380         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2381
2382
2383 def create_pidfile(universe):
2384     """Write a file containing the current process ID."""
2385     pid = str(os.getpid())
2386     log("Process ID: " + pid)
2387     file_name = universe.contents["internal:process"].get("pidfile")
2388     if file_name:
2389         if not os.path.isabs(file_name):
2390             file_name = os.path.join(universe.startdir, file_name)
2391         file_descriptor = codecs.open(file_name, "w", "utf-8")
2392         file_descriptor.write(pid + "\n")
2393         file_descriptor.flush()
2394         file_descriptor.close()
2395
2396
2397 def remove_pidfile(universe):
2398     """Remove the file containing the current process ID."""
2399     file_name = universe.contents["internal:process"].get("pidfile")
2400     if file_name:
2401         if not os.path.isabs(file_name):
2402             file_name = os.path.join(universe.startdir, file_name)
2403         if os.access(file_name, os.W_OK):
2404             os.remove(file_name)
2405
2406
2407 def excepthook(excepttype, value, tracebackdata):
2408     """Handle uncaught exceptions."""
2409
2410     # assemble the list of errors into a single string
2411     message = "".join(
2412         traceback.format_exception(excepttype, value, tracebackdata)
2413     )
2414
2415     # try to log it, if possible
2416     try:
2417         log(message, 9)
2418     except Exception as e:
2419         # try to write it to stderr, if possible
2420         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2421
2422
2423 def sighook(what, where):
2424     """Handle external signals."""
2425
2426     # a generic message
2427     message = "Caught signal: "
2428
2429     # for a hangup signal
2430     if what == signal.SIGHUP:
2431         message += "hangup (reloading)"
2432         universe.reload_flag = True
2433
2434     # for a terminate signal
2435     elif what == signal.SIGTERM:
2436         message += "terminate (halting)"
2437         universe.terminate_flag = True
2438
2439     # catchall for unexpected signals
2440     else:
2441         message += str(what) + " (unhandled)"
2442
2443     # log what happened
2444     log(message, 8)
2445
2446
2447 def override_excepthook():
2448     """Redefine sys.excepthook with our own."""
2449     sys.excepthook = excepthook
2450
2451
2452 def assign_sighook():
2453     """Assign a customized handler for some signals."""
2454     signal.signal(signal.SIGHUP, sighook)
2455     signal.signal(signal.SIGTERM, sighook)
2456
2457
2458 def setup():
2459     """This contains functions to be performed when starting the engine."""
2460
2461     # see if a configuration file was specified
2462     if len(sys.argv) > 1:
2463         conffile = sys.argv[1]
2464     else:
2465         conffile = ""
2466
2467     # the big bang
2468     global universe
2469     universe = Universe(conffile, True)
2470
2471     # report any loglines which accumulated during setup
2472     for logline in universe.setup_loglines:
2473         log(*logline)
2474     universe.setup_loglines = []
2475
2476     # log an initial message
2477     log("Started mudpy with command line: " + " ".join(sys.argv))
2478
2479     # fork and disassociate
2480     daemonize(universe)
2481
2482     # override the default exception handler so we get logging first thing
2483     override_excepthook()
2484
2485     # set up custom signal handlers
2486     assign_sighook()
2487
2488     # make the pidfile
2489     create_pidfile(universe)
2490
2491     # pass the initialized universe back
2492     return universe
2493
2494
2495 def finish():
2496     """These are functions performed when shutting down the engine."""
2497
2498     # the loop has terminated, so save persistent data
2499     universe.save()
2500
2501     # log a final message
2502     log("Shutting down now.")
2503
2504     # get rid of the pidfile
2505     remove_pidfile(universe)