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