diff --git a/commands/feedables.py b/commands/feedables.py index e9120f3..d46c6c7 100755 --- a/commands/feedables.py +++ b/commands/feedables.py @@ -4,7 +4,6 @@ from evennia import CmdSet from evennia.commands.default.general import CmdGive, NumberedTargetCommand from commands.command import Command -# from typeclasses.pets import Pet class CmdFeed(Command, NumberedTargetCommand): diff --git a/commands/pets.py b/commands/pets.py new file mode 100755 index 0000000..5ffa552 --- /dev/null +++ b/commands/pets.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python + +from evennia import CmdSet + +from commands.command import Command + +class CmdPet(Command): + """ + Pet a 'friendly' pet. + """ + key = "pet" + + def func(self): + """ + Implements the pet command. + """ + self.obj.pet_response(self.caller) + +class CmdPetSet(CmdSet): + """ + Things associated with pets. + """ + def at_cmdset_creation(self): + self.add(CmdPet) diff --git a/typeclasses/characters.py b/typeclasses/characters.py index 92d2dd1..01b7f1a 100644 --- a/typeclasses/characters.py +++ b/typeclasses/characters.py @@ -127,10 +127,10 @@ class Character(Object, GenderCharacter): for thing in self.contents: to = thing.locks.get('tethered') if to: - m = match(r".*:id\(#?(.*)\)", to) + m = match(r".*:id\((.*)\)", to) if m: id_num = m.group(1) - dest = self.global_search(f"#{id_num}") + dest = self.search(f"{id_num}") msg = thing.db.tethered_msg if dest and msg: thing.location = dest diff --git a/typeclasses/pets.py b/typeclasses/pets.py index 2554ec3..5f54e10 100755 --- a/typeclasses/pets.py +++ b/typeclasses/pets.py @@ -22,6 +22,7 @@ from typeclasses.objects import Object from typeclasses.characters import Character # from typeclasses.lightables import LightSource from commands.feedables import CmdFeedSet +from commands.pets import CmdPetSet from utils.word_list import squish, choices, split_party_msg @@ -220,6 +221,8 @@ class Friendly(Pet): """ super().at_object_creation() + self.cmdset.add(CmdPetSet) + # We have a list of actions that were spammed to the room: self.db.last_actions = [] @@ -371,7 +374,14 @@ class Friendly(Pet): def update_state(self, *args, **kwargs): """ - Hrm. + Call regularly to adjust the pet's reaction state. + + Change the state associated with _all_ characters, as well + as characters in the area, based on 'loneliness_amount' and + 'shyness_amount' respectively. + + Then, if 'active_amount' is triggered, call 'do_action'. + """ super().update_state(*args, **kwargs) self.adjust_all(self.db.loneliness_amount or -1) @@ -408,6 +418,33 @@ class Friendly(Pet): self.db.last_actions = self.db.last_actions[-5:] split_party_msg(focus, msg) + def pet_response(self, petter): + """ + Called with 'petter' attempts to 'pet' this. + Reaction should be based on petter reaction state. + """ + match self.friendly_reaction(petter): + case Reaction.SCARED: + msg = self.db.pet_scared_response + self.adjust_character(petter, self.db.pet_scared_adjust or 0) + case Reaction.CONCERNED: + msg = self.db.pet_concerned_response + self.adjust_character(petter, self.db.pet_concerned_adjust or 0) + case Reaction.INTERESTED: + msg = self.db.pet_interested_response + self.adjust_character(petter, self.db.pet_interested_adjust or 1) + case Reaction.FRIENDLY: + msg = self.db.pet_friendly_response + self.adjust_character(petter, self.db.pet_friendly_adjust or 8) + case Reaction.ECSTATIC: + msg = self.db.pet_ecstatic_response + self.adjust_character(petter, self.db.pet_ecstatic_adjust or 10) + if msg: + split_party_msg(petter, msg) + else: + petter.msg(f"You pet {self.name}.") + + class BHB(Friendly): def return_appearance(self, looker): @@ -532,7 +569,6 @@ class BHB(Friendly): case Reaction.CONCERNED: msg = "The beast walks over to the stick, sniffs it, and backs away." self.adjust_character(thrower, 5) - case Reaction.INTERESTED: msg = choices(""" The beast <> at the stick, then looks at , <> what to do with a stick the flies back to its owner. ;; diff --git a/typeclasses/things.py b/typeclasses/things.py index d65b2c1..ec15115 100755 --- a/typeclasses/things.py +++ b/typeclasses/things.py @@ -49,7 +49,7 @@ class Trinket(Object): # Once they have seen the crystal ball, they can now "see" # them, and probably pick one up, if they are around: if self.db.last_trinket_num == 0: - self.tags.add("hidden_ball") + looker.tags.add("hidden_ball") # Seen all the trinkets? Oh boy, well, let's loop: if self.db.last_trinket_num >= len(self.msgs): @@ -99,6 +99,38 @@ class Ring(Object): return False +class Pipe(Object): + """Simple abstraction for the following actions. + + Note that each message has two versions, one for the smoker (you) + and one for everyone else in the room. + + @set pipe/light_msg = "You pull out, pack and light a pipe." + @set pipe/light_msg_other = "{0} packs |p pipe and lights it." + + Where the |p is a possessive gender, if set, e.g. his or her. + + The random messages has available substitutions based on if the message is for the smoker or for the audience in the room. Specifically: + + - {0} :: either "you" or your name + - {1} :: either "your" or your name with an apostrophe 's. + - {2} :: blank (for you) or "s" for everyone else, e.g. "blow{2}" + + For instance: + + @set pipe/random_msgs = "{0} blow{1} a <> smoke-ring followed by another that flies through the first. ;; {1} smoke collesce to form a <> ... or + """ + def do_light(self, lighter): + you_msg = choices(self.db.light_msg or "You pack and light your pipe.") + lighter.msg(you_msg) + # desc = self.return_appearance()[:1].lower() + self.return_appearance()[1:] + other_msg = choices(self.db.light_msg_other or "{0} packs and lights |p pipe.", lighter.name) + lighter.location.msg_contents(other_msg, exclude=lighter) + + def do_puff(self, smoker): + pass + + class Wood(Object): "An object to burn." def at_object_creation(self): diff --git a/utils/word_list.py b/utils/word_list.py index 0ce5d8c..6cb646e 100755 --- a/utils/word_list.py +++ b/utils/word_list.py @@ -1,12 +1,61 @@ #!/usr/bin/env python -import random -import re +from itertools import batched +from random import choice +from re import compile, sub, split +from evennia.utils import logger def squish(text): "Remove series of spaces from the text." - return re.sub('[ \n\t]+', ' ', text).strip() + return sub('[ \t]+', ' ', text).strip() + +def _routput_choose(text): + """ + Pick a choice when text is: one ^ two ^ three + Done by splitting the text, and calling random.choice(). + """ + choices = split(r" *\^ *", text) + return choice(choices) + +def _routput_empty(entry): + """ + Return True if entry is empty, e.g. None or blank. + False otherwise. Note: Spaces are ignored. + """ + if entry: + if isinstance(entry, str) and entry.strip() == '': + return True + if isinstance(entry, (list, tuple)) and len(entry) == 0: + return True + return False + return True + +def _routput_pair(no_choice, choices = None): + """ + Return a list based on the ^-separated options in 'choices'. + + Give a pair of split items, the 'no_choice' is the first section + (before the << ... >>>), and the 'choices' is the section between + the '<< ... >>' delimiter. + """ + + # While unlikely, the text before << and the options between the + # << ...>> text may be blank: + if _routput_empty(no_choice) and _routput_empty(choices): + return '' + + # If we start a string with <<...>>, then thte 'no_choice' option + # is blank, so we only return a choice: + if _routput_empty(no_choice): + return _routput_choose(choices) + + # With text before, but not inside the <<...>> section, we just + # return the first part: + if _routput_empty(choices): + return no_choice + + return no_choice + ' ' + _routput_choose(choices) def routput(text, *substitutions): """ @@ -22,13 +71,19 @@ def routput(text, *substitutions): 'This feels very comfortable.' """ if text: - acc = [] - for s in text.split("<<"): - selections, *rest = s.split(">>") - choice = random.choice(re.split(r"\s*\^\s*", selections)) - acc = acc + [choice] + rest + # section is a list of some phrase followed by another phrase + # that contains ^-separate choices: + sections = split(r" *<< *(.*?) *>> *", text) - proposal = squish(''.join(acc).format(*substitutions)) + # Parts are the first section followed by the _rendered_ + # choices, so this^that is either 'this' or 'that': + parts = [_routput_pair(*pair) for pair in batched(sections, 2)] + + # The parts may have empty strings, so we filter those out, + # leaving on the phrases to join: + phrases = [phrase for phrase in parts if not _routput_empty(phrase)] + + proposal = ' '.join(phrases).format(*substitutions) # If a choice is at the end of a sentence, and we could have # "no choice", as in: @@ -36,8 +91,9 @@ def routput(text, *substitutions): # We don't want a version that looks like: # "He searches # ." # with a space before the punctuation: - - return re.sub(r"\s+([!?.,])", "\\1", proposal) + # + # Also, we should remove all double spaces that may show: + return squish(sub(r"\s+([!?.,])", "\\1", proposal)) def choices(text, *substitutions): @@ -47,23 +103,32 @@ def choices(text, *substitutions): Note that text can already be separated as a list or tuple. """ if isinstance(text, str): - selections = re.split(r"\s*;;\s*", text) + selections = split(r"\s*;;\s*", text) elif isinstance(text, (tuple, list)): selections = text if selections: - return routput(random.choice(selections), *substitutions) + return routput(choice(selections), *substitutions) -def split_party_msg(viewer, msg): +def split_party_msg(viewer, msg, *substitutions): + """ + Send a message to 'viewer' as well as all people in the area. + + Note that 'msg' could have choices separated by ;; + As well as random words, separated by << one ^ two ^ three >> + As well as one word for 'viewer' and other for rest, as in + <( You ^ {0} )> <( give ^ gives )> the ball. + """ + text = choices(msg, viewer.name.title()) + pattern = compile(r"\<\( *(.*?) *\^ *(.*?) *\)\>") + # First a message for the view: - viewer.msg( - re.sub("", "You", re.sub("", "you", msg)) - ) + viewer.msg( pattern.sub("\\1", text) ) + # Then the message for the rest of the area: - viewer.location.msg_contents(re.sub("<[Yy]ou>", - viewer.name.title(), msg), - exclude=viewer) + viewer.location.msg_contents( + pattern.sub("\\2", text), exclude=viewer) # def searsonal(text, **kwargs): # season = kwargs['season'] or kwargs['location'].get_season()