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