Animate the Wee Beastie and scrying into rooms

This commit gives an "AI" capability to any object (still
triggered by the 'say' command), so now the Wee Beastie can do more
than purr.

Also fixes the Witch and the Dragon's movements throughout the Realm.

We can also Scry into rooms in order to watch the behavior of the Chatbots.
This commit is contained in:
Howard Abrams 2026-04-16 17:09:15 -07:00
parent c9b602e0ab
commit 66cabcc1a2
11 changed files with 1013 additions and 170 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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:

View file

@ -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")

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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):
"""

View file

@ -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):

View file

@ -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)