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