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