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