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