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