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