diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py new file mode 100644 index 000000000..dd1002b11 --- /dev/null +++ b/evennia/contrib/rpsystem.py @@ -0,0 +1,771 @@ +""" +RP base system for Evennia + +Contrib by Griatch, 2015 + + +This RP base system introduces the following features to a game, +common to many RP-centric games: + + emote system using director stance emoting (names/sdescs instead of + replacing with you etc) + sdesc obscuration of real character names for use in emotes + and in any referencing + recog system to assign your own nicknames to characters, can then + be used for referencing + pose system to set room-persistent poses, visible in room + descriptions and when looking at the person + +Emote system: This uses a customizable replacement noun (/me, @ etc) + to represent you in the emote. You can use /sdesc, /nick, /key or + /alias to reference objects in the room. +Sdesc system: + This relies on an Attribute `sdesc` being set on the Character and + makes use of a custom Character.get_display_name hook. If sdesc + is not set, the character's `key` is used instead. This is particularly + used in the emoting system. +Recog system: + The user may recog a user and assign any personal nick to them. This + will be shown in descriptions and used to reference them. This is + making use of the nick functionality of Evennia. +Pose system: + This is a simple Attribute that modifies the way the character is + listed when in a room as sdesc + pose. + +Examples: + +> look +Tavern +The tavern is full of nice people + +You see *a tall man* standing by the bar. + +Above is an example of a player with an sdesc "a tall man". It is also +an example of a static *pose*: The "standing by the bar" has been set +by the player of the tall man, so that people looking at him can tell +at a glance what is going on. + +> emote /me looks at /tall and says "Hello!" + +I see: + Griatch looks at Tall man and says "Hello". +Tall man (assuming his name is Tom) sees: + The godlike figure looks at Tom and says "Hello". + +""" + +import re +from re import match as re_match +import itertools +from copy import copy +from evennia import DefaultObject, DefaultCharacter +from evennia import Command + +#------------------------------------------------------------ +# Emote parser +#------------------------------------------------------------ + +# Texts + +_EMOTE_NOMATCH_ERROR = \ +"""{{RNo match for {{r{ref}{{R.{{n""" + +_EMOTE_MULTIMATCH_ERROR = \ +"""{{RMultiple possibilities for {ref}: + {{r{reflist}{{n""" + +_LANGUAGE_NOMATCH_ERROR = \ +"""{{RNo language named {{r{langname}{{n""" + + +# The prefix is the (single-character) symbol used to find the start +# of a object reference, such as /tall (note that +# the system will understand multi-word references). +_PREFIX = "/" + +# The num_sep is the (single-character) symbol used to separate the +# sdesc from the number when trying to separate identical sdescs from +# one another. This is the same syntax used in the rest of Evennia, so +# by default, multiple "tall" can be separated by entering 1-tall, +# 2-tall etc. +_NUM_SEP = "-" + +# This regex will return groups (num, word), where num is an optional counter to +# separate multimatches from one another and word is the first word in the +# marker. So entering "/tall man" will return groups ("", "tall") +# and "/2-tall man" will return groups ("2", "tall"). +_RE_OBJ_REF_START = re.compile(r"%s(?:([0-9]+)%s)*(\w+)" % (_PREFIX, _NUM_SEP), + re.MULTILINE + re.UNICODE + re.IGNORECASE) + +# Reference markers are used internally when distributing the emote to +# all that can see it. They are never seen by players and are on the form {#dbref}. +_RE_REF = re.compile(r"\{+\#([0-9]+)\}+") + +# This regex is used to quickly reference one self in an emote. +_RE_SELF_REF = re.compile(r"/me|@", re.UNICODE + re.IGNORECASE) + +# reference markers for language +_RE_REF_LANG = re.compile(r"\{+\##([0-9]+)\}+") +# language says in the emote are on the form "..." or langname"..." (no spaces). +# this regex returns in groups (langname, say), where langname can be empty. +_RE_LANGUAGE = re.compile(r"(?:(\w+))*(\".+?\")") + +#TODO +# make this into a pluggable language module for handling +# language errors and translations. + +_LANGUAGE_MODULE = None # load code here +#TODO function determining if a given langname exists. Note that +# langname can be None if not specified explicitly. +_LANGUAGE_AVAILABLE = lambda langname: True +#TODO function to translate a string in a given language +_LANGUAGE_TRANSLATE = lambda speaker, listener, language, text: text +#TODO list available languages +_LANGUAGE_LIST = lambda: [] + + +# the emote parser works in two steps: +# 1) convert the incoming emote into an intermediary +# form with all object references mapped to ids. +# 2) for every person seeing the emote, parse this +# intermediary form into the one valid for that char. + +class EmoteError(Exception): + pass + + +class SdescError(Exception): + pass + + +class LanguageError(Exception): + pass + + +def ordered_permutation_regex(sentence): + """ + Builds a regex that matches 'ordered permutations' of a sentence's + words. + + Args: + sentence (str): The sentence to build a match pattern to + + Returns: + regex (re object): Compiled regex object represented the + possible ordered permutations of the sentence, from longest to + shortest. + Example: + The sdesc_regex for an sdesc of " very tall man" will + result in the following allowed permutations, + regex-matched in inverse order of length (case-insensitive): + "the very tall man", "the very tall", "very tall man", + "very tall", "the very", "tall man", "the", "very", "tall", + and "man". + + """ + # escape {#nnn} markers from sentence, replace with nnn + sentence = _RE_REF.sub("\1", sentence) + # escape self-ref marker from sentence + sentence = _RE_SELF_REF.sub("", sentence) + + # ordered permutation algorithm + words = sentence.split() + combinations = itertools.product((True, False), repeat=len(words)) + solution = [] + for combination in combinations: + comb = [] + for iword, word in enumerate(words): + if combination[iword]: + comb.append(word) + elif comb: + break + if comb: + solution.append(_PREFIX + " ".join(comb)) + + # compile into a match regex, first matching the longest down to the shortest components + regex = r"|".join(sorted(set(solution), key=lambda o:len(o), reverse=True))#, re.MULTILINE + re.IGNORECASE + re.UNICODE + return regex + + +def parse_language(speaker, emote): + """ + Parse the emote for language. This is + used with a plugin for handling languages. + + Args: + speaker (Object): The object speaking. + emote (str): An emote possibly containing + language references. + + Returns: + (emote, mapping) (tuple): A tuple where the + `emote` is the emote string with all says + (including quotes) replaced with reference + markers on the form {##n} where n is a running + number. The `mapping` is a dictionary between + the markers and a tuple (langname, saytext), where + langname can be None. + Raises: + LanguageError: If an invalid language was specified. + + Notes: + Note that no errors are raised if the wrong language identifier + is given. + This data, together with the identity of the speaker, is + intended to be used by the "listener" later, since with this + information the language skill of the speaker can be offset to + the language skill of the listener to determine how much + information is actually conveyed. + + """ + # escape mapping syntax on the form {##id} if it exists already in emote, + # if so it is replaced with just "id". + emote = _RE_REF_LANG.sub("\1", emote) + + errors = [] + mapping = {} + for imatch, say_match in enumerate(reversed(list(_RE_LANGUAGE.finditer(emote)))): + # process matches backwards to be able to replace + # in-place without messing up indexes for future matches + # note that saytext includes surrounding "...". + langname, saytext = say_match.group() + if not _LANGUAGE_AVAILABLE(langname): + errors.append(_LANGUAGE_NOMATCH_ERROR.format(langname=langname)) + continue + + istart, iend = say_match.start(), say_match.end() + # the key is simply the running match in the emote + key = "##%i" % imatch + # replace say with ref markers in emote + emote = emote[:istart] + "{%s}" % key + emote[iend:] + mapping[key] = (langname, saytext) + + if errors: + # catch errors and report + raise LanguageError("\n".join(errors)) + + # at this point all says have been replaced with {##nn} markers + # and mapping maps 1:1 to this. + return emote, mapping + + +def parse_sdescs_and_recogs(sender, candidates, emote): + """ + Read a textraw emote and parse it into an intermediary + format for distributing to all observers. + + Args: + sender (Object): The object sending the emote. This object's + recog data will be considered in the parsing. + candidates (iterable): A list of objects valid for referencing + in the emote. + emote (str): The incoming emote from the caller. + + Returns: + (emote, mapping) (tuple): A tuple where the emote is + the emote string, with all references replaced with + internal-representation {#dbref} markers and mapping + is a dictionary {"#dbref":obj,...} + + Raises: + EmoteException: For various ref-matching errors. + + Notes: + The parser analyzes and should understand the following + _PREFIX-tagged structures in the emote: + - self-reference (/me) + - recogs (any part of it) stored on emoter, matching obj in `candidates`. + - sdesc (any part of it) from any obj in `candidates`. + - N-sdesc, N-recog separating multi-matches (1-tall, 2-tall) + - says, "..." are + + """ + + # escape mapping syntax on the form {#id} if it exists already in emote, + # if so it is replaced with just "id". + emote = _RE_REF.sub("\1", emote) + + # build all candidate regex tuples + candidate_regexes = [ + (recog_regex, obj, sdesc) for obj, (recog_regex, sdesc) + in sender.db.recog_objmap.items() if obj in candidates] + \ + [obj.db.sdesc_regex_tuple or ("", None, None) for obj in candidates] + + # handle self-reference first + mapping = {} + if _RE_SELF_REF.search(emote): + key = "#%i" % sender.id + emote = _RE_SELF_REF.sub("{%s}" % key, emote) + mapping[key] = sender + + # we now loop over all references and analyze them + errors = [] + for marker_match in reversed(list(_RE_OBJ_REF_START.finditer(emote))): + # we scan backwards so we can replace in-situ without messing + # up later occurrences. Given a marker match, query from + # start index forward for all candidates. + + # first see if there is a number given (e.g. 1-tall) + num_identifier, _ = marker_match.groups("") + istart0 = marker_match.start() + # +1 for _PREFIX, +1 for _NUM_SEP, if defined + istart = istart0 + 1 + (len(num_identifier) + 1 if num_identifier else 0) + + # loop over all candidate regexes and match against the string following the match + matches = ((re_match(reg, emote[istart:], re.M + re.M + re.U), obj, text) for reg, obj, text in candidate_regexes) + + # score matches by how long part of the string was matched + matches = [(match.endpos if match else -1, obj, text) for match, obj, text in matches] + maxscore = max(score for score, obj in matches) + + # analyze result + if maxscore == -1: + # No matches + errors.append(_EMOTE_NOMATCH_ERROR.format(ref=marker_match.group())) + continue + + # we have a valid maxscore, extract all matches with this value + bestmatches = [obj for score, obj in matches if maxscore == score] + nmatches = len(bestmatches) + if nmatches == 1: + # an exact match. + obj = bestmatches[0] + if nmatches: + # several matches have the same score. + inum = max(0, int(num_identifier) - 1) if num_identifier else None + if all(bestmatches[0].id == obj.id for obj in bestmatches): + # multi-matches all references the same obj (could happen with clashing recogs/sdescs) + obj = bestmatches[0] + else: + # was a numberical identifier given to help us separate the multi-match? + if inum is None or inum > nmatches: + # no match or invalid match id given + refname = marker_match.group() + reflist = ["%s%s%s (%s)" % (inum, _NUM_SEP, refname, text) + for inum, (score, obj, text) in enumerate(bestmatches) if score == maxscore] + errors.append(_EMOTE_MULTIMATCH_ERROR.format(ref=marker_match.group(), reflist=reflist)) + continue + else: + # A valid inum is given. Use this to separate data + obj = bestmatches[inum] + + # if we get to this point we have identifed a local object tied to this sdesc or recog marker. + # we replace it with the interal representation on the form {#dbref}. + key = "#%i" % obj.id + emote = emote[:istart0] + "{%s}" % key + emote[score:] + mapping[key] = obj + + if errors: + # make sure to not let errors through. + raise EmoteError("\n".join(errors)) + + # at this point all references have been replaced with {#xxx} markers and the mapping contains + # a 1:1 mapping between those inline markers and objects. + return emote, mapping + + +def receive_emote(sender, receiver, emote, sdesc_mapping, language_mapping): + """ + Receive a pre-parsed emote. + + Args: + sender (Object): The object sending the emote. + receiver (Object): The object receiving (seeing) the emote. + emote (str): A pre-parsed emote string created with + `parse_emote`, with {#dbref} and {##nn} references for + objects and languages respectively. + sdesc_mapping (dict): A mapping between "#dbref" keys and + objects. + language_mapping (dict): A mapping "##dbref" and (langname, saytext). + + Returns: + finished_emote (str): The finished and ready-to-send emote + string, customized for receiver. + + Notes: + This function will translage all text back based both on sdesc + and recog mappings, but will give presedence to recog mappings. + """ + # we make a local copy that we can modify + mapping = copy(sdesc_mapping) + # overload mapping with receiver's recogs (which is on the same form) + if receiver.db.recog_refmap: + mapping.update(receiver.db.recog_refmap) + # handle the language mapping, which always produce different keys ##nn + for key, (langname, saytext) in language_mapping.iteritems(): + mapping[key] = _LANGUAGE_TRANSLATE(sender, receiver, langname, saytext) + return emote.format(**mapping) + + +def send_emote(sender, receivers, emote, no_anonymous=True): + """ + Main access function for distribute an emote. + + Args: + sender (Object): The one sending the emote. + receivers (iterable): Receivers of the emote. These + will also form the basis for which sdescs are + 'valid' to use in the emote. + emote (str): The raw emote string as input by emoter. + no_anonymous (bool, optional): Do not allow anonynous + emotes, that is, emotes without sender self-referencing, + but add an extra reference to the end of the emote + if so. + + """ + + try: + emote, sdesc_mapping = parse_sdescs_and_recogs(sender, receivers, emote) + emote, language_mapping = parse_language(sender, emote) + except (EmoteError, LanguageError) as err: + # handle all error messages, don't hide actual coding errors + sender.msg(err.message) + return + + if no_anonymous and not sender in sdesc_mapping.values(): + # no self-reference in the emote - add to the end + key = "#%i" % sender.id + emote = "%s [%s]" % (emote, "{%s}" % key) + sdesc_mapping[key] = sender + + # broadcast emote + for receiver in receivers: + receive_emote(sender, receiver, emote, sdesc_mapping, language_mapping) + + +#------------------------------------------------------------ +# RP Commands +#------------------------------------------------------------ + + +class RPCommand(Command): + "simple parent" + def parse(self): + "strip extra whitespace" + self.args = self.args.strip() + +class CmdEmote(RPCommand): # replaces the main emote + """ + Emote an action, allowing dynamic replacement of + text in the emote. + + Usage: + emote text + + Example: + emote /me looks around. + emote With a flurry /me attacks /tall man with his sword. + emote "Hello", /me says. + + Describes an event in the world. This allows the use of /ref + markers to replace with the short descriptions or recognized + strings of objects in the same room. These will be translated to + emotes to match each person seeing it. Use "..." for saying + things and langcode"..." without spaces to say something in + a different language. + + """ + key = "emote" + aliases = [":"] + locks = "cmd:all()" + + def func(self): + "Perform the emote." + if not self.args: + self.caller.msg("What do you want to do?") + else: + # we also include ourselves here. + targets = self.caller.location.contents + send_emote(self.caller, targets, self.args, no_anonymous=True) + + +class CmdSdesc(RPCommand): # set/look at own sdesc + """ + Assign yourself a short description (sdesc). + + Usage: + sdesc + + Assigns a short description to yourself. + + """ + key = "sdesc" + locks = "cmd:all()" + + def func(self): + "Assign the sdesc" + caller = self.caller + if not self.args: + caller.msg("Usage: sdesc ") + return + else: + sdesc = caller.set_sdesc(self.args) + caller.msg("Your sdesc was set to '%s'." % sdesc) + + +class CmdPose(Command): # set current pose and default pose + """ + Set a static pose + + Usage: + pose + pose default + + Examples: + pose leans against the tree + pose is talking to the barkeep. + + Set a static pose. This is the end of a full sentence that + starts with your sdesc. If no full stop is given, it will + be added automatically. The default pose means + + """ + key = "pose" + + def parse(self): + """ + Extract the "default" alternative to the pose. + """ + args = self.args.strip() + default = args.startswith("default") + if default: + args = args.strip("default ") + self.default = default + self.args = args.strip() + + def func(self): + "Create the pose" + caller = self.caller + pose = self.args + if not pose: + caller.msg("Usage: pose OR pose default ") + else: + if not pose.endswith("."): + pose = "%s." % pose + if self.default: + caller.db.pose_default = pose + else: + caller.db.pose = pose + caller.msg("Your pose is now '%s %s'." % pose) + + +class CmdRecog(Command): # assign personal alias to object in room + """ + Recognize another person in the same room. + + Usage: + recog sdesc as alias + + Example: + recog tall man as Griatch + + This will assign a personal alias for a person. + + """ + key = "recog" + aliases = ["recognize"] + + def parse(self): + "Parse for the sdesc as alias structure" + self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)] + + def func(self): + "Assign the recog" + caller = self.caller + if not all(self.args, self.sdesc, self.alias): + caller.msg("Usage: recog as ") + sdesc = self.sdesc + if not sdesc.startswith("/"): + # we make use of the emote parse mapper so + # we need to prep the sdesc a little + sdesc = "/%s" % sdesc + candidates = caller.location.contents + try: + _, mapping = parse_sdescs_and_recogs(candidates, sdesc) + + except EmoteError as err: + # errors are handled already here + caller.msg(err.message) + return + obj = mapping.values()[0] + # we have all we need, add the recog alias + alias = caller.set_recog(obj, self.alias) + caller.msg("You will now remember {w%s{n as {w%s{n." % (sdesc, alias)) + + +class CmdLanguage(Command): # list available languages + """ + List the available languages. + + Usages: + languages + + This will display a list of all languages available + and the short names needed to speak a given language in + an emote. + + """ + key = "language" + + def func(self): + "simple list" + self.caller.msg("Languages available: %s" % ", ".join(_LANGUAGE_LIST)) + + +#------------------------------------------------------------ +# RP Object typeclass +#------------------------------------------------------------ + +class RPObject(DefaultObject): + """ + This class is meant as a mix-in or parent for characters in an + rp-heavy game. It implements the base functionality for sdescs, + name replacement and look extensions. + """ + + def at_object_creation(self): + """ + Called at initial creation. + """ + super(RPObject, self).at_object_creation + + # emoting/recog data + self.db.pose = "" + self.db.pose_default = "is here." + + self.db.sdesc = None + self.db.sdesc_regex_tuple = None + self.set_sdesc("A normal person") + self.db.recog_refmap = {} + self.db.recog_objmap = {} + + def set_sdesc(self, sdesc): + """ + Assign a new sdesc to this character. It's important + to use this in order to set up all mappings. + + Args: + sdesc (str): The short description to set. + + Returns: + sdesc (str): The (possibly cleaned up) sdesc actually set. + + """ + # strip emote components from sdesc + sdesc = _RE_REF.sub("\1", + _RE_REF_LANG.sub("\1", + _RE_SELF_REF.sub("", + _RE_LANGUAGE.sub("", + _RE_OBJ_REF_START.sub("", sdesc))))) + + self.db.sdesc = sdesc + self.db.sdesc_regex_tuple = (ordered_permutation_regex(sdesc), self, sdesc) + return sdesc + + def set_recog(self, obj, recog): + """ + Assign a custom recog (nick) to the given sdesc. This + requires that the sdesc exists in the same room as us. + + Args: + obj (Object): The object ot associate with the recog string. This + is usually determined from the sdesc in the room by a call to + parse_sdescs_and_recogs, but can also be given, say as part + of changing an existing recog value.given, say as part + recog (str): The replacement string to use for this sdesc. + + Returns: + recog (str): The (possibly cleaned up) recog string actually set. + + """ + # strip emote components from recog + recog = _RE_REF.sub("\1", + _RE_REF_LANG.sub("\1", + _RE_SELF_REF.sub("", + _RE_LANGUAGE.sub("", + _RE_OBJ_REF_START.sub("", recog))))) + + # mapping #dbref:obj + self.db.recog_refmap.update("{#%i}" % obj.id, recog) + # mapping obj:(regex, recog) + self.db.recog_objmap[obj] = (ordered_permutation_regex(recog), recog) + + def get_recog(self, obj): + """ + Get recog replacement string, if one exists. + + Args: + obj (Object): The object, whose sdesc to replace + Returns: + recog (str): The replacement string to use. + + """ + return self.db.recog_objmap.get(obj, (None, None))[1] + + def get_display_name(self, looker, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + + Args: + looker (TypedObject): The object or player that is looking + at/getting inforamtion for this object. + + Kwargs: + pose (bool): Include the pose (if available) in the return. + + Returns: + name (str): A string of the sdesc containing the name of the object, + if this is defined. + including the DBREF if this user is privileged to control + said object. + + """ + idstr = " (#%s)" % self.id if self.access(looker, access_type='control') else "" + try: + recog = looker.get_recog(self) + except AttributeError: + recog = None + sdesc = recog or self.db.sdesc or self.key + pose = " %s" % self.db.pose or "" if kwargs.get("pose", False) else "" + return "%s%s%s" % (sdesc, pose, idstr) + + def return_appearance(self, looker): + """ + This formats a description. It is the hook a 'look' command + should call. + + Args: + looker (Object): Object doing the looking. + """ + if not looker: + return + # get and identify all objects + visible = (con for con in self.contents if con != looker and + con.access(looker, "view")) + exits, users, things = [], [], [] + for con in visible: + key = con.get_display_name(looker, pose=True) + if con.destination: + exits.append(key) + elif con.has_player: + users.append("{c%s{n" % key) + else: + things.append(key) + # get description, build string + string = "{c%s{n\n" % self.get_display_name(looker, pose=True) + desc = self.db.desc + if desc: + string += "%s" % desc + if exits: + string += "\n{wExits:{n " + ", ".join(exits) + if users or things: + string += "\n{wYou see:{n " + "\n ".join(users + things) + return string + + +class RPCharacter(DefaultCharacter, RPObject): + """ + This is a character aware of RP systems. + """ + def at_object_creation(self): + super(RPCharacter, self).at_object_creation()