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