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