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