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