Fix reload to use a copy of datafile keys
[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 " + \
627                 self.state + " 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 \
792                    or not 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] + \
1227                 "\r\n" + text[last_whitespace + 1:]
1228
1229             # increase the absolute position because an eol is two
1230             # characters but the space it replaced was only one
1231             abs_pos += 1
1232
1233             # now we're at the begining of a new line, plus the
1234             # number of characters wrapped from the previous line
1235             rel_pos = 0
1236             for remaining_characters in text[last_whitespace:abs_pos]:
1237                 rel_pos += glyph_columns(remaining_characters)
1238
1239         # as long as the character is not a carriage return and the
1240         # other above conditions haven't been met, count it as a
1241         # printable character
1242         elif each_character != "\r":
1243             rel_pos += glyph_columns(each_character)
1244             if unicodedata.category(each_character) in ("Cc", "Zs"):
1245                 last_whitespace = abs_pos
1246
1247         # increase the absolute position for every character
1248         abs_pos += 1
1249
1250     # return the newly-wrapped text
1251     return text
1252
1253
1254 def weighted_choice(data):
1255     """Takes a dict weighted by value and returns a random key."""
1256
1257     # this will hold our expanded list of keys from the data
1258     expanded = []
1259
1260     # create the expanded list of keys
1261     for key in data.keys():
1262         for count in range(data[key]):
1263             expanded.append(key)
1264
1265     # return one at random
1266     return random.choice(expanded)
1267
1268
1269 def random_name():
1270     """Returns a random character name."""
1271
1272     # the vowels and consonants needed to create romaji syllables
1273     vowels = [
1274         "a",
1275         "i",
1276         "u",
1277         "e",
1278         "o"
1279     ]
1280     consonants = [
1281         "'",
1282         "k",
1283         "z",
1284         "s",
1285         "sh",
1286         "z",
1287         "j",
1288         "t",
1289         "ch",
1290         "ts",
1291         "d",
1292         "n",
1293         "h",
1294         "f",
1295         "m",
1296         "y",
1297         "r",
1298         "w"
1299     ]
1300
1301     # this dict will hold our weighted list of syllables
1302     syllables = {}
1303
1304     # generate the list with an even weighting
1305     for consonant in consonants:
1306         for vowel in vowels:
1307             syllables[consonant + vowel] = 1
1308
1309     # we'll build the name into this string
1310     name = ""
1311
1312     # create a name of random length from the syllables
1313     for syllable in range(random.randrange(2, 6)):
1314         name += weighted_choice(syllables)
1315
1316     # strip any leading quotemark, capitalize and return the name
1317     return name.strip("'").capitalize()
1318
1319
1320 def replace_macros(user, text, is_input=False):
1321     """Replaces macros in text output."""
1322
1323     # third person pronouns
1324     pronouns = {
1325         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1326         "male": {"obj": "him", "pos": "his", "sub": "he"},
1327         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1328     }
1329
1330     # a dict of replacement macros
1331     macros = {
1332         "eol": "\r\n",
1333         "bld": chr(27) + "[1m",
1334         "nrm": chr(27) + "[0m",
1335         "blk": chr(27) + "[30m",
1336         "blu": chr(27) + "[34m",
1337         "cyn": chr(27) + "[36m",
1338         "grn": chr(27) + "[32m",
1339         "mgt": chr(27) + "[35m",
1340         "red": chr(27) + "[31m",
1341         "yel": chr(27) + "[33m",
1342     }
1343
1344     # add dynamic macros where possible
1345     if user.account:
1346         account_name = user.account.get("name")
1347         if account_name:
1348             macros["account"] = account_name
1349     if user.avatar:
1350         avatar_gender = user.avatar.get("gender")
1351         if avatar_gender:
1352             macros["tpop"] = pronouns[avatar_gender]["obj"]
1353             macros["tppp"] = pronouns[avatar_gender]["pos"]
1354             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1355
1356     # loop until broken
1357     while True:
1358
1359         # find and replace per the macros dict
1360         macro_start = text.find("$(")
1361         if macro_start == -1:
1362             break
1363         macro_end = text.find(")", macro_start) + 1
1364         macro = text[macro_start + 2:macro_end - 1]
1365         if macro in macros.keys():
1366             replacement = macros[macro]
1367
1368         # this is how we handle local file inclusion (dangerous!)
1369         elif macro.startswith("inc:"):
1370             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1371             if os.path.exists(incfile):
1372                 incfd = codecs.open(incfile, "r", "utf-8")
1373                 replacement = ""
1374                 for line in incfd:
1375                     if line.endswith("\n") and not line.endswith("\r\n"):
1376                         line = line.replace("\n", "\r\n")
1377                     replacement += line
1378                 # lose the trailing eol
1379                 replacement = replacement[:-2]
1380             else:
1381                 replacement = ""
1382                 log("Couldn't read included " + incfile + " file.", 6)
1383
1384         # if we get here, log and replace it with null
1385         else:
1386             replacement = ""
1387             if not is_input:
1388                 log("Unexpected replacement macro " +
1389                     macro + " encountered.", 6)
1390
1391         # and now we act on the replacement
1392         text = text.replace("$(" + macro + ")", replacement)
1393
1394     # replace the look-like-a-macro sequence
1395     text = text.replace("$_(", "$(")
1396
1397     return text
1398
1399
1400 def escape_macros(text):
1401     """Escapes replacement macros in text."""
1402     return text.replace("$(", "$_(")
1403
1404
1405 def first_word(text, separator=" "):
1406     """Returns a tuple of the first word and the rest."""
1407     if text:
1408         if text.find(separator) > 0:
1409             return text.split(separator, 1)
1410         else:
1411             return text, ""
1412     else:
1413         return "", ""
1414
1415
1416 def on_pulse():
1417     """The things which should happen on each pulse, aside from reloads."""
1418
1419     # open the listening socket if it hasn't been already
1420     if not hasattr(universe, "listening_socket"):
1421         universe.initialize_server_socket()
1422
1423     # assign a user if a new connection is waiting
1424     user = check_for_connection(universe.listening_socket)
1425     if user:
1426         universe.userlist.append(user)
1427
1428     # iterate over the connected users
1429     for user in universe.userlist:
1430         user.pulse()
1431
1432     # add an element for counters if it doesn't exist
1433     if "counters" not in universe.categories["internal"]:
1434         universe.categories["internal"]["counters"] = Element(
1435             "internal:counters", universe
1436         )
1437
1438     # update the log every now and then
1439     if not universe.categories["internal"]["counters"].getint("mark"):
1440         log(str(len(universe.userlist)) + " connection(s)")
1441         universe.categories["internal"]["counters"].set(
1442             "mark", universe.categories["internal"]["time"].getint(
1443                 "frequency_log"
1444             )
1445         )
1446     else:
1447         universe.categories["internal"]["counters"].set(
1448             "mark", universe.categories["internal"]["counters"].getint(
1449                 "mark"
1450             ) - 1
1451         )
1452
1453     # periodically save everything
1454     if not universe.categories["internal"]["counters"].getint("save"):
1455         universe.save()
1456         universe.categories["internal"]["counters"].set(
1457             "save", universe.categories["internal"]["time"].getint(
1458                 "frequency_save"
1459             )
1460         )
1461     else:
1462         universe.categories["internal"]["counters"].set(
1463             "save", universe.categories["internal"]["counters"].getint(
1464                 "save"
1465             ) - 1
1466         )
1467
1468     # pause for a configurable amount of time (decimal seconds)
1469     time.sleep(universe.categories["internal"]
1470                ["time"].getfloat("increment"))
1471
1472     # increase the elapsed increment counter
1473     universe.categories["internal"]["counters"].set(
1474         "elapsed", universe.categories["internal"]["counters"].getint(
1475             "elapsed"
1476         ) + 1
1477     )
1478
1479
1480 def reload_data():
1481     """Reload all relevant objects."""
1482     for user in universe.userlist[:]:
1483         user.reload()
1484     for element in universe.contents.values():
1485         if element.origin.is_writeable():
1486             element.reload()
1487     universe.load()
1488
1489
1490 def check_for_connection(listening_socket):
1491     """Check for a waiting connection and return a new user object."""
1492
1493     # try to accept a new connection
1494     try:
1495         connection, address = listening_socket.accept()
1496     except:
1497         return None
1498
1499     # note that we got one
1500     log("Connection from " + address[0], 2)
1501
1502     # disable blocking so we can proceed whether or not we can send/receive
1503     connection.setblocking(0)
1504
1505     # create a new user object
1506     user = User()
1507
1508     # associate this connection with it
1509     user.connection = connection
1510
1511     # set the user's ipa from the connection's ipa
1512     user.address = address[0]
1513
1514     # let the client know we WILL EOR (RFC 885)
1515     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1516     user.negotiation_pause = 2
1517
1518     # return the new user object
1519     return user
1520
1521
1522 def get_menu(state, error=None, choices=None):
1523     """Show the correct menu text to a user."""
1524
1525     # make sure we don't reuse a mutable sequence by default
1526     if choices is None:
1527         choices = {}
1528
1529     # get the description or error text
1530     message = get_menu_description(state, error)
1531
1532     # get menu choices for the current state
1533     message += get_formatted_menu_choices(state, choices)
1534
1535     # try to get a prompt, if it was defined
1536     message += get_menu_prompt(state)
1537
1538     # throw in the default choice, if it exists
1539     message += get_formatted_default_menu_choice(state)
1540
1541     # display a message indicating if echo is off
1542     message += get_echo_message(state)
1543
1544     # return the assembly of various strings defined above
1545     return message
1546
1547
1548 def menu_echo_on(state):
1549     """True if echo is on, false if it is off."""
1550     return universe.categories["menu"][state].getboolean("echo", True)
1551
1552
1553 def get_echo_message(state):
1554     """Return a message indicating that echo is off."""
1555     if menu_echo_on(state):
1556         return ""
1557     else:
1558         return "(won't echo) "
1559
1560
1561 def get_default_menu_choice(state):
1562     """Return the default choice for a menu."""
1563     return universe.categories["menu"][state].get("default")
1564
1565
1566 def get_formatted_default_menu_choice(state):
1567     """Default menu choice foratted for inclusion in a prompt string."""
1568     default_choice = get_default_menu_choice(state)
1569     if default_choice:
1570         return "[$(red)" + default_choice + "$(nrm)] "
1571     else:
1572         return ""
1573
1574
1575 def get_menu_description(state, error):
1576     """Get the description or error text."""
1577
1578     # an error condition was raised by the handler
1579     if error:
1580
1581         # try to get an error message matching the condition
1582         # and current state
1583         description = universe.categories[
1584             "menu"][state].get("error_" + error)
1585         if not description:
1586             description = "That is not a valid choice..."
1587         description = "$(red)" + description + "$(nrm)"
1588
1589     # there was no error condition
1590     else:
1591
1592         # try to get a menu description for the current state
1593         description = universe.categories["menu"][state].get("description")
1594
1595     # return the description or error message
1596     if description:
1597         description += "$(eol)$(eol)"
1598     return description
1599
1600
1601 def get_menu_prompt(state):
1602     """Try to get a prompt, if it was defined."""
1603     prompt = universe.categories["menu"][state].get("prompt")
1604     if prompt:
1605         prompt += " "
1606     return prompt
1607
1608
1609 def get_menu_choices(user):
1610     """Return a dict of choice:meaning."""
1611     menu = universe.categories["menu"][user.state]
1612     create_choices = menu.get("create")
1613     if create_choices:
1614         choices = eval(create_choices)
1615     else:
1616         choices = {}
1617     ignores = []
1618     options = {}
1619     creates = {}
1620     for facet in menu.facets():
1621         if facet.startswith("demand_") and not eval(
1622            universe.categories["menu"][user.state].get(facet)
1623            ):
1624             ignores.append(facet.split("_", 2)[1])
1625         elif facet.startswith("create_"):
1626             creates[facet] = facet.split("_", 2)[1]
1627         elif facet.startswith("choice_"):
1628             options[facet] = facet.split("_", 2)[1]
1629     for facet in creates.keys():
1630         if not creates[facet] in ignores:
1631             choices[creates[facet]] = eval(menu.get(facet))
1632     for facet in options.keys():
1633         if not options[facet] in ignores:
1634             choices[options[facet]] = menu.get(facet)
1635     return choices
1636
1637
1638 def get_formatted_menu_choices(state, choices):
1639     """Returns a formatted string of menu choices."""
1640     choice_output = ""
1641     choice_keys = list(choices.keys())
1642     choice_keys.sort()
1643     for choice in choice_keys:
1644         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1645             choice
1646         ] + "$(eol)"
1647     if choice_output:
1648         choice_output += "$(eol)"
1649     return choice_output
1650
1651
1652 def get_menu_branches(state):
1653     """Return a dict of choice:branch."""
1654     branches = {}
1655     for facet in universe.categories["menu"][state].facets():
1656         if facet.startswith("branch_"):
1657             branches[
1658                 facet.split("_", 2)[1]
1659             ] = universe.categories["menu"][state].get(facet)
1660     return branches
1661
1662
1663 def get_default_branch(state):
1664     """Return the default branch."""
1665     return universe.categories["menu"][state].get("branch")
1666
1667
1668 def get_choice_branch(user, choice):
1669     """Returns the new state matching the given choice."""
1670     branches = get_menu_branches(user.state)
1671     if choice in branches.keys():
1672         return branches[choice]
1673     elif choice in user.menu_choices.keys():
1674         return get_default_branch(user.state)
1675     else:
1676         return ""
1677
1678
1679 def get_menu_actions(state):
1680     """Return a dict of choice:branch."""
1681     actions = {}
1682     for facet in universe.categories["menu"][state].facets():
1683         if facet.startswith("action_"):
1684             actions[
1685                 facet.split("_", 2)[1]
1686             ] = universe.categories["menu"][state].get(facet)
1687     return actions
1688
1689
1690 def get_default_action(state):
1691     """Return the default action."""
1692     return universe.categories["menu"][state].get("action")
1693
1694
1695 def get_choice_action(user, choice):
1696     """Run any indicated script for the given choice."""
1697     actions = get_menu_actions(user.state)
1698     if choice in actions.keys():
1699         return actions[choice]
1700     elif choice in user.menu_choices.keys():
1701         return get_default_action(user.state)
1702     else:
1703         return ""
1704
1705
1706 def handle_user_input(user):
1707     """The main handler, branches to a state-specific handler."""
1708
1709     # if the user's client echo is off, send a blank line for aesthetics
1710     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1711                                mudpy.telnet.US):
1712         user.send("", add_prompt=False, prepend_padding=False)
1713
1714     # check to make sure the state is expected, then call that handler
1715     if "handler_" + user.state in globals():
1716         exec("handler_" + user.state + "(user)")
1717     else:
1718         generic_menu_handler(user)
1719
1720     # since we got input, flag that the menu/prompt needs to be redisplayed
1721     user.menu_seen = False
1722
1723     # update the last_input timestamp while we're at it
1724     user.last_input = universe.get_time()
1725
1726
1727 def generic_menu_handler(user):
1728     """A generic menu choice handler."""
1729
1730     # get a lower-case representation of the next line of input
1731     if user.input_queue:
1732         choice = user.input_queue.pop(0)
1733         if choice:
1734             choice = choice.lower()
1735     else:
1736         choice = ""
1737     if not choice:
1738         choice = get_default_menu_choice(user.state)
1739     if choice in user.menu_choices:
1740         exec(get_choice_action(user, choice))
1741         new_state = get_choice_branch(user, choice)
1742         if new_state:
1743             user.state = new_state
1744     else:
1745         user.error = "default"
1746
1747
1748 def handler_entering_account_name(user):
1749     """Handle the login account name."""
1750
1751     # get the next waiting line of input
1752     input_data = user.input_queue.pop(0)
1753
1754     # did the user enter anything?
1755     if input_data:
1756
1757         # keep only the first word and convert to lower-case
1758         name = input_data.lower()
1759
1760         # fail if there are non-alphanumeric characters
1761         if name != "".join(filter(
1762                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1763                 name)):
1764             user.error = "bad_name"
1765
1766         # if that account exists, time to request a password
1767         elif name in universe.categories["account"]:
1768             user.account = universe.categories["account"][name]
1769             user.state = "checking_password"
1770
1771         # otherwise, this could be a brand new user
1772         else:
1773             user.account = Element("account:" + name, universe)
1774             user.account.set("name", name)
1775             log("New user: " + name, 2)
1776             user.state = "checking_new_account_name"
1777
1778     # if the user entered nothing for a name, then buhbye
1779     else:
1780         user.state = "disconnecting"
1781
1782
1783 def handler_checking_password(user):
1784     """Handle the login account password."""
1785
1786     # get the next waiting line of input
1787     input_data = user.input_queue.pop(0)
1788
1789     # does the hashed input equal the stored hash?
1790     if mudpy.password.verify(input_data, user.account.get("passhash")):
1791
1792         # if so, set the username and load from cold storage
1793         if not user.replace_old_connections():
1794             user.authenticate()
1795             user.state = "main_utility"
1796
1797     # if at first your hashes don't match, try, try again
1798     elif user.password_tries < universe.categories[
1799         "internal"
1800     ][
1801         "limits"
1802     ].getint(
1803         "password_tries"
1804     ) - 1:
1805         user.password_tries += 1
1806         user.error = "incorrect"
1807
1808     # we've exceeded the maximum number of password failures, so disconnect
1809     else:
1810         user.send(
1811             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1812         )
1813         user.state = "disconnecting"
1814
1815
1816 def handler_entering_new_password(user):
1817     """Handle a new password entry."""
1818
1819     # get the next waiting line of input
1820     input_data = user.input_queue.pop(0)
1821
1822     # make sure the password is strong--at least one upper, one lower and
1823     # one digit, seven or more characters in length
1824     if len(input_data) > 6 and len(
1825        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1826        ) and len(
1827         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1828     ) and len(
1829         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1830     ):
1831
1832         # hash and store it, then move on to verification
1833         user.account.set("passhash", mudpy.password.create(input_data))
1834         user.state = "verifying_new_password"
1835
1836     # the password was weak, try again if you haven't tried too many times
1837     elif user.password_tries < universe.categories[
1838         "internal"
1839     ][
1840         "limits"
1841     ].getint(
1842         "password_tries"
1843     ) - 1:
1844         user.password_tries += 1
1845         user.error = "weak"
1846
1847     # too many tries, so adios
1848     else:
1849         user.send(
1850             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1851         )
1852         user.account.destroy()
1853         user.state = "disconnecting"
1854
1855
1856 def handler_verifying_new_password(user):
1857     """Handle the re-entered new password for verification."""
1858
1859     # get the next waiting line of input
1860     input_data = user.input_queue.pop(0)
1861
1862     # hash the input and match it to storage
1863     if mudpy.password.verify(input_data, user.account.get("passhash")):
1864         user.authenticate()
1865
1866         # the hashes matched, so go active
1867         if not user.replace_old_connections():
1868             user.state = "main_utility"
1869
1870     # go back to entering the new password as long as you haven't tried
1871     # too many times
1872     elif user.password_tries < universe.categories[
1873         "internal"
1874     ][
1875         "limits"
1876     ].getint(
1877         "password_tries"
1878     ) - 1:
1879         user.password_tries += 1
1880         user.error = "differs"
1881         user.state = "entering_new_password"
1882
1883     # otherwise, sayonara
1884     else:
1885         user.send(
1886             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1887         )
1888         user.account.destroy()
1889         user.state = "disconnecting"
1890
1891
1892 def handler_active(user):
1893     """Handle input for active users."""
1894
1895     # get the next waiting line of input
1896     input_data = user.input_queue.pop(0)
1897
1898     # is there input?
1899     if input_data:
1900
1901         # split out the command and parameters
1902         actor = user.avatar
1903         mode = actor.get("mode")
1904         if mode and input_data.startswith("!"):
1905             command_name, parameters = first_word(input_data[1:])
1906         elif mode == "chat":
1907             command_name = "say"
1908             parameters = input_data
1909         else:
1910             command_name, parameters = first_word(input_data)
1911
1912         # lowercase the command
1913         command_name = command_name.lower()
1914
1915         # the command matches a command word for which we have data
1916         if command_name in universe.categories["command"]:
1917             command = universe.categories["command"][command_name]
1918         else:
1919             command = None
1920
1921         # if it's allowed, do it
1922         if actor.can_run(command):
1923             exec(command.get("action"))
1924
1925         # otherwise, give an error
1926         elif command_name:
1927             command_error(actor, input_data)
1928
1929     # if no input, just idle back with a prompt
1930     else:
1931         user.send("", just_prompt=True)
1932
1933
1934 def command_halt(actor, parameters):
1935     """Halt the world."""
1936     if actor.owner:
1937
1938         # see if there's a message or use a generic one
1939         if parameters:
1940             message = "Halting: " + parameters
1941         else:
1942             message = "User " + actor.owner.account.get(
1943                 "name"
1944             ) + " halted the world."
1945
1946         # let everyone know
1947         broadcast(message, add_prompt=False)
1948         log(message, 8)
1949
1950         # set a flag to terminate the world
1951         universe.terminate_flag = True
1952
1953
1954 def command_reload(actor):
1955     """Reload all code modules, configs and data."""
1956     if actor.owner:
1957
1958         # let the user know and log
1959         actor.send("Reloading all code modules, configs and data.")
1960         log(
1961             "User " +
1962             actor.owner.account.get("name") + " reloaded the world.",
1963             8
1964         )
1965
1966         # set a flag to reload
1967         universe.reload_flag = True
1968
1969
1970 def command_quit(actor):
1971     """Leave the world and go back to the main menu."""
1972     if actor.owner:
1973         actor.owner.state = "main_utility"
1974         actor.owner.deactivate_avatar()
1975
1976
1977 def command_help(actor, parameters):
1978     """List available commands and provide help for commands."""
1979
1980     # did the user ask for help on a specific command word?
1981     if parameters and actor.owner:
1982
1983         # is the command word one for which we have data?
1984         if parameters in universe.categories["command"]:
1985             command = universe.categories["command"][parameters]
1986         else:
1987             command = None
1988
1989         # only for allowed commands
1990         if actor.can_run(command):
1991
1992             # add a description if provided
1993             description = command.get("description")
1994             if not description:
1995                 description = "(no short description provided)"
1996             if command.getboolean("administrative"):
1997                 output = "$(red)"
1998             else:
1999                 output = "$(grn)"
2000             output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
2001
2002             # add the help text if provided
2003             help_text = command.get("help")
2004             if not help_text:
2005                 help_text = "No help is provided for this command."
2006             output += help_text
2007
2008             # list related commands
2009             see_also = command.getlist("see_also")
2010             if see_also:
2011                 really_see_also = ""
2012                 for item in see_also:
2013                     if item in universe.categories["command"]:
2014                         command = universe.categories["command"][item]
2015                         if actor.can_run(command):
2016                             if really_see_also:
2017                                 really_see_also += ", "
2018                             if command.getboolean("administrative"):
2019                                 really_see_also += "$(red)"
2020                             else:
2021                                 really_see_also += "$(grn)"
2022                             really_see_also += item + "$(nrm)"
2023                 if really_see_also:
2024                     output += "$(eol)$(eol)See also: " + really_see_also
2025
2026         # no data for the requested command word
2027         else:
2028             output = "That is not an available command."
2029
2030     # no specific command word was indicated
2031     else:
2032
2033         # give a sorted list of commands with descriptions if provided
2034         output = "These are the commands available to you:$(eol)$(eol)"
2035         sorted_commands = list(universe.categories["command"].keys())
2036         sorted_commands.sort()
2037         for item in sorted_commands:
2038             command = universe.categories["command"][item]
2039             if actor.can_run(command):
2040                 description = command.get("description")
2041                 if not description:
2042                     description = "(no short description provided)"
2043                 if command.getboolean("administrative"):
2044                     output += "   $(red)"
2045                 else:
2046                     output += "   $(grn)"
2047                 output += item + "$(nrm) - " + description + "$(eol)"
2048         output += "$(eol)Enter \"help COMMAND\" for help on a command " \
2049             + "named \"COMMAND\"."
2050
2051     # send the accumulated output to the user
2052     actor.send(output)
2053
2054
2055 def command_move(actor, parameters):
2056     """Move the avatar in a given direction."""
2057     if parameters in universe.contents[actor.get("location")].portals():
2058         actor.move_direction(parameters)
2059     else:
2060         actor.send("You cannot go that way.")
2061
2062
2063 def command_look(actor, parameters):
2064     """Look around."""
2065     if parameters:
2066         actor.send("You can't look at or in anything yet.")
2067     else:
2068         actor.look_at(actor.get("location"))
2069
2070
2071 def command_say(actor, parameters):
2072     """Speak to others in the same room."""
2073
2074     # check for replacement macros and escape them
2075     parameters = escape_macros(parameters)
2076
2077     # if the message is wrapped in quotes, remove them and leave contents
2078     # intact
2079     if parameters.startswith("\"") and parameters.endswith("\""):
2080         message = parameters[1:-1]
2081         literal = True
2082
2083     # otherwise, get rid of stray quote marks on the ends of the message
2084     else:
2085         message = parameters.strip("\"'`")
2086         literal = False
2087
2088     # the user entered a message
2089     if message:
2090
2091         # match the punctuation used, if any, to an action
2092         actions = universe.categories["internal"]["language"].getdict(
2093             "actions"
2094         )
2095         default_punctuation = (
2096             universe.categories["internal"]["language"].get(
2097                 "default_punctuation"))
2098         action = ""
2099         for mark in actions.keys():
2100             if not literal and message.endswith(mark):
2101                 action = actions[mark]
2102                 break
2103
2104         # add punctuation if needed
2105         if not action:
2106             action = actions[default_punctuation]
2107             if message and not (
2108                literal or unicodedata.category(message[-1]) == "Po"
2109                ):
2110                 message += default_punctuation
2111
2112         # failsafe checks to avoid unwanted reformatting and null strings
2113         if message and not literal:
2114
2115             # decapitalize the first letter to improve matching
2116             message = message[0].lower() + message[1:]
2117
2118             # iterate over all words in message, replacing typos
2119             typos = universe.categories["internal"]["language"].getdict(
2120                 "typos"
2121             )
2122             words = message.split()
2123             for index in range(len(words)):
2124                 word = words[index]
2125                 while unicodedata.category(word[0]) == "Po":
2126                     word = word[1:]
2127                 while unicodedata.category(word[-1]) == "Po":
2128                     word = word[:-1]
2129                 if word in typos.keys():
2130                     words[index] = words[index].replace(word, typos[word])
2131             message = " ".join(words)
2132
2133             # capitalize the first letter
2134             message = message[0].upper() + message[1:]
2135
2136     # tell the room
2137     if message:
2138         actor.echo_to_location(
2139             actor.get("name") + " " + action + "s, \"" + message + "\""
2140         )
2141         actor.send("You " + action + ", \"" + message + "\"")
2142
2143     # there was no message
2144     else:
2145         actor.send("What do you want to say?")
2146
2147
2148 def command_chat(actor):
2149     """Toggle chat mode."""
2150     mode = actor.get("mode")
2151     if not mode:
2152         actor.set("mode", "chat")
2153         actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2154     elif mode == "chat":
2155         actor.remove_facet("mode")
2156         actor.send("Exiting chat mode.")
2157     else:
2158         actor.send("Sorry, but you're already busy with something else!")
2159
2160
2161 def command_show(actor, parameters):
2162     """Show program data."""
2163     message = ""
2164     arguments = parameters.split()
2165     if not parameters:
2166         message = "What do you want to show?"
2167     elif arguments[0] == "time":
2168         message = universe.categories["internal"]["counters"].get(
2169             "elapsed"
2170         ) + " increments elapsed since the world was created."
2171     elif arguments[0] == "categories":
2172         message = "These are the element categories:$(eol)"
2173         categories = list(universe.categories.keys())
2174         categories.sort()
2175         for category in categories:
2176             message += "$(eol)   $(grn)" + category + "$(nrm)"
2177     elif arguments[0] == "files":
2178         message = "These are the current files containing the universe:$(eol)"
2179         filenames = list(universe.files.keys())
2180         filenames.sort()
2181         for filename in filenames:
2182             if universe.files[filename].is_writeable():
2183                 status = "rw"
2184             else:
2185                 status = "ro"
2186             message += "$(eol)   $(red)(" + status + ") $(grn)" + filename \
2187                 + "$(nrm)"
2188     elif arguments[0] == "category":
2189         if len(arguments) != 2:
2190             message = "You must specify one category."
2191         elif arguments[1] in universe.categories:
2192             message = "These are the elements in the \"" + arguments[1] \
2193                 + "\" category:$(eol)"
2194             elements = [
2195                 (
2196                     universe.categories[arguments[1]][x].key
2197                 ) for x in universe.categories[arguments[1]].keys()
2198             ]
2199             elements.sort()
2200             for element in elements:
2201                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2202         else:
2203             message = "Category \"" + arguments[1] + "\" does not exist."
2204     elif arguments[0] == "file":
2205         if len(arguments) != 2:
2206             message = "You must specify one file."
2207         elif arguments[1] in universe.files:
2208             message = "These are the elements in the \"" + arguments[1] \
2209                 + "\" file:$(eol)"
2210             elements = universe.files[arguments[1]].data.sections()
2211             elements.sort()
2212             for element in elements:
2213                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2214         else:
2215             message = "Category \"" + arguments[1] + "\" does not exist."
2216     elif arguments[0] == "element":
2217         if len(arguments) != 2:
2218             message = "You must specify one element."
2219         elif arguments[1] in universe.contents:
2220             element = universe.contents[arguments[1]]
2221             message = "These are the properties of the \"" + arguments[1] \
2222                 + \
2223                 "\" element (in \"" + \
2224                 element.origin.filename + "\"):$(eol)"
2225             facets = element.facets()
2226             facets.sort()
2227             for facet in facets:
2228                 message += "$(eol)   $(grn)" + facet + ": $(red)" \
2229                     + escape_macros(element.get(facet)) + "$(nrm)"
2230         else:
2231             message = "Element \"" + arguments[1] + "\" does not exist."
2232     elif arguments[0] == "result":
2233         if len(arguments) < 2:
2234             message = "You need to specify an expression."
2235         else:
2236             try:
2237                 message = repr(eval(" ".join(arguments[1:])))
2238             except:
2239                 message = "Your expression raised an exception!"
2240     elif arguments[0] == "log":
2241         if len(arguments) == 4:
2242             if re.match("^\d+$", arguments[3]) and int(arguments[3]) >= 0:
2243                 stop = int(arguments[3])
2244             else:
2245                 stop = -1
2246         else:
2247             stop = 0
2248         if len(arguments) >= 3:
2249             if re.match("^\d+$", arguments[2]) and int(arguments[2]) > 0:
2250                 start = int(arguments[2])
2251             else:
2252                 start = -1
2253         else:
2254             start = 10
2255         if len(arguments) >= 2:
2256             if (re.match("^\d+$", arguments[1])
2257                     and 0 <= int(arguments[1]) <= 9):
2258                 level = int(arguments[1])
2259             else:
2260                 level = -1
2261         elif 0 <= actor.owner.account.getint("loglevel") <= 9:
2262             level = actor.owner.account.getint("loglevel")
2263         else:
2264             level = 1
2265         if level > -1 and start > -1 and stop > -1:
2266             message = get_loglines(level, start, stop)
2267         else:
2268             message = "When specified, level must be 0-9 (default 1), " \
2269                 + "start and stop must be >=1 (default 10 and 1)."
2270     else:
2271         message = "I don't know what \"" + parameters + "\" is."
2272     actor.send(message)
2273
2274
2275 def command_create(actor, parameters):
2276     """Create an element if it does not exist."""
2277     if not parameters:
2278         message = "You must at least specify an element to create."
2279     elif not actor.owner:
2280         message = ""
2281     else:
2282         arguments = parameters.split()
2283         if len(arguments) == 1:
2284             arguments.append("")
2285         if len(arguments) == 2:
2286             element, filename = arguments
2287             if element in universe.contents:
2288                 message = "The \"" + element + "\" element already exists."
2289             else:
2290                 message = "You create \"" + \
2291                     element + "\" within the universe."
2292                 logline = actor.owner.account.get(
2293                     "name"
2294                 ) + " created an element: " + element
2295                 if filename:
2296                     logline += " in file " + filename
2297                     if filename not in universe.files:
2298                         message += " Warning: \"" + filename \
2299                             + "\" is not yet included in any other file and will " \
2300                             + \
2301                             "not be read on startup unless this is remedied."
2302                 Element(element, universe, filename)
2303                 log(logline, 6)
2304         elif len(arguments) > 2:
2305             message = "You can only specify an element and a filename."
2306     actor.send(message)
2307
2308
2309 def command_destroy(actor, parameters):
2310     """Destroy an element if it exists."""
2311     if actor.owner:
2312         if not parameters:
2313             message = "You must specify an element to destroy."
2314         else:
2315             if parameters not in universe.contents:
2316                 message = "The \"" + parameters + \
2317                     "\" element does not exist."
2318             else:
2319                 universe.contents[parameters].destroy()
2320                 message = "You destroy \"" + parameters \
2321                     + "\" within the universe."
2322                 log(
2323                     actor.owner.account.get(
2324                         "name"
2325                     ) + " destroyed an element: " + parameters,
2326                     6
2327                 )
2328         actor.send(message)
2329
2330
2331 def command_set(actor, parameters):
2332     """Set a facet of an element."""
2333     if not parameters:
2334         message = "You must specify an element, a facet and a value."
2335     else:
2336         arguments = parameters.split(" ", 2)
2337         if len(arguments) == 1:
2338             message = "What facet of element \"" + arguments[0] \
2339                 + "\" would you like to set?"
2340         elif len(arguments) == 2:
2341             message = "What value would you like to set for the \"" \
2342                 + arguments[1] + "\" facet of the \"" + arguments[0] \
2343                 + "\" element?"
2344         else:
2345             element, facet, value = arguments
2346             if element not in universe.contents:
2347                 message = "The \"" + element + "\" element does not exist."
2348             else:
2349                 universe.contents[element].set(facet, value)
2350                 message = "You have successfully (re)set the \"" + facet \
2351                     + "\" facet of element \"" + element \
2352                     + "\". Try \"show element " + \
2353                     element + "\" for verification."
2354     actor.send(message)
2355
2356
2357 def command_delete(actor, parameters):
2358     """Delete a facet from an element."""
2359     if not parameters:
2360         message = "You must specify an element and a facet."
2361     else:
2362         arguments = parameters.split(" ")
2363         if len(arguments) == 1:
2364             message = "What facet of element \"" + arguments[0] \
2365                 + "\" would you like to delete?"
2366         elif len(arguments) != 2:
2367             message = "You may only specify an element and a facet."
2368         else:
2369             element, facet = arguments
2370             if element not in universe.contents:
2371                 message = "The \"" + element + "\" element does not exist."
2372             elif facet not in universe.contents[element].facets():
2373                 message = "The \"" + element + "\" element has no \"" + facet \
2374                     + "\" facet."
2375             else:
2376                 universe.contents[element].remove_facet(facet)
2377                 message = "You have successfully deleted the \"" + facet \
2378                     + "\" facet of element \"" + element \
2379                     + "\". Try \"show element " + \
2380                     element + "\" for verification."
2381     actor.send(message)
2382
2383
2384 def command_error(actor, input_data):
2385     """Generic error for an unrecognized command word."""
2386
2387     # 90% of the time use a generic error
2388     if random.randrange(10):
2389         message = "I'm not sure what \"" + input_data + "\" means..."
2390
2391     # 10% of the time use the classic diku error
2392     else:
2393         message = "Arglebargle, glop-glyf!?!"
2394
2395     # send the error message
2396     actor.send(message)
2397
2398
2399 def daemonize(universe):
2400     """Fork and disassociate from everything."""
2401
2402     # only if this is what we're configured to do
2403     if universe.contents["internal:process"].getboolean("daemon"):
2404
2405         # if possible, we want to rename the process to the same as the script
2406         new_argv = b"\x00".join(x.encode("utf-8") for x in sys.argv) + b"\x00"
2407         short_argv0 = os.path.basename(sys.argv[0]).encode("utf-8") + b"\x00"
2408
2409         # attempt the linux way first
2410         try:
2411             argv_array = ctypes.POINTER(ctypes.c_char_p)
2412             ctypes.pythonapi.Py_GetArgcArgv.argtypes = (
2413                 ctypes.POINTER(ctypes.c_int),
2414                 ctypes.POINTER(argv_array)
2415             )
2416             argc = argv_array()
2417             ctypes.pythonapi.Py_GetArgcArgv(
2418                 ctypes.c_int(0),
2419                 ctypes.pointer(argc)
2420             )
2421             old_argv0_size = len(argc.contents.value)
2422             ctypes.memset(argc.contents, 0, len(new_argv) + old_argv0_size)
2423             ctypes.memmove(argc.contents, new_argv, len(new_argv))
2424             ctypes.CDLL(ctypes.util.find_library("c")).prctl(
2425                 15,
2426                 short_argv0,
2427                 0,
2428                 0,
2429                 0
2430             )
2431
2432         except:
2433
2434             # since that failed, maybe it's bsd?
2435             try:
2436
2437                 # much simpler, since bsd has a libc function call for this
2438                 ctypes.CDLL(ctypes.util.find_library("c")).setproctitle(
2439                     new_argv
2440                 )
2441
2442             except:
2443
2444                 # that didn't work either, so just log that we couldn't
2445                 log("Failed to rename the interpreter process (cosmetic).")
2446
2447         # log before we start forking around, so the terminal gets the message
2448         log("Disassociating from the controlling terminal.")
2449
2450         # fork off and die, so we free up the controlling terminal
2451         if os.fork():
2452             os._exit(0)
2453
2454         # switch to a new process group
2455         os.setsid()
2456
2457         # fork some more, this time to free us from the old process group
2458         if os.fork():
2459             os._exit(0)
2460
2461         # reset the working directory so we don't needlessly tie up mounts
2462         os.chdir("/")
2463
2464         # clear the file creation mask so we can bend it to our will later
2465         os.umask(0)
2466
2467         # redirect stdin/stdout/stderr and close off their former descriptors
2468         for stdpipe in range(3):
2469             os.close(stdpipe)
2470         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2471         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2472         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2473         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2474         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2475         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2476
2477
2478 def create_pidfile(universe):
2479     """Write a file containing the current process ID."""
2480     pid = str(os.getpid())
2481     log("Process ID: " + pid)
2482     file_name = universe.contents["internal:process"].get("pidfile")
2483     if file_name:
2484         if not os.path.isabs(file_name):
2485             file_name = os.path.join(universe.startdir, file_name)
2486         file_descriptor = codecs.open(file_name, "w", "utf-8")
2487         file_descriptor.write(pid + "\n")
2488         file_descriptor.flush()
2489         file_descriptor.close()
2490
2491
2492 def remove_pidfile(universe):
2493     """Remove the file containing the current process ID."""
2494     file_name = universe.contents["internal:process"].get("pidfile")
2495     if file_name:
2496         if not os.path.isabs(file_name):
2497             file_name = os.path.join(universe.startdir, file_name)
2498         if os.access(file_name, os.W_OK):
2499             os.remove(file_name)
2500
2501
2502 def excepthook(excepttype, value, tracebackdata):
2503     """Handle uncaught exceptions."""
2504
2505     # assemble the list of errors into a single string
2506     message = "".join(
2507         traceback.format_exception(excepttype, value, tracebackdata)
2508     )
2509
2510     # try to log it, if possible
2511     try:
2512         log(message, 9)
2513     except:
2514         pass
2515
2516     # try to write it to stderr, if possible
2517     try:
2518         sys.stderr.write(message)
2519     except:
2520         pass
2521
2522
2523 def sighook(what, where):
2524     """Handle external signals."""
2525
2526     # a generic message
2527     message = "Caught signal: "
2528
2529     # for a hangup signal
2530     if what == signal.SIGHUP:
2531         message += "hangup (reloading)"
2532         universe.reload_flag = True
2533
2534     # for a terminate signal
2535     elif what == signal.SIGTERM:
2536         message += "terminate (halting)"
2537         universe.terminate_flag = True
2538
2539     # catchall for unexpected signals
2540     else:
2541         message += str(what) + " (unhandled)"
2542
2543     # log what happened
2544     log(message, 8)
2545
2546
2547 def override_excepthook():
2548     """Redefine sys.excepthook with our own."""
2549     sys.excepthook = excepthook
2550
2551
2552 def assign_sighook():
2553     """Assign a customized handler for some signals."""
2554     signal.signal(signal.SIGHUP, sighook)
2555     signal.signal(signal.SIGTERM, sighook)
2556
2557
2558 def setup():
2559     """This contains functions to be performed when starting the engine."""
2560
2561     # see if a configuration file was specified
2562     if len(sys.argv) > 1:
2563         conffile = sys.argv[1]
2564     else:
2565         conffile = ""
2566
2567     # the big bang
2568     global universe
2569     universe = Universe(conffile, True)
2570
2571     # log an initial message
2572     log("Started mudpy with command line: " + " ".join(sys.argv))
2573
2574     # fork and disassociate
2575     daemonize(universe)
2576
2577     # override the default exception handler so we get logging first thing
2578     override_excepthook()
2579
2580     # set up custom signal handlers
2581     assign_sighook()
2582
2583     # make the pidfile
2584     create_pidfile(universe)
2585
2586     # pass the initialized universe back
2587     return universe
2588
2589
2590 def finish():
2591     """This contains functions to be performed when shutting down the
2592         engine."""
2593
2594     # the loop has terminated, so save persistent data
2595     universe.save()
2596
2597     # log a final message
2598     log("Shutting down now.")
2599
2600     # get rid of the pidfile
2601     remove_pidfile(universe)