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