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