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