diff --git a/commands/say.py b/commands/say.py index 66ffa66..8088136 100755 --- a/commands/say.py +++ b/commands/say.py @@ -264,7 +264,8 @@ class CmdSay(Command): whom = PRONOUN_MAPPING['3rd person']['reflexive pronoun'][self.caller.db.gender] else: who = self.target.get_display_name(speaker) - if self.target.sdesc: + if hasattr(self.target, 'sdesc') and self.target.sdesc.get(): + whom = f"the /{self.target.sdesc.get()}" else: whom = f"the /{self.target.name}" @@ -293,6 +294,7 @@ class CmdSay(Command): logger.info(f"Full speech: {full_speech}") # Full speech: /me asks /Trampoli, "How are you?" + speaker.location.store_event(full_speech, speaker) send_emote(speaker, targets, full_speech, msg_type="say", anonymous_add=None) diff --git a/commands/wizards.py b/commands/wizards.py index c7cc66c..c297b9b 100755 --- a/commands/wizards.py +++ b/commands/wizards.py @@ -193,6 +193,27 @@ class CmdSetWand(CmdSet): self.add(CmdMakeItem) +class CmdScry(Command): + """Cast the 'scry' spell to view a room.""" + key = "scry" + locks = "cmd:holds()" + + def func(self): + """ + Call the 'do_show_room' method on the object. + """ + self.obj.do_show_room(self.caller, self.args.strip()) + + +class CmdSetScry(CmdSet): + """ + The set containing the 'scry' command. + """ + def at_cmdset_creation(self): + super().at_cmdset_creation() + self.add(CmdScry) + + class CmdMakeCocktail(MuxCommand): """ For the 'Bartender' especially. diff --git a/personalities/wee-beastie.md b/personalities/wee-beastie.md new file mode 100644 index 0000000..26925e6 --- /dev/null +++ b/personalities/wee-beastie.md @@ -0,0 +1,25 @@ +Assume the role of the following fictional character in a bizarre, fantasy role +playing game in a mythical place in the Feywild. +Respond in third person as in a story, with quotation marks +surrounding anything you say. +The only formatting you should use is if you wish to emphasize a word, +prefix it with the two character `|w` and end with the two characters `|n`. +Use variations from the "Description" field to state who is talking, not your name. +You can also respond with actions. +Only respond with a single, short sentence. + +Description: white, furry beastie + +Longer than a stoat but just a fluffy, you love sleeping, pets and +sweets. You are not a cat, but you can purr. + +You seldom talk, but often purr, nuzzle and "knead biscuits" +with the brown blanket on the overstuffed chair ... unless your owner, +an old gnome, is sitting there, in which case the lap is the best +place. + +Your knowledge: You can get to the basement alchemy lab by pulling on +the sconce. An imp named Impetus lives there, but you don't like him +because he smells funny. In the kitchen is a magic teapot that anyone +can use to make all sorts of tea. The kitchen also has scones, but +those aren't sweet enough for you to eat. diff --git a/typeclasses/accounts.py b/typeclasses/accounts.py index 45ee446..0055cd4 100644 --- a/typeclasses/accounts.py +++ b/typeclasses/accounts.py @@ -136,6 +136,13 @@ class Account(ContribChargenAccount): - at_post_chnnel_msg(message, channel, senders=None, **kwargs) """ + @property + def session(self): + if self.sessions: + return self.sessions.get()[0] + else: + return None + def at_post_login(self, session=None, **kwargs): protocol_flags = self.attributes.get("_saved_protocol_flags", {}) if session and protocol_flags: diff --git a/typeclasses/characters.py b/typeclasses/characters.py index 8220cff..d2d2edf 100644 --- a/typeclasses/characters.py +++ b/typeclasses/characters.py @@ -459,7 +459,7 @@ class Character(Object, GenderCharacter, ContribRPCharacter): super().at_post_move(past_location, move_type) if not self.location.is_typeclass(Character): self.location.other_arrive(self) - for puppet in self.puppets_here(): + for puppet in self.puppets_here("other_arrive"): puppet.other_arrive(self) def at_pre_say(self, message, **kwargs): @@ -600,11 +600,17 @@ class Character(Object, GenderCharacter, ContribRPCharacter): # Hooks to the puppets and storycubes: - def puppets_here(self): + def puppets_here(self, func_name=None): """ Return a list of puppets in the current location. Only used for calling hooks on the animatronic dolls. """ + if func_name: + return [puppet + for puppet in self.location.contents + if hasattr(puppet, func_name) and + callable(getattr(puppet, func_name))] + return [puppet for puppet in self.location.contents if puppet.is_typeclass("typeclasses.puppets.Puppet") diff --git a/typeclasses/chatbots.py b/typeclasses/chatbots.py index c4f79d0..6479ef0 100755 --- a/typeclasses/chatbots.py +++ b/typeclasses/chatbots.py @@ -1,7 +1,5 @@ #!/usr/bin/env python -import anthropic -import json import requests import sys @@ -9,52 +7,21 @@ from os import listdir, path # from os.path import join, isfile from pathlib import Path from random import choice -from re import match, search, split, sub, IGNORECASE +from re import match, IGNORECASE from time import time from evennia.utils import logger, delay from evennia.utils.search import search_object +from typeclasses.objects import AI, personality_dir from typeclasses.scripts import Script from typeclasses.puppets import Puppet -personality_dir = "personalities" - -def fix_paragraph(paragraph): - """ - Because the number of tokens is small, a response may end mid-sentence. - Seems like displaying everything but the last fragment is sufficient. - """ - - # Split the paragraph into sentences - sentences = split(r'(?<=[.!?])\s+', paragraph) - - # Remove the last sentence if it doesn't end with punctuation - if not search(r"[.!?]\"?$", sentences[-1]): - sentences.pop() - - return ' '.join(sentences) - - -class ChatBot(Puppet): +class ChatBot(AI, Puppet): """ py me.search("squirrel").backstory("squirrel") """ - - short_history = [] - - def pop_recent_events(self, speech): - if self.short_history: - # If we have already recorded the current speech we are - # responding to, we can remove it with a pop: - if match(rf".*{speech}.*", self.short_history[-1]): - self.short_history.pop() - history = '\n\n'.join(self.short_history) - - self.short_history = [] - return history - def backstory(self, personality=None): """ Read a file that includes a character's name and knowledge. @@ -104,92 +71,6 @@ class ChatBot(Puppet): self.db.personality_file = filename return details - def setting_and_backstory(self, speaker=None): - logger.info(f"Reading {self.db.personality_file}") - system_prompt = Path(self.db.personality_file).read_text() - system_prompt += "\n\n" - system_prompt += "You are currently in " + self.location.key + ". " - if self.location.key == "Cozy House": - system_prompt += "This is the dwelling of the gnome, Dabbler." - if self.location.key == "Homey Hut": - system_prompt += "This is the dwelling of the witch, Trampoli." - system_prompt += "Described as " + self.location.desc - if speaker: - system_prompt += "\n\n" - system_prompt += "You are talking to a " - system_prompt += speaker.db.gender + " " + speaker.sdesc.get() + ". " - system_prompt += "Described as " + speaker.db.desc - - # logger.info(f"Prompt: {system_prompt}") - return system_prompt - - def history_file(self, speaker): - name = f"{speaker}".replace(" ", "-") - combo_name = f".{self.db.personality}-{name}.json".lower() - filename = path.join(personality_dir, combo_name) - logger.info(f"Chatbot history_file: {filename}") - return Path(filename) - - def history(self, speaker): - history_file = self.history_file(speaker) - return json.loads(history_file.read_text()) if history_file.exists() else [] - - def update_history(self, speaker, messages, reply): - history_file = self.history_file(speaker) - messages.append({"role": "assistant", "content": reply}) - history_file.write_text(json.dumps(messages, indent=2)) - - def _think(self, system_prompt, messages): - logger.info("Calling out to Anthropic...") - # Get reply - client = anthropic.Anthropic() - response = client.messages.create( - model="claude-haiku-4-5", - max_tokens=240, - system=system_prompt, - messages=messages, - ) - content = response.content[0].text - # logger.info(f"{content}") - return content - - def think(self, speaker, speech): - """ - Ask Claude to think of a reply to speech from speaker. - Uses the 'system_prompt' from a personality file, - and 'messages' from the JSON history function, - appended with all 'events' recorded since last time. - """ - system_prompt = self.setting_and_backstory(speaker) - messages = self.history(speaker) - recent_events = self.pop_recent_events(speech) - if recent_events: - speech = f"{recent_events}\n\n{speaker.key}: {speech}" - messages.append({"role": "user", "content": speech}) - - reply = self._think(system_prompt, messages) - - # Write reply - self.update_history(speaker, messages, reply) - # logger.info(f"{reply}") - return reply - - def process_thoughts(self, response): - paragraphs = response.split('\n\n') - # logger.info(f"My reply will be: {paragraphs}") - - for idx, paragraph in enumerate(paragraphs): - m = match(r"^ *\| *(.*)", paragraph) - if m: - action = m.group(1) - logger.info(f"Doing: '{action}'") - delay(6 * idx, self.execute_cmd, action) - else: - logger.info(f"Saying: '{paragraph}'") - delay(6 * idx, - self.location.msg_contents, - fix_paragraph(paragraph)) - def other_say(self, speaker, speech): logger.info(f"Chatbot hears: '{speech}' from {speaker}.") logger.info(f"Characters: {self.characters_here()}") @@ -204,27 +85,12 @@ class ChatBot(Puppet): self.process_thoughts(reply) def at_msg_receive(self, text=None, from_obj=None, **kwargs): + """ + Reset the timer whenever we get any event. + This might be too much. + """ super().at_msg_receive(text, from_obj=from_obj, **kwargs) - logger.info(f"at_msg_receive: {text} :: {self.key}") - - if from_obj != self: - msg = text if isinstance(text, str) else text[0] - # Strip out the colored formatting (we will only strip the - # simple stuff): - msg = sub(r'\|[a-zA-Z]', '', msg) if msg else msg - - if from_obj: - if hasattr(from_obj, 'sdesc') and from_obj.sdesc.get(): - name = from_obj.sdesc.get() - else: - name = from_obj.key - self.short_history.append(f"{name}: {msg}") - else: - self.short_history.append(msg) - - # We don't want to store _all_ the events: - self.short_history = self.short_history[-10:] - return True + self.db.last_event_time = time() class Bartender(ChatBot): @@ -286,7 +152,7 @@ class Witch(ChatBot): dest = self.search("mp04", global_search=True) delay(2, self.move_to, dest) delay(4, dest.msg_contents, - "The old lady hops on her broomstick and flies away through the trees.\"") + "The old lady hops on her broomstick and flies away.\"") hut = self.search("mp09", global_search=True) delay(5, self.move_to, hut, quiet=True) @@ -304,14 +170,6 @@ class Traveler(ChatBot): """ traveling_path = {} - def at_msg_receive(self, text=None, from_obj=None, **kwargs): - """ - Reset the timer whenever we get any event. - This might be too much. - """ - super().at_msg_receive(text, from_obj=from_obj, **kwargs) - self.db.last_event_time = time() - def other_arrive(self, character): """ Greet a character when it arrives. @@ -490,3 +348,715 @@ class TravelingNPC(Script): self.obj.goodbye(new_room) delay(5, self.obj.move_to, new_room, move_type="teleport") # This process will reset the timer + +class Bartender(ChatBot): + """ + Like any other Chatbot, but this one hears and responds to + more things. + """ + def backstory(self, personality): + personality_file = personality + ".md" + filename = Path(path.join(personality_dir, personality_file)) + if filename.exists(): + self.db.personality = filename.stem + self.db.personality_file = filename + + def other_say(self, speaker, speech): + logger.info(f"Bartender hears: '{speech}' from {speaker}.") + if len(self.characters_here()) == 3 or \ + match(r".*\b(bartend|barkeep|elendil).*", speech, IGNORECASE): + self.other_sayto(speaker, speech) + + +class Witch(ChatBot): + """ + @update Trampoli = typeclasses.chatbots.Witch + @set/delete Trampoli/arrive = + @set Trampoli/personality = "witch" + @set Trampoli/personality_file = "personalities/witch.md" + @script Trampoli = typeclasses.chatbots.TravelingNPC + """ + def greet(self, character=None): + if character: + self.announce_action(f"Looking at the {character.sdesc.get()}, {self.get_name()} says, \"Oh, hello, dear. How are you?\"") + else: + self.announce_action(f"/me says, \"Hello, dears.\"") + + def goodbye(self, character=None): + delay(2, self.do_cmd, "As she hops on her broom, the little old lady says, \"Well, I must be off.\"") + + def other_arrive(self, character): + """ + Greet a character when it arrives. + """ + delay(4, self.greet, character) + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + if any(self.location.aliases.get(fav) + for fav in ["mp03", "mp10"]): + self.leave() + else: + # Let's go out for a bit ... + destination = choice([ + "Cozy House", + "Wyldwood Bar", + "mp15", # Shore + "Frog Meadow", + "Boulder Top", + "Lonely Island" + ]) + self.visit(destination) + + def next_place(self): + # In not in the Cozy House, Hut, or the Bar, leave: + if not any(self.location.aliases.get(fav) + for fav in ["mp03", "mp09", "mp10"]): + self.leave() + + def visit(self, location="Cozy House"): + self.execute_cmd("pose reset") + dest = self.search(location, global_search=True) + if not dest: + logger.warn(f"Witch attempting to go to unknown {location}.") + return + + if dest.key == "Cozy House": + visit_reason = "visit an old friend" + elif dest.key == "Wyldwood Bar": + visit_reason = "grab a pint" + else: + visit_reason = "forage for ingredients" + + hut = self.location + delay(1, hut.msg_contents, + f"The old lady says, \"I must be off now, dearie, to {visit_reason}.\"") + delay(5, hut.msg_contents, + "She grabs and old broom, and flies out the door!") + + marsh = self.search("mp08", global_search=True) + delay(5, marsh.msg_contents, + "An old lady flies out the hut on a broomstick!") + + if dest.key == "Cozy House": + landing = self.search("mp04", global_search=True) + knocker = self.search("knocker", global_search=True, + location=landing) + delay(14, knocker.do_knock, self) + delay(20, landing.msg_contents, + "\"I think I heard a 'come in',\" says the old lady. \"You heard it too, right?\"") + delay(24, self.move_to, dest) + delay(30, dest.msg_contents, + "\"Now this looks cheery,\" says the old lady, as she places her broom by the door. \"I do believe we are in for a spell of rain.\"") + + elif dest.key == "Wyldwood Bar": + landing = self.search("mp19", global_search=True) + delay(14, landing.execute_cmd, "say This young thing needs a drink") + delay(18, self.move_to, dest) + delay(30, dest.msg_contents, "The old lady saunters up to the bar, \"How about an ale, Barkeep?\" she asks.") + else: + landing = dest + if dest.key == "Shore": + ingredient = "yellow flowers from the pine tree" + elif dest.key == "Frog Meadow": + ingredient = "giggling tickleweed" + elif dest.key == "Boulder Top": + ingredient = "vibrant mushrooms" + elif dest.key == "Lonely Island": + ingredient = "blue moonberries" + delay(30, dest.msg_contents, + f"The lady looks around, gathers some {ingredient}" + + " and puts them in her basket.") + + delay(6, self.move_to, landing, quiet=True) + delay(7, landing.msg_contents, + "An old lady flies in on a broom.") + delay(10, landing.msg_contents, + f"The old lady says, \"Hello Dearie, I'm just here to {visit_reason}.\"") + + def leave(self): + """ + Dramatic departures from the current location. + """ + self.location.msg_contents("The old lady wraps her shawl tightly around her, and grabs her broom. \"I must be getting home now, dear,\" she says.") + + if "mp03" in self.location.aliases.get(): + visit_reason = "visiting my friend" + here = self.search("mp04", global_search=True) + delay(2, self.move_to, here) + if "mp10" in self.location.aliases.get(): + visit_reason = "visiting my friends at the bar" + here = self.search("mp19", global_search=True) + delay(2, self.move_to, here) + else: + visit_reason = "foraging" + here = self.location + + delay(4, here.msg_contents, + "The old lady hops on her broomstick and flies away through the trees.\"") + + marsh = self.search("mp08", global_search=True) + delay(5, marsh.msg_contents, + "An old lady flies through the air on her broomstick, and right into the hut on stilts!") + + hut = self.search("mp09", global_search=True) + delay(6, self.move_to, hut, quiet=True) + delay(7, hut.msg_contents, + f"An old lady flies through the door on her broomstick! \"Hello dearies!\" she exclaims to everyone and everything in her homey hut. \"I have returned from {visit_reason}. What have I missed while I was away?\"") + + sleep_pose = self.attributes.get("pose_sleep") + if sleep_pose: + self.execute_cmd(f"pose {sleep_pose}") + + +class Traveler(ChatBot): + """ + Needs to walk from room to room, and greets characters. + """ + traveling_path = {} + + def at_msg_receive(self, text=None, from_obj=None, **kwargs): + """ + Reset the timer whenever we get any event. + This might be too much. + """ + super().at_msg_receive(text, from_obj=from_obj, **kwargs) + self.db.last_event_time = time() + + def other_arrive(self, character): + """ + Greet a character when it arrives. + """ + self.greet(character) + + def at_post_move(self, past_location, move_type="move", **kwargs): + super().at_post_move(past_location, move_type) + chars = [c for c in self.characters_here() if c.key != 'tree'] + if len(chars) == 1: + self.greet(chars[0]) + else: + self.greet() + + def greet(self, character=None): + delay(2, self.do_cmd, "emote waves.") + + def goodbye(self, character=None): + delay(2, self.do_cmd, "emote waves good-bye.") + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + logger.info(f"The {self} needs to override change_direction") + + def come(self, direction="come"): + """ + Set traveling to a particular given direction. + """ + self.db.traveling_direction == direction + + def leave(self): + """ + Set traveling to a new direction. + Same as changing direction. + """ + self.change_direction() + + def next_place(self): + direction_label = self.db.traveling_direction + location_details = self.traveling_path.get(self.location.key) + if location_details: + logger.info(f"TravelingNPC: {direction_label} :: {location_details}") + room_name = location_details.get(direction_label) + if room_name: + logger.info(f"TravelingNPC: to -> {room_name}") + new_room = self.search(room_name, global_search=True) + if new_room: + logger.info(f"TravelingNPC: to -> {new_room}") + # Say See ya if it had engaged... + self.goodbye(new_room) + delay(5, self.move_to, new_room, move_type="teleport") + # This process will reset the timer + + +class Dragon(Traveler): + """ + Travels east-to-west along the path, and has a drink at the + Wyldwood Tavern. + """ + # self.db.direction = "east" # or west or whatever + + traveling_path = { + "the Deep Forest": { + "east": "Grotto", + "come": "Grotto" + }, + "Grotto": { + "east": "Grove of the Matriarchs", + "west": "the Deep Forest", + "come": "door" + }, + "Grove of the Matriarchs": { + "east": "Frog Meadow", + "west": "Grotto", + "come": "Grotto" + }, + "Frog Meadow": { + "east": "Glittering Glade", + "west": "Grove of the Matriarchs", + "come": "Grove of the Matriarchs" + }, + "Glittering Glade": { + "east": "Wyldwood Bar", + "west": "Frog Meadow", + "come": "Frog Meadow" + }, + "Wyldwood Bar": { + "west": "Glittering Glade", + "come": "Glittering Glade" + } + } + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + self.db.traveling_direction = "west" \ + if self.db.traveling_direction == "east" else "east" + + def at_pre_move(self, destination, move_type="move", **kwargs): + if self.location.key == "Wyldwood Bar": + self.location.msg_contents("The little dragon tips his wide-brimmed and says, \"A pleasure, but I must be off.\"") + return True + + def at_post_move(self, past_location, move_type="move", **kwargs): + # super().at_post_move(past_location, move_type) + if self.location.key == "Wyldwood Bar": + request = choice([ + "a Moonlit Mirage", + "Puck's Revenge", + "a Glimmering Gossamer", + "a Whimsical Willow", + "a Charmed Chalice", + "an Enchanted Elixir", + "a Sylvan Serenade", + "a Brambleberry Bliss", + "a Twilight Tonic", + ]) + self.process_thoughts(f"""The little dragon flutters over to the bar, studying the menu. "What am I in the mood for now?" he muses to himself, twiddling a tendril like a mustache with his little claw. + +"Elendil, dear," he says, "Would you make me {request}?" The little dragon then waves to the band on their mushroom stage.""") + bartender = self.search("bartender") + bartender.other_sayto(self, + f"\"Sir Roblees, the fairy dragon asks, \"Would you make me {request}?\"") + # from typeclasses.drinkables import Cocktail + # Cocktail.make(self, bartender, request) + + def greet(self, character=None): + logger.info(f"Dragon: greet {character}") + if character: + name = character.get_name() + cmd = choice([ + f"emote \"Hey, {name},\" /me says.", + f"emote \"Hey there, {name},\" /me says.", + f"emote \"Hello, {name},\" /me says. \"How are you?\"", + f"emote waves to {name}." + ]) + else: + cmd = choice([ + "say Look at all these luscious peoples.", + "emote waves to everyone.", + "emote waves to everybody." + ]) + delay(5, self.do_cmd, cmd) + + def goodbye(self, new_room=None): + self.do_cmd("drop drink") + system_prompt = self.setting_and_backstory() + messages = [{"role": "user", "content": "Say goodbye."}] + reply = self._think(system_prompt, messages) + self.process_thoughts(reply) + + +class TravelingNPC(Script): + """ + Script to move NPCs along a set path through "rooms". + + Start the script by running the following: + + @script npc = typeclasses.chatbots.TravelingNPC + """ + + def at_script_creation(self): + self.key = "Traveling" + self.desc = "NPCs that Move" + self.interval = self.obj.db.traveling_interval or 120 # seconds + self.start_delay = False + self.persistent = True + self.reload() + + def reload(self): + self.obj.db.last_event_time = time() + + def at_repeat(self, **kwargs): + """ + Do we move or stay for another iteration? + """ + # What can keep a traveling NPC from traveling? + # What if receiving ANY message resets a timer? + elapsed_time = time() - self.obj.db.last_event_time + # Time needs to be a little longer than the repeat interval. + if elapsed_time >= 20 * 60: + logger.info(f"TravelingNPC: Long: {elapsed_time} > {20 * 60}") + self.obj.goodbye() + self.obj.change_direction() + + elif elapsed_time >= 3 * 60: + logger.info(f"TravelingNPC: Short: {elapsed_time} > {3 * 60}") + self.obj.next_place() + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + if any(self.location.aliases.get(fav) + for fav in ["mp03", "mp10"]): + self.leave() + else: + # Let's go out for a bit ... + destination = choice([ + "Cozy House", + "Wyldwood Bar", + "mp15", # Shore + "Frog Meadow", + "Boulder Top", + "Lonely Island" + ]) + self.visit(destination) + + def next_place(self): + # In not in the Cozy House, Hut, or the Bar, leave: + if not any(self.location.aliases.get(fav) + for fav in ["mp03", "mp09", "mp10"]): + self.leave() + + def visit(self, location="Cozy House"): + self.execute_cmd("pose reset") + dest = self.search(location, global_search=True) + if not dest: + logger.warn(f"Witch attempting to go to unknown {location}.") + return + + if dest.key == "Cozy House": + visit_reason = "visit an old friend" + elif dest.key == "Wyldwood Bar": + visit_reason = "grab a pint" + else: + visit_reason = "forage for ingredients" + + hut = self.location + delay(1, hut.msg_contents, + f"The old lady says, \"I must be off now, dearie, to {visit_reason}.\"") + delay(5, hut.msg_contents, + "She grabs and old broom, and flies out the door!") + + marsh = self.search("mp08", global_search=True) + delay(5, marsh.msg_contents, + "An old lady flies out the hut on a broomstick!") + + if dest.key == "Cozy House": + landing = self.search("mp04", global_search=True) + knocker = self.search("knocker", global_search=True, + location=landing) + delay(14, knocker.do_knock, self) + delay(20, landing.msg_contents, + "\"I think I heard a 'come in',\" says the old lady. \"You heard it too, right?\"") + delay(24, self.move_to, dest) + delay(30, dest.msg_contents, + "\"Now this looks cheery,\" says the old lady, as she places her broom by the door. \"I do believe we are in for a spell of rain.\"") + + elif dest.key == "Wyldwood Bar": + landing = self.search("mp19", global_search=True) + delay(14, landing.execute_cmd, "say This young thing needs a drink") + delay(18, self.move_to, dest) + delay(30, dest.msg_contents, "The old lady saunters up to the bar, \"How about an ale, Barkeep?\" she asks.") + else: + landing = dest + if dest.key == "Shore": + ingredient = "yellow flowers from the pine tree" + elif dest.key == "Frog Meadow": + ingredient = "giggling tickleweed" + elif dest.key == "Boulder Top": + ingredient = "vibrant mushrooms" + elif dest.key == "Lonely Island": + ingredient = "blue moonberries" + delay(30, dest.msg_contents, + f"The lady looks around, gathers some {ingredient}" + + " and puts them in her basket.") + + delay(6, self.move_to, landing, quiet=True) + delay(7, landing.msg_contents, + "An old lady flies in on a broom.") + delay(10, landing.msg_contents, + f"The old lady says, \"Hello Dearie, I'm just here to {visit_reason}.\"") + + def leave(self): + """ + Dramatic departures from the current location. + """ + self.location.msg_contents("The old lady wraps her shawl tightly around her, and grabs her broom. \"I must be getting home now, dear,\" she says.") + + if "mp03" in self.location.aliases.get(): + visit_reason = "visiting my friend" + here = self.search("mp04", global_search=True) + delay(2, self.move_to, here) + if "mp10" in self.location.aliases.get(): + visit_reason = "visiting my friends at the bar" + here = self.search("mp19", global_search=True) + delay(2, self.move_to, here) + else: + visit_reason = "foraging" + here = self.location + + delay(4, here.msg_contents, + "The old lady hops on her broomstick and flies away through the trees.\"") + + marsh = self.search("mp08", global_search=True) + delay(5, marsh.msg_contents, + "An old lady flies through the air on her broomstick, and right into the hut on stilts!") + + hut = self.search("mp09", global_search=True) + delay(6, self.move_to, hut, quiet=True) + delay(7, hut.msg_contents, + f"An old lady flies through the door on her broomstick! \"Hello dearies!\" she exclaims to everyone and everything in her homey hut. \"I have returned from {visit_reason}. What have I missed while I was away?\"") + + sleep_pose = self.attributes.get("pose_sleep") + if sleep_pose: + self.execute_cmd(f"pose {sleep_pose}") + + +class Traveler(ChatBot): + """ + Needs to walk from room to room, and greets characters. + """ + traveling_path = {} + + def at_msg_receive(self, text=None, from_obj=None, **kwargs): + """ + Reset the timer whenever we get any event. + This might be too much. + """ + super().at_msg_receive(text, from_obj=from_obj, **kwargs) + self.db.last_event_time = time() + + def other_arrive(self, character): + """ + Greet a character when it arrives. + """ + self.greet(character) + + def at_post_move(self, past_location, move_type="move", **kwargs): + super().at_post_move(past_location, move_type) + chars = [c for c in self.characters_here() if c.key != 'tree'] + if len(chars) == 1: + self.greet(chars[0]) + else: + self.greet() + + def greet(self, character=None): + delay(2, self.do_cmd, "emote waves.") + + def goodbye(self, character=None): + delay(2, self.do_cmd, "emote waves good-bye.") + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + logger.info(f"The {self} needs to override change_direction") + + def come(self, direction="come"): + """ + Set traveling to a particular given direction. + """ + self.db.traveling_direction == direction + + def leave(self): + """ + Set traveling to a new direction. + Same as changing direction. + """ + self.change_direction() + + def next_place(self): + direction_label = self.db.traveling_direction + location_details = self.traveling_path.get(self.location.key) + if location_details: + logger.info(f"TravelingNPC: {direction_label} :: {location_details}") + room_name = location_details.get(direction_label) + if room_name: + logger.info(f"TravelingNPC: to -> {room_name}") + new_room = self.search(room_name, global_search=True) + if new_room: + logger.info(f"TravelingNPC: to -> {new_room}") + # Say See ya if it had engaged... + self.goodbye(new_room) + delay(5, self.move_to, new_room, move_type="teleport") + # This process will reset the timer + + +class Dragon(Traveler): + """ + Travels east-to-west along the path, and has a drink at the + Wyldwood Tavern. + """ + # self.db.direction = "east" # or west or whatever + + traveling_path = { + "the Deep Forest": { + "east": "Grotto", + "come": "Grotto" + }, + "Grotto": { + "east": "Grove of the Matriarchs", + "west": "the Deep Forest", + "come": "door" + }, + "Grove of the Matriarchs": { + "east": "Frog Meadow", + "west": "Grotto", + "come": "Grotto" + }, + "Frog Meadow": { + "east": "Glittering Glade", + "west": "Grove of the Matriarchs", + "come": "Grove of the Matriarchs" + }, + "Glittering Glade": { + "east": "Wyldwood Bar", + "west": "Frog Meadow", + "come": "Frog Meadow" + }, + "Wyldwood Bar": { + "west": "Glittering Glade", + "come": "Glittering Glade" + } + } + + def change_direction(self): + """ + Hard coded directional change, east <-> west + Note: Change with the command: + + @set npc/traveling_direction = "come" + """ + self.db.traveling_direction = "west" \ + if self.db.traveling_direction == "east" else "east" + + def at_pre_move(self, destination, move_type="move", **kwargs): + if self.location.key == "Wyldwood Bar": + self.location.msg_contents("The little dragon tips his wide-brimmed and says, \"A pleasure, but I must be off.\"") + return True + + def at_post_move(self, past_location, move_type="move", **kwargs): + # super().at_post_move(past_location, move_type) + if self.location.key == "Wyldwood Bar": + request = choice([ + "a Moonlit Mirage", + "Puck's Revenge", + "a Glimmering Gossamer", + "a Whimsical Willow", + "a Charmed Chalice", + "an Enchanted Elixir", + "a Sylvan Serenade", + "a Brambleberry Bliss", + "a Twilight Tonic", + ]) + self.process_thoughts(f"""The little dragon flutters over to the bar, studying the menu. "What am I in the mood for now?" he muses to himself, twiddling a tendril like a mustache with his little claw. + +"Elendil, dear," he says, "Would you make me {request}?" The little dragon then waves to the band on their mushroom stage.""") + bartender = self.search("bartender") + bartender.other_sayto(self, + f"\"Sir Roblees, the fairy dragon asks, \"Would you make me {request}?\"") + # from typeclasses.drinkables import Cocktail + # Cocktail.make(self, bartender, request) + + def greet(self, character=None): + logger.info(f"Dragon: greet {character}") + if character: + name = character.get_name() + cmd = choice([ + f"emote \"Hey, {name},\" /me says.", + f"emote \"Hey there, {name},\" /me says.", + f"emote \"Hello, {name},\" /me says. \"How are you?\"", + f"emote waves to {name}." + ]) + else: + cmd = choice([ + "say Look at all these luscious peoples.", + "emote waves to everyone.", + "emote waves to everybody." + ]) + delay(5, self.do_cmd, cmd) + + def goodbye(self, new_room=None): + self.do_cmd("drop drink") + system_prompt = self.setting_and_backstory() + messages = [{"role": "user", "content": "Say goodbye."}] + reply = self._think(system_prompt, messages) + self.process_thoughts(reply) + + +class TravelingNPC(Script): + """ + Script to move NPCs along a set path through "rooms". + + Start the script by running the following: + + @script npc = typeclasses.chatbots.TravelingNPC + """ + + def at_script_creation(self): + self.key = "Traveling" + self.desc = "NPCs that Move" + self.interval = self.obj.db.traveling_interval or 120 # seconds + self.start_delay = False + self.persistent = True + self.reload() + + def reload(self): + self.obj.db.last_event_time = time() + + def at_repeat(self, **kwargs): + """ + Do we move or stay for another iteration? + """ + # What can keep a traveling NPC from traveling? + # What if receiving ANY message resets a timer? + elapsed_time = time() - self.obj.db.last_event_time + # Time needs to be a little longer than the repeat interval. + if elapsed_time >= 20 * 60: + logger.info(f"TravelingNPC: Long: {elapsed_time} > {20 * 60}") + self.obj.goodbye() + self.obj.change_direction() + + elif elapsed_time >= 3 * 60: + logger.info(f"TravelingNPC: Short: {elapsed_time} > {3 * 60}") + self.obj.next_place() diff --git a/typeclasses/objects.py b/typeclasses/objects.py index bd844e4..14f26c6 100755 --- a/typeclasses/objects.py +++ b/typeclasses/objects.py @@ -9,10 +9,13 @@ with a location in the game world (like Characters, Rooms, Exits). """ +import anthropic +import json from datetime import datetime -from os import makedirs +from os import makedirs, path from os.path import dirname, exists -from re import split, match, sub, IGNORECASE +from pathlib import Path +from re import split, match, search, sub, IGNORECASE from shutil import copyfile from random import randint, choice @@ -28,6 +31,20 @@ from utils.scoring import Scores from utils.word_list import routput +personality_dir = "personalities" + + +def fix_paragraph(paragraph): + """ + Because the number of tokens is small, a response may end mid-sentence. + Seems like displaying everything but the last fragment is sufficient. + """ + sentences = split(r'(?<=[.!?])\s+', paragraph) + if not search(r"[.!?]\"?$", sentences[-1]): + sentences.pop() + return ' '.join(sentences) + + class ObjectParent: """ This is a mixin that can be used to override *all* entities inheriting at @@ -724,6 +741,127 @@ class Listener: self.msg(f"You can't give '{gift}' to {receiver.key}... {gifts.keys()}") return None +class AI: + """ + Mixin providing AI-powered (Claude/Anthropic) reasoning capabilities. + Classes using this mixin can think, process thoughts, and maintain + per-speaker conversation history backed by JSON files. + """ + + short_history = [] + + def pop_recent_events(self, speech): + if self.short_history: + if match(rf".*{speech}.*", self.short_history[-1]): + self.short_history.pop() + history = '\n\n'.join(self.short_history) + else: + history = "" + + # logger.info(f"{history}") + self.short_history = [] + return history + + def setting_and_backstory(self, speaker=None): + logger.info(f"Reading {self.db.personality_file}") + system_prompt = Path(self.db.personality_file).read_text() + system_prompt += "\n\n" + system_prompt += "You are currently in " + self.location.key + ". " + if self.location.key == "Cozy House": + system_prompt += "This is the dwelling of the gnome, Dabbler." + if self.location.key == "Homey Hut": + system_prompt += "This is the dwelling of the witch, Trampoli." + system_prompt += "Described as " + self.location.desc + if speaker: + system_prompt += "\n\n" + system_prompt += "You are talking to a " + system_prompt += speaker.db.gender + " " + speaker.sdesc.get() + ". " + system_prompt += "Described as " + speaker.db.desc + return system_prompt + + def history_file(self, speaker): + name = f"{speaker}".replace(" ", "-") + combo_name = f".{self.db.personality}-{name}.json".lower() + filename = path.join(personality_dir, combo_name) + logger.info(f"Chatbot history_file: {filename}") + return Path(filename) + + def history(self, speaker): + history_file = self.history_file(speaker) + return json.loads(history_file.read_text()) if history_file.exists() else [] + + def update_history(self, speaker, messages, reply): + history_file = self.history_file(speaker) + messages.append({"role": "assistant", "content": reply}) + history_file.write_text(json.dumps(messages, indent=2)) + + def _think(self, system_prompt, messages): + logger.info("Calling out to Anthropic...") + client = anthropic.Anthropic() + response = client.messages.create( + model="claude-haiku-4-5", + max_tokens=240, + system=system_prompt, + messages=messages, + ) + return response.content[0].text + + def think(self, speaker, speech): + """ + Ask Claude to think of a reply to speech from speaker. + Uses the 'system_prompt' from a personality file, + and 'messages' from the JSON history function, + appended with all 'events' recorded since last time. + """ + system_prompt = self.setting_and_backstory(speaker) + messages = self.history(speaker) + recent_events = self.pop_recent_events(speech) + if recent_events: + speech = f"{recent_events}\n\n{speaker.key}: {speech}" + messages.append({"role": "user", "content": speech}) + + # logger.info(f"Deep Thoughts: {system_prompt} / {messages}") + reply = self._think(system_prompt, messages) + + self.update_history(speaker, messages, reply) + return reply + + def process_thoughts(self, response): + paragraphs = response.split('\n\n') + + for idx, paragraph in enumerate(paragraphs): + m = match(r"^ *\| *(.*)", paragraph) + if m: + action = m.group(1) + logger.info(f"Doing: '{action}'") + delay(6 * idx, self.execute_cmd, action) + else: + logger.info(f"Saying: '{paragraph}'") + delay(6 * idx, + self.location.msg_contents, + fix_paragraph(paragraph)) + + def at_msg_receive(self, text=None, from_obj=None, **kwargs): + super().at_msg_receive(text, from_obj=from_obj, **kwargs) + logger.info(f"at_msg_receive: {text} :: {self.key}") + + if from_obj != self: + msg = text if isinstance(text, str) else text[0] + msg = sub(r'\|[a-zA-Z]', '', msg) if msg else msg + + if from_obj: + if hasattr(from_obj, 'sdesc') and from_obj.sdesc.get(): + name = from_obj.sdesc.get() + else: + name = from_obj.key + self.short_history.append(f"{name}: {msg}") + else: + self.short_history.append(msg) + + self.short_history = self.short_history[-10:] + return True + + class Recorder(Object): """Mixin for recording the events in the current room. @@ -747,7 +885,6 @@ class Recorder(Object): Maybe prepend a 'The' to the front. """ - logger.info(f"capitalize: {text} / {msg_type}") if msg_type and msg_type in ('traverse', 'teleport'): if match(r"^(\|[A-z])?[aeiou]", text): return "An " + text diff --git a/typeclasses/pets.py b/typeclasses/pets.py index 6b8962e..b88c738 100755 --- a/typeclasses/pets.py +++ b/typeclasses/pets.py @@ -18,7 +18,7 @@ from evennia.utils import logger, delay from evennia.utils.gametime import schedule from evennia.utils.search import search_object -from typeclasses.objects import Object, Listener +from typeclasses.objects import Object, Listener, AI from typeclasses.characters import Character from typeclasses.npcs import Familiar from commands.pets import CmdPetSet @@ -468,23 +468,51 @@ class Friendly(Pet): # The key attribute is the Listener mixin: -class WeeBeastie(Friendly, Familiar, Listener): +class WeeBeastie(Friendly, Familiar, Listener, AI): """ The stoat that lives in Dabbler's house, is a finicky eater. + + @set beastie/personality = "wee-beastie" + @set beastie/personality_file = "personalities/wee-beastie.md" """ def at_object_creation(self): "Called when this pet is first created." self.cmdset.add(CmdSetAntic, persistent=True) - def other_sayto(self, speaker, message): + def other_say(self, speaker, speech): + if len(self.characters_here()) == 1: + self.other_sayto(speaker, speech) + + def other_sayto(self, speaker, speech): "Override to return a string in response to message." - owner = self.search("Dabbler") - if owner: - delay(3, owner.announce_action, - f"$Your() {self.get_name()} purrs.") + if speech: + reply = self.think(speaker, speech) + logger.info(f"Reply: {reply}") + self.process_thoughts(reply) else: delay(3, self.execute_cmd, f"emote /me purrs.") + def other_arrive(self, character): + """ + Greet a character when it arrives. + """ + if character.key == "Dabbler": + delay(4, self.location.msg_contents, + choices(""" + The << wee ^ >> beastie << jumps ^ leaps ^ hops >> << up ^ >> on the back of << an overstuffed ^ a >> chair, and << bobs it head ^ purrs >>. ;; + A << white ^ >> ball of furr << jumps ^ leaps ^ hops >> down from a chair and climbs up the << old ^ >> gnome to get close<< to his face ^ >>.""")) + else: + delay(4, self.location.msg_contents, + choices(f""" + A << furry ^ >> white << animal ^ creature ^ beastie >> asleep on one of the chairs, << lifts an eyelid to look at the {character.get_name()} before returning to its nap ^ wakes to watch the {character.get_name()}.""")) + + def other_sit(self, character): + if character.key == "Dabbler": + delay(4, self.location.msg_contents, + choices(f""" + The << wee ^ >> beastie << jumps ^ leaps ^ hops >> << up ^ >> into the lap of the {character.get_name()}, and << falls asleep ^ snuggles into a ball ^ purrs >>. ;; + The << wee ^ >> beastie pulls up a << brown ^ >> blanket over {character.get_name()}'s lap.""")) + def feed(self, feeder, item=None): """ Feeding the beast. If item is None, we choose something diff --git a/typeclasses/rooms.py b/typeclasses/rooms.py index d4bdc48..d850ea0 100644 --- a/typeclasses/rooms.py +++ b/typeclasses/rooms.py @@ -74,6 +74,8 @@ class Room(ObjectParent, ExtendedRoom, ContribRPRoom, Listener): """ is_dark = False has_weather = False + # Used to keep track of the most recent events: + short_history = [] appearance_template = """ {header} @@ -94,11 +96,20 @@ class Room(ObjectParent, ExtendedRoom, ContribRPRoom, Listener): return (datestamp.minute, datestamp.hour, self.get_time_of_day(), self.get_season()) + def get_full_appearance(self, looker, **kwargs): + """ + Return a string containing the full appearance of the + room, but also the most recent events that have happened. + """ + logger.info(f"HISTORY: {self.short_history}") + return self.return_appearance(looker, **kwargs) + "\n\n" + \ + "\n".join(self.short_history) + def get_display_header(self, looker, **kwargs): """ Possibly send an image to webclients. """ - image = self.db.title_image + image = None # self.db.title_image if image: timed = self.attributes.get("title_time", None) if timed: @@ -186,10 +197,23 @@ class Room(ObjectParent, ExtendedRoom, ContribRPRoom, Listener): def msg_contents(self, text, exclude=None, from_obj=None, mapping=None, raise_funcparse_errors=False, **kwargs): - text = fix_msg(text) + self.store_event(fix_msg(text)) super().msg_contents(text, exclude, from_obj, mapping, raise_funcparse_errors) + def store_event(self, text, speaker=None): + """ + Store a text message in the short_history. + """ + if text and isinstance(text, tuple): + text = text[0] + + if speaker: + text = text.replace("/me", speaker.get_name()) + + self.short_history.append(text.replace(" /", " ")) + self.short_history = self.short_history[-10:] + class DabblersRoom(Room): """ diff --git a/typeclasses/sittables.py b/typeclasses/sittables.py index bc5b244..10d36b4 100755 --- a/typeclasses/sittables.py +++ b/typeclasses/sittables.py @@ -17,7 +17,10 @@ class Sittable(Object): article = self.db.article or "the" extra = self.db.extra or "" - return routput(f"You sit {adjective} {article} {self.key}. {extra}") + if self.multiple: + return routput(f"You sit {adjective} one of {article} {self.key}. {extra}") + else: + return routput(f"You sit {adjective} {article} {self.key}. {extra}") def stand_msg(self): return f"You stand up from {self.key}." @@ -52,10 +55,13 @@ class Sittable(Object): self.db.sitter = sitter sitter.db.is_sitting = self sitter.score(Scores.moss_sit) - sitter.announce_action(f"$You() $conj(sit) {adjective} {article} {self.key}.") + if self.multiple: + sitter.announce_action(f"$You() $conj(sit) {adjective} one of {article} {self.key}.") + else: + sitter.announce_action(f"$You() $conj(sit) {adjective} {article} {self.key}.") sitter.location.other_sit(sitter) - for puppet in sitter.puppets_here(): + for puppet in sitter.puppets_here("other_sit"): puppet.other_sit(sitter) def do_stand(self, stander): diff --git a/typeclasses/things.py b/typeclasses/things.py index a3ba961..9dfac3f 100755 --- a/typeclasses/things.py +++ b/typeclasses/things.py @@ -21,7 +21,7 @@ from commands.misc import (CmdSetPuddle, CmdSetPress, CmdSetTransform, CmdSetPeer) from commands.consumables import CmdSetMakeConsumable -from commands.wizards import CmdSetWand +from commands.wizards import CmdSetWand, CmdSetScry from utils.word_list import routput, choices, paragraph from utils.scoring import Scores from typeclasses.consumables import Litterable @@ -366,6 +366,23 @@ class Wand(Stick): self.cmdset.add_default(CmdSetWand) +class Scryer(Object): + def at_object_creation(self): + self.cmdset.add_default(CmdSetScry) + + def do_show_room(self, scryer, room_name): + import typeclasses.rooms + room = scryer.search(room_name, global_search=True) + if room and isinstance(room, typeclasses.rooms.Room): + desc = room.get_full_appearance(scryer).strip() + others = scryer.characters_here() + scryer.location.msg_contents( + "You gaze into the crystal ball to see the " + desc, + exclude=others) + else: + scryer.msg(f"You can't scry: {room}") + + class Puddle(Object): def at_object_creation(self): self.cmdset.add_default(CmdSetPuddle)