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