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