Stop trying to rename the daemon process
[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:
897                 if self.account and self.account.get("name"):
898                     account = self.account.get("name")
899                 else:
900                     account = "an unknown user"
901                 log("Sending to %s raised an exception (broken pipe?)."
902                     % account, 7)
903                 pass
904
905     def enqueue_input(self):
906         """Process and enqueue any new input."""
907
908         # check for some input
909         try:
910             raw_input = self.connection.recv(1024)
911         except:
912             raw_input = b""
913
914         # we got something
915         if raw_input:
916
917             # tack this on to any previous partial
918             self.partial_input += raw_input
919
920             # reply to and remove any IAC negotiation codes
921             mudpy.telnet.negotiate_telnet_options(self)
922
923             # separate multiple input lines
924             new_input_lines = self.partial_input.split(b"\n")
925
926             # if input doesn't end in a newline, replace the
927             # held partial input with the last line of it
928             if not self.partial_input.endswith(b"\n"):
929                 self.partial_input = new_input_lines.pop()
930
931             # otherwise, chop off the extra null input and reset
932             # the held partial input
933             else:
934                 new_input_lines.pop()
935                 self.partial_input = b""
936
937             # iterate over the remaining lines
938             for line in new_input_lines:
939
940                 # strip off extra whitespace
941                 line = line.strip()
942
943                 # log non-printable characters remaining
944                 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
945                                            mudpy.telnet.HIM):
946                     asciiline = b"".join(
947                         filter(lambda x: b" " <= x <= b"~", line))
948                     if line != asciiline:
949                         logline = "Non-ASCII characters from "
950                         if self.account and self.account.get("name"):
951                             logline += self.account.get("name") + ": "
952                         else:
953                             logline += "unknown user: "
954                         logline += repr(line)
955                         log(logline, 4)
956                         line = asciiline
957
958                 try:
959                     line = line.decode("utf-8")
960                 except UnicodeDecodeError:
961                     logline = "Non-UTF-8 characters from "
962                     if self.account and self.account.get("name"):
963                         logline += self.account.get("name") + ": "
964                     else:
965                         logline += "unknown user: "
966                     logline += repr(line)
967                     log(logline, 4)
968                     return
969
970                 line = unicodedata.normalize("NFKC", line)
971
972                 # put on the end of the queue
973                 self.input_queue.append(line)
974
975     def new_avatar(self):
976         """Instantiate a new, unconfigured avatar for this user."""
977         counter = 0
978         while "avatar:" + self.account.get("name") + ":" + str(
979             counter
980         ) in universe.categories["actor"].keys():
981             counter += 1
982         self.avatar = Element(
983             "actor:avatar:" + self.account.get("name") + ":" + str(
984                 counter
985             ),
986             universe
987         )
988         self.avatar.append("inherit", "archetype:avatar")
989         self.account.append("avatars", self.avatar.key)
990
991     def delete_avatar(self, avatar):
992         """Remove an avatar from the world and from the user's list."""
993         if self.avatar is universe.contents[avatar]:
994             self.avatar = None
995         universe.contents[avatar].destroy()
996         avatars = self.account.getlist("avatars")
997         avatars.remove(avatar)
998         self.account.set("avatars", avatars)
999
1000     def activate_avatar_by_index(self, index):
1001         """Enter the world with a particular indexed avatar."""
1002         self.avatar = universe.contents[
1003             self.account.getlist("avatars")[index]]
1004         self.avatar.owner = self
1005         self.state = "active"
1006         self.avatar.go_home()
1007
1008     def deactivate_avatar(self):
1009         """Have the active avatar leave the world."""
1010         if self.avatar:
1011             current = self.avatar.get("location")
1012             if current:
1013                 self.avatar.set("default_location", current)
1014                 self.avatar.echo_to_location(
1015                     "You suddenly wonder where " + self.avatar.get(
1016                         "name"
1017                     ) + " went."
1018                 )
1019                 del universe.contents[current].contents[self.avatar.key]
1020                 self.avatar.remove_facet("location")
1021             self.avatar.owner = None
1022             self.avatar = None
1023
1024     def destroy(self):
1025         """Destroy the user and associated avatars."""
1026         for avatar in self.account.getlist("avatars"):
1027             self.delete_avatar(avatar)
1028         self.account.destroy()
1029
1030     def list_avatar_names(self):
1031         """List names of assigned avatars."""
1032         return [
1033             universe.contents[avatar].get(
1034                 "name"
1035             ) for avatar in self.account.getlist("avatars")
1036         ]
1037
1038
1039 def broadcast(message, add_prompt=True):
1040     """Send a message to all connected users."""
1041     for each_user in universe.userlist:
1042         each_user.send("$(eol)" + message, add_prompt=add_prompt)
1043
1044
1045 def log(message, level=0):
1046     """Log a message."""
1047
1048     # a couple references we need
1049     file_name = universe.categories["internal"]["logging"].get("file")
1050     max_log_lines = universe.categories["internal"]["logging"].getint(
1051         "max_log_lines"
1052     )
1053     syslog_name = universe.categories["internal"]["logging"].get("syslog")
1054     timestamp = time.asctime()[4:19]
1055
1056     # turn the message into a list of lines
1057     lines = filter(
1058         lambda x: x != "", [(x.rstrip()) for x in message.split("\n")]
1059     )
1060
1061     # send the timestamp and line to a file
1062     if file_name:
1063         if not os.path.isabs(file_name):
1064             file_name = os.path.join(universe.startdir, file_name)
1065         file_descriptor = codecs.open(file_name, "a", "utf-8")
1066         for line in lines:
1067             file_descriptor.write(timestamp + " " + line + "\n")
1068         file_descriptor.flush()
1069         file_descriptor.close()
1070
1071     # send the timestamp and line to standard output
1072     if universe.categories["internal"]["logging"].getboolean("stdout"):
1073         for line in lines:
1074             print(timestamp + " " + line)
1075
1076     # send the line to the system log
1077     if syslog_name:
1078         syslog.openlog(
1079             syslog_name.encode("utf-8"),
1080             syslog.LOG_PID,
1081             syslog.LOG_INFO | syslog.LOG_DAEMON
1082         )
1083         for line in lines:
1084             syslog.syslog(line)
1085         syslog.closelog()
1086
1087     # display to connected administrators
1088     for user in universe.userlist:
1089         if user.state == "active" and user.account.getboolean(
1090            "administrator"
1091            ) and user.account.getint("loglevel") <= level:
1092             # iterate over every line in the message
1093             full_message = ""
1094             for line in lines:
1095                 full_message += (
1096                     "$(bld)$(red)" + timestamp + " "
1097                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1098             user.send(full_message, flush=True)
1099
1100     # add to the recent log list
1101     for line in lines:
1102         while 0 < len(universe.loglines) >= max_log_lines:
1103             del universe.loglines[0]
1104         universe.loglines.append((level, timestamp + " " + line))
1105
1106
1107 def get_loglines(level, start, stop):
1108     """Return a specific range of loglines filtered by level."""
1109
1110     # filter the log lines
1111     loglines = filter(lambda x: x[0] >= level, universe.loglines)
1112
1113     # we need these in several places
1114     total_count = str(len(universe.loglines))
1115     filtered_count = len(loglines)
1116
1117     # don't proceed if there are no lines
1118     if filtered_count:
1119
1120         # can't start before the begining or at the end
1121         if start > filtered_count:
1122             start = filtered_count
1123         if start < 1:
1124             start = 1
1125
1126         # can't stop before we start
1127         if stop > start:
1128             stop = start
1129         elif stop < 1:
1130             stop = 1
1131
1132         # some preamble
1133         message = "There are " + str(total_count)
1134         message += " log lines in memory and " + str(filtered_count)
1135         message += " at or above level " + str(level) + "."
1136         message += " The matching lines from " + str(stop) + " to "
1137         message += str(start) + " are:$(eol)$(eol)"
1138
1139         # add the text from the selected lines
1140         if stop > 1:
1141             range_lines = loglines[-start:-(stop - 1)]
1142         else:
1143             range_lines = loglines[-start:]
1144         for line in range_lines:
1145             message += "   (" + str(line[0]) + ") " + line[1].replace(
1146                 "$(", "$_("
1147             ) + "$(eol)"
1148
1149     # there were no lines
1150     else:
1151         message = "None of the " + str(total_count)
1152         message += " lines in memory matches your request."
1153
1154     # pass it back
1155     return message
1156
1157
1158 def glyph_columns(character):
1159     """Convenience function to return the column width of a glyph."""
1160     if unicodedata.east_asian_width(character) in "FW":
1161         return 2
1162     else:
1163         return 1
1164
1165
1166 def wrap_ansi_text(text, width):
1167     """Wrap text with arbitrary width while ignoring ANSI colors."""
1168
1169     # the current position in the entire text string, including all
1170     # characters, printable or otherwise
1171     abs_pos = 0
1172
1173     # the current text position relative to the begining of the line,
1174     # ignoring color escape sequences
1175     rel_pos = 0
1176
1177     # the absolute position of the most recent whitespace character
1178     last_whitespace = 0
1179
1180     # whether the current character is part of a color escape sequence
1181     escape = False
1182
1183     # normalize any potentially composited unicode before we count it
1184     text = unicodedata.normalize("NFKC", text)
1185
1186     # iterate over each character from the begining of the text
1187     for each_character in text:
1188
1189         # the current character is the escape character
1190         if each_character == "\x1b" and not escape:
1191             escape = True
1192
1193         # the current character is within an escape sequence
1194         elif escape:
1195
1196             # the current character is m, which terminates the
1197             # escape sequence
1198             if each_character == "m":
1199                 escape = False
1200
1201         # the current character is a newline, so reset the relative
1202         # position (start a new line)
1203         elif each_character == "\n":
1204             rel_pos = 0
1205             last_whitespace = abs_pos
1206
1207         # the current character meets the requested maximum line width,
1208         # so we need to backtrack and find a space at which to wrap;
1209         # special care is taken to avoid an off-by-one in case the
1210         # current character is a double-width glyph
1211         elif each_character != "\r" and (
1212             rel_pos >= width or (
1213                 rel_pos >= width - 1 and glyph_columns(
1214                     each_character
1215                 ) == 2
1216             )
1217         ):
1218
1219             # it's always possible we landed on whitespace
1220             if unicodedata.category(each_character) in ("Cc", "Zs"):
1221                 last_whitespace = abs_pos
1222
1223             # insert an eol in place of the space
1224             text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
1225
1226             # increase the absolute position because an eol is two
1227             # characters but the space it replaced was only one
1228             abs_pos += 1
1229
1230             # now we're at the begining of a new line, plus the
1231             # number of characters wrapped from the previous line
1232             rel_pos = 0
1233             for remaining_characters in text[last_whitespace:abs_pos]:
1234                 rel_pos += glyph_columns(remaining_characters)
1235
1236         # as long as the character is not a carriage return and the
1237         # other above conditions haven't been met, count it as a
1238         # printable character
1239         elif each_character != "\r":
1240             rel_pos += glyph_columns(each_character)
1241             if unicodedata.category(each_character) in ("Cc", "Zs"):
1242                 last_whitespace = abs_pos
1243
1244         # increase the absolute position for every character
1245         abs_pos += 1
1246
1247     # return the newly-wrapped text
1248     return text
1249
1250
1251 def weighted_choice(data):
1252     """Takes a dict weighted by value and returns a random key."""
1253
1254     # this will hold our expanded list of keys from the data
1255     expanded = []
1256
1257     # create the expanded list of keys
1258     for key in data.keys():
1259         for count in range(data[key]):
1260             expanded.append(key)
1261
1262     # return one at random
1263     return random.choice(expanded)
1264
1265
1266 def random_name():
1267     """Returns a random character name."""
1268
1269     # the vowels and consonants needed to create romaji syllables
1270     vowels = [
1271         "a",
1272         "i",
1273         "u",
1274         "e",
1275         "o"
1276     ]
1277     consonants = [
1278         "'",
1279         "k",
1280         "z",
1281         "s",
1282         "sh",
1283         "z",
1284         "j",
1285         "t",
1286         "ch",
1287         "ts",
1288         "d",
1289         "n",
1290         "h",
1291         "f",
1292         "m",
1293         "y",
1294         "r",
1295         "w"
1296     ]
1297
1298     # this dict will hold our weighted list of syllables
1299     syllables = {}
1300
1301     # generate the list with an even weighting
1302     for consonant in consonants:
1303         for vowel in vowels:
1304             syllables[consonant + vowel] = 1
1305
1306     # we'll build the name into this string
1307     name = ""
1308
1309     # create a name of random length from the syllables
1310     for syllable in range(random.randrange(2, 6)):
1311         name += weighted_choice(syllables)
1312
1313     # strip any leading quotemark, capitalize and return the name
1314     return name.strip("'").capitalize()
1315
1316
1317 def replace_macros(user, text, is_input=False):
1318     """Replaces macros in text output."""
1319
1320     # third person pronouns
1321     pronouns = {
1322         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1323         "male": {"obj": "him", "pos": "his", "sub": "he"},
1324         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1325     }
1326
1327     # a dict of replacement macros
1328     macros = {
1329         "eol": "\r\n",
1330         "bld": chr(27) + "[1m",
1331         "nrm": chr(27) + "[0m",
1332         "blk": chr(27) + "[30m",
1333         "blu": chr(27) + "[34m",
1334         "cyn": chr(27) + "[36m",
1335         "grn": chr(27) + "[32m",
1336         "mgt": chr(27) + "[35m",
1337         "red": chr(27) + "[31m",
1338         "yel": chr(27) + "[33m",
1339     }
1340
1341     # add dynamic macros where possible
1342     if user.account:
1343         account_name = user.account.get("name")
1344         if account_name:
1345             macros["account"] = account_name
1346     if user.avatar:
1347         avatar_gender = user.avatar.get("gender")
1348         if avatar_gender:
1349             macros["tpop"] = pronouns[avatar_gender]["obj"]
1350             macros["tppp"] = pronouns[avatar_gender]["pos"]
1351             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1352
1353     # loop until broken
1354     while True:
1355
1356         # find and replace per the macros dict
1357         macro_start = text.find("$(")
1358         if macro_start == -1:
1359             break
1360         macro_end = text.find(")", macro_start) + 1
1361         macro = text[macro_start + 2:macro_end - 1]
1362         if macro in macros.keys():
1363             replacement = macros[macro]
1364
1365         # this is how we handle local file inclusion (dangerous!)
1366         elif macro.startswith("inc:"):
1367             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1368             if os.path.exists(incfile):
1369                 incfd = codecs.open(incfile, "r", "utf-8")
1370                 replacement = ""
1371                 for line in incfd:
1372                     if line.endswith("\n") and not line.endswith("\r\n"):
1373                         line = line.replace("\n", "\r\n")
1374                     replacement += line
1375                 # lose the trailing eol
1376                 replacement = replacement[:-2]
1377             else:
1378                 replacement = ""
1379                 log("Couldn't read included " + incfile + " file.", 6)
1380
1381         # if we get here, log and replace it with null
1382         else:
1383             replacement = ""
1384             if not is_input:
1385                 log("Unexpected replacement macro " +
1386                     macro + " encountered.", 6)
1387
1388         # and now we act on the replacement
1389         text = text.replace("$(" + macro + ")", replacement)
1390
1391     # replace the look-like-a-macro sequence
1392     text = text.replace("$_(", "$(")
1393
1394     return text
1395
1396
1397 def escape_macros(text):
1398     """Escapes replacement macros in text."""
1399     return text.replace("$(", "$_(")
1400
1401
1402 def first_word(text, separator=" "):
1403     """Returns a tuple of the first word and the rest."""
1404     if text:
1405         if text.find(separator) > 0:
1406             return text.split(separator, 1)
1407         else:
1408             return text, ""
1409     else:
1410         return "", ""
1411
1412
1413 def on_pulse():
1414     """The things which should happen on each pulse, aside from reloads."""
1415
1416     # open the listening socket if it hasn't been already
1417     if not hasattr(universe, "listening_socket"):
1418         universe.initialize_server_socket()
1419
1420     # assign a user if a new connection is waiting
1421     user = check_for_connection(universe.listening_socket)
1422     if user:
1423         universe.userlist.append(user)
1424
1425     # iterate over the connected users
1426     for user in universe.userlist:
1427         user.pulse()
1428
1429     # add an element for counters if it doesn't exist
1430     if "counters" not in universe.categories["internal"]:
1431         universe.categories["internal"]["counters"] = Element(
1432             "internal:counters", universe
1433         )
1434
1435     # update the log every now and then
1436     if not universe.categories["internal"]["counters"].getint("mark"):
1437         log(str(len(universe.userlist)) + " connection(s)")
1438         universe.categories["internal"]["counters"].set(
1439             "mark", universe.categories["internal"]["time"].getint(
1440                 "frequency_log"
1441             )
1442         )
1443     else:
1444         universe.categories["internal"]["counters"].set(
1445             "mark", universe.categories["internal"]["counters"].getint(
1446                 "mark"
1447             ) - 1
1448         )
1449
1450     # periodically save everything
1451     if not universe.categories["internal"]["counters"].getint("save"):
1452         universe.save()
1453         universe.categories["internal"]["counters"].set(
1454             "save", universe.categories["internal"]["time"].getint(
1455                 "frequency_save"
1456             )
1457         )
1458     else:
1459         universe.categories["internal"]["counters"].set(
1460             "save", universe.categories["internal"]["counters"].getint(
1461                 "save"
1462             ) - 1
1463         )
1464
1465     # pause for a configurable amount of time (decimal seconds)
1466     time.sleep(universe.categories["internal"]
1467                ["time"].getfloat("increment"))
1468
1469     # increase the elapsed increment counter
1470     universe.categories["internal"]["counters"].set(
1471         "elapsed", universe.categories["internal"]["counters"].getint(
1472             "elapsed"
1473         ) + 1
1474     )
1475
1476
1477 def reload_data():
1478     """Reload all relevant objects."""
1479     for user in universe.userlist[:]:
1480         user.reload()
1481     for element in universe.contents.values():
1482         if element.origin.is_writeable():
1483             element.reload()
1484     universe.load()
1485
1486
1487 def check_for_connection(listening_socket):
1488     """Check for a waiting connection and return a new user object."""
1489
1490     # try to accept a new connection
1491     try:
1492         connection, address = listening_socket.accept()
1493     except:
1494         return None
1495
1496     # note that we got one
1497     log("Connection from " + address[0], 2)
1498
1499     # disable blocking so we can proceed whether or not we can send/receive
1500     connection.setblocking(0)
1501
1502     # create a new user object
1503     user = User()
1504
1505     # associate this connection with it
1506     user.connection = connection
1507
1508     # set the user's ipa from the connection's ipa
1509     user.address = address[0]
1510
1511     # let the client know we WILL EOR (RFC 885)
1512     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1513     user.negotiation_pause = 2
1514
1515     # return the new user object
1516     return user
1517
1518
1519 def get_menu(state, error=None, choices=None):
1520     """Show the correct menu text to a user."""
1521
1522     # make sure we don't reuse a mutable sequence by default
1523     if choices is None:
1524         choices = {}
1525
1526     # get the description or error text
1527     message = get_menu_description(state, error)
1528
1529     # get menu choices for the current state
1530     message += get_formatted_menu_choices(state, choices)
1531
1532     # try to get a prompt, if it was defined
1533     message += get_menu_prompt(state)
1534
1535     # throw in the default choice, if it exists
1536     message += get_formatted_default_menu_choice(state)
1537
1538     # display a message indicating if echo is off
1539     message += get_echo_message(state)
1540
1541     # return the assembly of various strings defined above
1542     return message
1543
1544
1545 def menu_echo_on(state):
1546     """True if echo is on, false if it is off."""
1547     return universe.categories["menu"][state].getboolean("echo", True)
1548
1549
1550 def get_echo_message(state):
1551     """Return a message indicating that echo is off."""
1552     if menu_echo_on(state):
1553         return ""
1554     else:
1555         return "(won't echo) "
1556
1557
1558 def get_default_menu_choice(state):
1559     """Return the default choice for a menu."""
1560     return universe.categories["menu"][state].get("default")
1561
1562
1563 def get_formatted_default_menu_choice(state):
1564     """Default menu choice foratted for inclusion in a prompt string."""
1565     default_choice = get_default_menu_choice(state)
1566     if default_choice:
1567         return "[$(red)" + default_choice + "$(nrm)] "
1568     else:
1569         return ""
1570
1571
1572 def get_menu_description(state, error):
1573     """Get the description or error text."""
1574
1575     # an error condition was raised by the handler
1576     if error:
1577
1578         # try to get an error message matching the condition
1579         # and current state
1580         description = universe.categories[
1581             "menu"][state].get("error_" + error)
1582         if not description:
1583             description = "That is not a valid choice..."
1584         description = "$(red)" + description + "$(nrm)"
1585
1586     # there was no error condition
1587     else:
1588
1589         # try to get a menu description for the current state
1590         description = universe.categories["menu"][state].get("description")
1591
1592     # return the description or error message
1593     if description:
1594         description += "$(eol)$(eol)"
1595     return description
1596
1597
1598 def get_menu_prompt(state):
1599     """Try to get a prompt, if it was defined."""
1600     prompt = universe.categories["menu"][state].get("prompt")
1601     if prompt:
1602         prompt += " "
1603     return prompt
1604
1605
1606 def get_menu_choices(user):
1607     """Return a dict of choice:meaning."""
1608     menu = universe.categories["menu"][user.state]
1609     create_choices = menu.get("create")
1610     if create_choices:
1611         choices = eval(create_choices)
1612     else:
1613         choices = {}
1614     ignores = []
1615     options = {}
1616     creates = {}
1617     for facet in menu.facets():
1618         if facet.startswith("demand_") and not eval(
1619            universe.categories["menu"][user.state].get(facet)
1620            ):
1621             ignores.append(facet.split("_", 2)[1])
1622         elif facet.startswith("create_"):
1623             creates[facet] = facet.split("_", 2)[1]
1624         elif facet.startswith("choice_"):
1625             options[facet] = facet.split("_", 2)[1]
1626     for facet in creates.keys():
1627         if not creates[facet] in ignores:
1628             choices[creates[facet]] = eval(menu.get(facet))
1629     for facet in options.keys():
1630         if not options[facet] in ignores:
1631             choices[options[facet]] = menu.get(facet)
1632     return choices
1633
1634
1635 def get_formatted_menu_choices(state, choices):
1636     """Returns a formatted string of menu choices."""
1637     choice_output = ""
1638     choice_keys = list(choices.keys())
1639     choice_keys.sort()
1640     for choice in choice_keys:
1641         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1642             choice
1643         ] + "$(eol)"
1644     if choice_output:
1645         choice_output += "$(eol)"
1646     return choice_output
1647
1648
1649 def get_menu_branches(state):
1650     """Return a dict of choice:branch."""
1651     branches = {}
1652     for facet in universe.categories["menu"][state].facets():
1653         if facet.startswith("branch_"):
1654             branches[
1655                 facet.split("_", 2)[1]
1656             ] = universe.categories["menu"][state].get(facet)
1657     return branches
1658
1659
1660 def get_default_branch(state):
1661     """Return the default branch."""
1662     return universe.categories["menu"][state].get("branch")
1663
1664
1665 def get_choice_branch(user, choice):
1666     """Returns the new state matching the given choice."""
1667     branches = get_menu_branches(user.state)
1668     if choice in branches.keys():
1669         return branches[choice]
1670     elif choice in user.menu_choices.keys():
1671         return get_default_branch(user.state)
1672     else:
1673         return ""
1674
1675
1676 def get_menu_actions(state):
1677     """Return a dict of choice:branch."""
1678     actions = {}
1679     for facet in universe.categories["menu"][state].facets():
1680         if facet.startswith("action_"):
1681             actions[
1682                 facet.split("_", 2)[1]
1683             ] = universe.categories["menu"][state].get(facet)
1684     return actions
1685
1686
1687 def get_default_action(state):
1688     """Return the default action."""
1689     return universe.categories["menu"][state].get("action")
1690
1691
1692 def get_choice_action(user, choice):
1693     """Run any indicated script for the given choice."""
1694     actions = get_menu_actions(user.state)
1695     if choice in actions.keys():
1696         return actions[choice]
1697     elif choice in user.menu_choices.keys():
1698         return get_default_action(user.state)
1699     else:
1700         return ""
1701
1702
1703 def handle_user_input(user):
1704     """The main handler, branches to a state-specific handler."""
1705
1706     # if the user's client echo is off, send a blank line for aesthetics
1707     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1708                                mudpy.telnet.US):
1709         user.send("", add_prompt=False, prepend_padding=False)
1710
1711     # check to make sure the state is expected, then call that handler
1712     if "handler_" + user.state in globals():
1713         exec("handler_" + user.state + "(user)")
1714     else:
1715         generic_menu_handler(user)
1716
1717     # since we got input, flag that the menu/prompt needs to be redisplayed
1718     user.menu_seen = False
1719
1720     # update the last_input timestamp while we're at it
1721     user.last_input = universe.get_time()
1722
1723
1724 def generic_menu_handler(user):
1725     """A generic menu choice handler."""
1726
1727     # get a lower-case representation of the next line of input
1728     if user.input_queue:
1729         choice = user.input_queue.pop(0)
1730         if choice:
1731             choice = choice.lower()
1732     else:
1733         choice = ""
1734     if not choice:
1735         choice = get_default_menu_choice(user.state)
1736     if choice in user.menu_choices:
1737         exec(get_choice_action(user, choice))
1738         new_state = get_choice_branch(user, choice)
1739         if new_state:
1740             user.state = new_state
1741     else:
1742         user.error = "default"
1743
1744
1745 def handler_entering_account_name(user):
1746     """Handle the login account name."""
1747
1748     # get the next waiting line of input
1749     input_data = user.input_queue.pop(0)
1750
1751     # did the user enter anything?
1752     if input_data:
1753
1754         # keep only the first word and convert to lower-case
1755         name = input_data.lower()
1756
1757         # fail if there are non-alphanumeric characters
1758         if name != "".join(filter(
1759                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1760                 name)):
1761             user.error = "bad_name"
1762
1763         # if that account exists, time to request a password
1764         elif name in universe.categories["account"]:
1765             user.account = universe.categories["account"][name]
1766             user.state = "checking_password"
1767
1768         # otherwise, this could be a brand new user
1769         else:
1770             user.account = Element("account:" + name, universe)
1771             user.account.set("name", name)
1772             log("New user: " + name, 2)
1773             user.state = "checking_new_account_name"
1774
1775     # if the user entered nothing for a name, then buhbye
1776     else:
1777         user.state = "disconnecting"
1778
1779
1780 def handler_checking_password(user):
1781     """Handle the login account password."""
1782
1783     # get the next waiting line of input
1784     input_data = user.input_queue.pop(0)
1785
1786     # does the hashed input equal the stored hash?
1787     if mudpy.password.verify(input_data, user.account.get("passhash")):
1788
1789         # if so, set the username and load from cold storage
1790         if not user.replace_old_connections():
1791             user.authenticate()
1792             user.state = "main_utility"
1793
1794     # if at first your hashes don't match, try, try again
1795     elif user.password_tries < universe.categories[
1796         "internal"
1797     ][
1798         "limits"
1799     ].getint(
1800         "password_tries"
1801     ) - 1:
1802         user.password_tries += 1
1803         user.error = "incorrect"
1804
1805     # we've exceeded the maximum number of password failures, so disconnect
1806     else:
1807         user.send(
1808             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1809         )
1810         user.state = "disconnecting"
1811
1812
1813 def handler_entering_new_password(user):
1814     """Handle a new password entry."""
1815
1816     # get the next waiting line of input
1817     input_data = user.input_queue.pop(0)
1818
1819     # make sure the password is strong--at least one upper, one lower and
1820     # one digit, seven or more characters in length
1821     if len(input_data) > 6 and len(
1822        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1823        ) and len(
1824         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1825     ) and len(
1826         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1827     ):
1828
1829         # hash and store it, then move on to verification
1830         user.account.set("passhash", mudpy.password.create(input_data))
1831         user.state = "verifying_new_password"
1832
1833     # the password was weak, try again if you haven't tried too many times
1834     elif user.password_tries < universe.categories[
1835         "internal"
1836     ][
1837         "limits"
1838     ].getint(
1839         "password_tries"
1840     ) - 1:
1841         user.password_tries += 1
1842         user.error = "weak"
1843
1844     # too many tries, so adios
1845     else:
1846         user.send(
1847             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1848         )
1849         user.account.destroy()
1850         user.state = "disconnecting"
1851
1852
1853 def handler_verifying_new_password(user):
1854     """Handle the re-entered new password for verification."""
1855
1856     # get the next waiting line of input
1857     input_data = user.input_queue.pop(0)
1858
1859     # hash the input and match it to storage
1860     if mudpy.password.verify(input_data, user.account.get("passhash")):
1861         user.authenticate()
1862
1863         # the hashes matched, so go active
1864         if not user.replace_old_connections():
1865             user.state = "main_utility"
1866
1867     # go back to entering the new password as long as you haven't tried
1868     # too many times
1869     elif user.password_tries < universe.categories[
1870         "internal"
1871     ][
1872         "limits"
1873     ].getint(
1874         "password_tries"
1875     ) - 1:
1876         user.password_tries += 1
1877         user.error = "differs"
1878         user.state = "entering_new_password"
1879
1880     # otherwise, sayonara
1881     else:
1882         user.send(
1883             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1884         )
1885         user.account.destroy()
1886         user.state = "disconnecting"
1887
1888
1889 def handler_active(user):
1890     """Handle input for active users."""
1891
1892     # get the next waiting line of input
1893     input_data = user.input_queue.pop(0)
1894
1895     # is there input?
1896     if input_data:
1897
1898         # split out the command and parameters
1899         actor = user.avatar
1900         mode = actor.get("mode")
1901         if mode and input_data.startswith("!"):
1902             command_name, parameters = first_word(input_data[1:])
1903         elif mode == "chat":
1904             command_name = "say"
1905             parameters = input_data
1906         else:
1907             command_name, parameters = first_word(input_data)
1908
1909         # lowercase the command
1910         command_name = command_name.lower()
1911
1912         # the command matches a command word for which we have data
1913         if command_name in universe.categories["command"]:
1914             command = universe.categories["command"][command_name]
1915         else:
1916             command = None
1917
1918         # if it's allowed, do it
1919         if actor.can_run(command):
1920             exec(command.get("action"))
1921
1922         # otherwise, give an error
1923         elif command_name:
1924             command_error(actor, input_data)
1925
1926     # if no input, just idle back with a prompt
1927     else:
1928         user.send("", just_prompt=True)
1929
1930
1931 def command_halt(actor, parameters):
1932     """Halt the world."""
1933     if actor.owner:
1934
1935         # see if there's a message or use a generic one
1936         if parameters:
1937             message = "Halting: " + parameters
1938         else:
1939             message = "User " + actor.owner.account.get(
1940                 "name"
1941             ) + " halted the world."
1942
1943         # let everyone know
1944         broadcast(message, add_prompt=False)
1945         log(message, 8)
1946
1947         # set a flag to terminate the world
1948         universe.terminate_flag = True
1949
1950
1951 def command_reload(actor):
1952     """Reload all code modules, configs and data."""
1953     if actor.owner:
1954
1955         # let the user know and log
1956         actor.send("Reloading all code modules, configs and data.")
1957         log(
1958             "User " +
1959             actor.owner.account.get("name") + " reloaded the world.",
1960             8
1961         )
1962
1963         # set a flag to reload
1964         universe.reload_flag = True
1965
1966
1967 def command_quit(actor):
1968     """Leave the world and go back to the main menu."""
1969     if actor.owner:
1970         actor.owner.state = "main_utility"
1971         actor.owner.deactivate_avatar()
1972
1973
1974 def command_help(actor, parameters):
1975     """List available commands and provide help for commands."""
1976
1977     # did the user ask for help on a specific command word?
1978     if parameters and actor.owner:
1979
1980         # is the command word one for which we have data?
1981         if parameters in universe.categories["command"]:
1982             command = universe.categories["command"][parameters]
1983         else:
1984             command = None
1985
1986         # only for allowed commands
1987         if actor.can_run(command):
1988
1989             # add a description if provided
1990             description = command.get("description")
1991             if not description:
1992                 description = "(no short description provided)"
1993             if command.getboolean("administrative"):
1994                 output = "$(red)"
1995             else:
1996                 output = "$(grn)"
1997             output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1998
1999             # add the help text if provided
2000             help_text = command.get("help")
2001             if not help_text:
2002                 help_text = "No help is provided for this command."
2003             output += help_text
2004
2005             # list related commands
2006             see_also = command.getlist("see_also")
2007             if see_also:
2008                 really_see_also = ""
2009                 for item in see_also:
2010                     if item in universe.categories["command"]:
2011                         command = universe.categories["command"][item]
2012                         if actor.can_run(command):
2013                             if really_see_also:
2014                                 really_see_also += ", "
2015                             if command.getboolean("administrative"):
2016                                 really_see_also += "$(red)"
2017                             else:
2018                                 really_see_also += "$(grn)"
2019                             really_see_also += item + "$(nrm)"
2020                 if really_see_also:
2021                     output += "$(eol)$(eol)See also: " + really_see_also
2022
2023         # no data for the requested command word
2024         else:
2025             output = "That is not an available command."
2026
2027     # no specific command word was indicated
2028     else:
2029
2030         # give a sorted list of commands with descriptions if provided
2031         output = "These are the commands available to you:$(eol)$(eol)"
2032         sorted_commands = list(universe.categories["command"].keys())
2033         sorted_commands.sort()
2034         for item in sorted_commands:
2035             command = universe.categories["command"][item]
2036             if actor.can_run(command):
2037                 description = command.get("description")
2038                 if not description:
2039                     description = "(no short description provided)"
2040                 if command.getboolean("administrative"):
2041                     output += "   $(red)"
2042                 else:
2043                     output += "   $(grn)"
2044                 output += item + "$(nrm) - " + description + "$(eol)"
2045         output += ("$(eol)Enter \"help COMMAND\" for help on a command "
2046                    "named \"COMMAND\".")
2047
2048     # send the accumulated output to the user
2049     actor.send(output)
2050
2051
2052 def command_move(actor, parameters):
2053     """Move the avatar in a given direction."""
2054     if parameters in universe.contents[actor.get("location")].portals():
2055         actor.move_direction(parameters)
2056     else:
2057         actor.send("You cannot go that way.")
2058
2059
2060 def command_look(actor, parameters):
2061     """Look around."""
2062     if parameters:
2063         actor.send("You can't look at or in anything yet.")
2064     else:
2065         actor.look_at(actor.get("location"))
2066
2067
2068 def command_say(actor, parameters):
2069     """Speak to others in the same room."""
2070
2071     # check for replacement macros and escape them
2072     parameters = escape_macros(parameters)
2073
2074     # if the message is wrapped in quotes, remove them and leave contents
2075     # intact
2076     if parameters.startswith("\"") and parameters.endswith("\""):
2077         message = parameters[1:-1]
2078         literal = True
2079
2080     # otherwise, get rid of stray quote marks on the ends of the message
2081     else:
2082         message = parameters.strip("\"'`")
2083         literal = False
2084
2085     # the user entered a message
2086     if message:
2087
2088         # match the punctuation used, if any, to an action
2089         actions = universe.categories["internal"]["language"].getdict(
2090             "actions"
2091         )
2092         default_punctuation = (
2093             universe.categories["internal"]["language"].get(
2094                 "default_punctuation"))
2095         action = ""
2096         for mark in actions.keys():
2097             if not literal and message.endswith(mark):
2098                 action = actions[mark]
2099                 break
2100
2101         # add punctuation if needed
2102         if not action:
2103             action = actions[default_punctuation]
2104             if message and not (
2105                literal or unicodedata.category(message[-1]) == "Po"
2106                ):
2107                 message += default_punctuation
2108
2109         # failsafe checks to avoid unwanted reformatting and null strings
2110         if message and not literal:
2111
2112             # decapitalize the first letter to improve matching
2113             message = message[0].lower() + message[1:]
2114
2115             # iterate over all words in message, replacing typos
2116             typos = universe.categories["internal"]["language"].getdict(
2117                 "typos"
2118             )
2119             words = message.split()
2120             for index in range(len(words)):
2121                 word = words[index]
2122                 while unicodedata.category(word[0]) == "Po":
2123                     word = word[1:]
2124                 while unicodedata.category(word[-1]) == "Po":
2125                     word = word[:-1]
2126                 if word in typos.keys():
2127                     words[index] = words[index].replace(word, typos[word])
2128             message = " ".join(words)
2129
2130             # capitalize the first letter
2131             message = message[0].upper() + message[1:]
2132
2133     # tell the room
2134     if message:
2135         actor.echo_to_location(
2136             actor.get("name") + " " + action + "s, \"" + message + "\""
2137         )
2138         actor.send("You " + action + ", \"" + message + "\"")
2139
2140     # there was no message
2141     else:
2142         actor.send("What do you want to say?")
2143
2144
2145 def command_chat(actor):
2146     """Toggle chat mode."""
2147     mode = actor.get("mode")
2148     if not mode:
2149         actor.set("mode", "chat")
2150         actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2151     elif mode == "chat":
2152         actor.remove_facet("mode")
2153         actor.send("Exiting chat mode.")
2154     else:
2155         actor.send("Sorry, but you're already busy with something else!")
2156
2157
2158 def command_show(actor, parameters):
2159     """Show program data."""
2160     message = ""
2161     arguments = parameters.split()
2162     if not parameters:
2163         message = "What do you want to show?"
2164     elif arguments[0] == "time":
2165         message = universe.categories["internal"]["counters"].get(
2166             "elapsed"
2167         ) + " increments elapsed since the world was created."
2168     elif arguments[0] == "categories":
2169         message = "These are the element categories:$(eol)"
2170         categories = list(universe.categories.keys())
2171         categories.sort()
2172         for category in categories:
2173             message += "$(eol)   $(grn)" + category + "$(nrm)"
2174     elif arguments[0] == "files":
2175         message = "These are the current files containing the universe:$(eol)"
2176         filenames = list(universe.files.keys())
2177         filenames.sort()
2178         for filename in filenames:
2179             if universe.files[filename].is_writeable():
2180                 status = "rw"
2181             else:
2182                 status = "ro"
2183             message += ("$(eol)   $(red)(" + status + ") $(grn)" + filename
2184                         + "$(nrm)")
2185     elif arguments[0] == "category":
2186         if len(arguments) != 2:
2187             message = "You must specify one category."
2188         elif arguments[1] in universe.categories:
2189             message = ("These are the elements in the \"" + arguments[1]
2190                        + "\" category:$(eol)")
2191             elements = [
2192                 (
2193                     universe.categories[arguments[1]][x].key
2194                 ) for x in universe.categories[arguments[1]].keys()
2195             ]
2196             elements.sort()
2197             for element in elements:
2198                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2199         else:
2200             message = "Category \"" + arguments[1] + "\" does not exist."
2201     elif arguments[0] == "file":
2202         if len(arguments) != 2:
2203             message = "You must specify one file."
2204         elif arguments[1] in universe.files:
2205             message = ("These are the elements in the \"" + arguments[1]
2206                        + "\" file:$(eol)")
2207             elements = universe.files[arguments[1]].data.sections()
2208             elements.sort()
2209             for element in elements:
2210                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2211         else:
2212             message = "Category \"" + arguments[1] + "\" does not exist."
2213     elif arguments[0] == "element":
2214         if len(arguments) != 2:
2215             message = "You must specify one element."
2216         elif arguments[1] in universe.contents:
2217             element = universe.contents[arguments[1]]
2218             message = ("These are the properties of the \"" + arguments[1]
2219                        + "\" element (in \"" + element.origin.filename
2220                        + "\"):$(eol)")
2221             facets = element.facets()
2222             facets.sort()
2223             for facet in facets:
2224                 message += ("$(eol)   $(grn)" + facet + ": $(red)"
2225                             + escape_macros(element.get(facet)) + "$(nrm)")
2226         else:
2227             message = "Element \"" + arguments[1] + "\" does not exist."
2228     elif arguments[0] == "result":
2229         if len(arguments) < 2:
2230             message = "You need to specify an expression."
2231         else:
2232             try:
2233                 message = repr(eval(" ".join(arguments[1:])))
2234             except:
2235                 message = "Your expression raised an exception!"
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)