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