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