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