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