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