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