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