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