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.
633 lines
23 KiB
Python
633 lines
23 KiB
Python
"""
|
|
Characters
|
|
|
|
Characters are (by default) Objects setup to be puppeted by Accounts.
|
|
They are what you "see" in game. The Character class in this module
|
|
is setup to be the "default" character type created by the default
|
|
creation commands.
|
|
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from re import match, compile, sub
|
|
|
|
import requests
|
|
|
|
from evennia.commands.command import InterruptCommand
|
|
from evennia.contrib.game_systems.gendersub import GenderCharacter
|
|
from evennia.contrib.rpg.rpsystem import ContribRPCharacter, send_emote
|
|
from evennia.prototypes.spawner import spawn
|
|
from evennia.utils import delay, logger, int2str
|
|
from evennia.utils.search import (search_object, search_account,
|
|
search_objects_by_typeclass,
|
|
search_channel)
|
|
|
|
from utils.user_info import location
|
|
from utils.word_list import routput, choices, fix_msg
|
|
from typeclasses.objects import Object, ObjectParent
|
|
from typeclasses.tutorial import TutorBird, TutorialState
|
|
|
|
|
|
INTRO = """
|
|
. o O o .
|
|
|
|
As the surrounding mists dissipate, you find yourself in an ancient, halcyon forest dripping with moss. You see an envelope of parchment wedged under a scaly protrusion of bark...inside, a letter in familiar penmanship, personally addressed to you, which you pick up. A little gray bird flies by... wait, was it sporting a top hat!?"""
|
|
|
|
READ_LETTER = """You read a letter with an oddly familiar penmanship:
|
|
|
|
My dear {0},
|
|
|
|
If you are reading this, you've found the world I was overly excited in relaying to you over drinks in Marsivan. Most excellent. Enjoy this halcyon world, unspoiled and idyllic.
|
|
|
|
I would suggest seeing/doing these:
|
|
|
|
- Jump in a puddle
|
|
- Sit on some moss
|
|
- Feed the beast until it is friendly
|
|
- Catch an obnoxious fish
|
|
- Get and read a book
|
|
- Have some tea and scones
|
|
|
|
Oh, I also suggest checking out |wWyldwood|n, a fabulous bar that doesn't open very often, but is quite fun when it does.
|
|
|
|
I'm here, so join me in a cup of tea and we can reconnect and reminisce of glorious days gone by, and the utter curiosity that surrounds us.
|
|
|
|
Your friend,
|
|
Dabbler
|
|
|
|
(Type 'help start' for details on playing this game)"""
|
|
|
|
|
|
class Character(Object, GenderCharacter, ContribRPCharacter):
|
|
"""
|
|
The Character just re-implements some of the Object's methods and hooks
|
|
to represent a Character entity in-game.
|
|
|
|
See mygame/typeclasses/objects.py for a list of
|
|
properties and methods available on all Object child classes like this.
|
|
"""
|
|
pose = True
|
|
|
|
def at_object_creation(self):
|
|
"called when a character is first created."
|
|
self.db.tutorstate = 0
|
|
self.tags.add('beginner')
|
|
|
|
if self.dbref != "#1" and not self.is_typeclass('typeclasses.puppets.Puppet'):
|
|
self.create_letter()
|
|
self.create_ticket()
|
|
self.create_pouch()
|
|
|
|
def is_utf(self):
|
|
"""
|
|
Return True if character's user encoding is UTF-8.
|
|
"""
|
|
session = self.sessions.get()[0]
|
|
if session:
|
|
flags = session.protocol_flags
|
|
return flags.get("ENCODING") == 'utf-8'
|
|
return False
|
|
|
|
def is_webclient(self):
|
|
"""
|
|
Return True if using the 'web' protocol.
|
|
"""
|
|
session = self.sessions.get()[0]
|
|
if session:
|
|
prot = session.protocol_key
|
|
return prot == 'websocket'
|
|
return False
|
|
|
|
def delete_inv(self, typeclass):
|
|
"""
|
|
Delete items from a character's inventory of typeclass.
|
|
"""
|
|
for obj in self.contents:
|
|
if obj.is_typeclass(typeclass):
|
|
obj.delete()
|
|
|
|
def score(self, tick):
|
|
"""
|
|
Convenience method for keeping track of activities.
|
|
"""
|
|
score = self.attributes.get('score', 0)
|
|
score |= tick.value
|
|
self.attributes.add('score', score)
|
|
|
|
def new_account_setup(self):
|
|
"""
|
|
New accounts should connect the tutorial.
|
|
"""
|
|
self.fix_letter()
|
|
self.db.visited = True
|
|
self.db.tutorstate = 0
|
|
TutorBird.do_start_tutorial(self)
|
|
self.msg(INTRO)
|
|
|
|
def guest_account_setup(self):
|
|
"""
|
|
Cleanup the guest account to start fresh for a new player.
|
|
What if there is a bug and they are able to "get" something
|
|
important, instead of berries?
|
|
"""
|
|
# Remove all the tags, which resets all previously seen objects:
|
|
for tag in self.tags.all():
|
|
self.tags.remove(tag)
|
|
|
|
# Remove everything in their inventory...excluding the letter:
|
|
for obj in self.contents:
|
|
if obj.name != "letter":
|
|
logger.warning(f"Guest account: deleting {obj.name}")
|
|
obj.delete()
|
|
|
|
# Move them to the Grove to begin their adventure:
|
|
self.location = search_object("mp01").first()
|
|
|
|
# Reset the "running state" that character may have
|
|
# experienced. This way, new guests get to _re-experience_ it:
|
|
|
|
self.db.score = 0
|
|
self.db.visited = False
|
|
self.db.jumped_times = 0
|
|
self.db.tutorstate = 0
|
|
self.db.thrown_times = 0
|
|
self.db.knocker_conversation_state = None
|
|
self.ndb.cozy_house_number_of_bookshelves = 0
|
|
self.db.received_pipe = False
|
|
self.ndb.assortment_of_jars_view_index = 0
|
|
self.ndb.shelf_full_of_jars_view_index = 0
|
|
self.db.wee_beastie_friendly_level = 0
|
|
self.db.big_hairy_beast_friendly_level = 0
|
|
self.tags.add('beginner')
|
|
|
|
# Finally, star the tutorial:
|
|
TutorBird.do_start_tutorial(self)
|
|
self.msg(INTRO)
|
|
|
|
def after_bar_setup(self):
|
|
"""
|
|
Users should not return to the bar.
|
|
|
|
And let's get rid of their cocktail glasses, too.
|
|
"""
|
|
self.msg("You wake up in a meadow with a strange dream of a bar...")
|
|
self.delete_inv("typeclasses.drinkables.Cocktail")
|
|
self.move_to(search_object("mp05").first(), quiet=True, use_destination=True)
|
|
|
|
def at_post_puppet(self, **kwargs):
|
|
"""
|
|
Setup the character based for _this world_.
|
|
|
|
New accounts, guest accounts, and those that woke up
|
|
with a hangover in the bar, need consideration.
|
|
"""
|
|
account = self.account
|
|
player = account.key
|
|
|
|
# Temporary solution to retrofit the fact that new characters
|
|
# should have this attribute...
|
|
if not self.tags.has('alchemist'):
|
|
self.tags.add('beginner')
|
|
|
|
if player not in ("rob", "george", "darol", "rick", "howard"):
|
|
# Does everyone need to know about people logging in?
|
|
# pub = search_channel("Public").first()
|
|
# if pub:
|
|
# pub.msg(msg)
|
|
|
|
name = self.name
|
|
desc = self.db._sdesc
|
|
(city, region, country, _) = location(self)
|
|
if country:
|
|
where = f"(from {city}, {region}, {country})"
|
|
else:
|
|
where = "(from localhost)"
|
|
msg = f"{name}, the {desc}, played by `{player}`, connected {where}."
|
|
|
|
logger.info(msg)
|
|
# Send an notification about the login:
|
|
requests.post("https://ntfy.sh/moss-n-puddles-user-login", timeout=2,
|
|
data=msg.encode(encoding='utf-8'))
|
|
|
|
if self.db.guest_account:
|
|
self.guest_account_setup()
|
|
elif not self.db.visited:
|
|
self.new_account_setup()
|
|
elif self.location.key == "Wyldwood Bar":
|
|
self.after_bar_setup()
|
|
else:
|
|
self.msg(f"""\n“Welcome back, {self.key.capitalize()}.”\n""")
|
|
self.execute_cmd("look")
|
|
|
|
self.account.db._last_puppet = self
|
|
|
|
def at_pre_unpuppet(self, **kwargs):
|
|
"""
|
|
Make sure we aren't left sitting down when logging out.
|
|
"""
|
|
if self.db.is_sitting:
|
|
chair = self.db.is_sitting
|
|
chair.db.sitter = None
|
|
self.db.is_sitting = None
|
|
|
|
def msg(self, text=None, from_obj=None, session=None, **kwargs):
|
|
"""
|
|
Capitalizes messages sent to the user.
|
|
This just looks better to me.
|
|
"""
|
|
text = fix_msg(text)
|
|
|
|
if hasattr(self, "sessions"):
|
|
sessions = self.sessions.get()
|
|
if sessions and len(sessions) > 0:
|
|
protocol = sessions[0].protocol_key
|
|
if protocol == "ssh" or protocol == "telnet":
|
|
if text and isinstance(text, str) and len(text) > 0:
|
|
text += "\n"
|
|
elif text and isinstance(text, tuple):
|
|
text = (text[0] + "\n", text[1])
|
|
|
|
super().msg(text, from_obj=from_obj, session=session, **kwargs)
|
|
|
|
def create_pouch(self, name="pouch", desc="leather pouch", giver=None):
|
|
"""
|
|
Create a leather pouch of coins.
|
|
|
|
Fill it with ten gold coins, so they can play the games.
|
|
"""
|
|
pouch = spawn({
|
|
"typeclass": "typeclasses.things.CoinPurse",
|
|
"key": name,
|
|
"desc": f"A {desc} containing coins.",
|
|
})[0]
|
|
pouch.db.gold_amount = 10
|
|
pouch.location = self
|
|
|
|
if giver:
|
|
self.announce_action(f"The {giver.get_display_name(self)} tosses a {desc} to $you().")
|
|
self.msg(f"You now have a {name} with {int2str(pouch.db.gold_amount)} coins.")
|
|
|
|
def get_pouch(self):
|
|
"""
|
|
Return a pouch object.
|
|
|
|
Throws InterruptCommand exception if the character has
|
|
no pouch, and therefore, no money.
|
|
"""
|
|
pouches = [item
|
|
for item in self.contents
|
|
if item.is_typeclass("typeclasses.things.CoinPurse")]
|
|
if pouches:
|
|
return pouches[0]
|
|
raise InterruptCommand("No coin purse")
|
|
|
|
def how_many_coins(self):
|
|
"Return the amount of money in the coin purse."
|
|
return self.get_pouch().how_much()
|
|
|
|
def adjust_coins(self, amount):
|
|
"Increase or decrease the amount of money in the coin purse."
|
|
return self.get_pouch().adjust_amount(amount)
|
|
|
|
def has_coins(self, at_least=1):
|
|
"Return True if character has 'at_least' that many coins."
|
|
return self.get_pouch().has_amount(at_least)
|
|
|
|
def create_letter(self):
|
|
"create a welcome letter in a character's inventory"
|
|
letter = spawn({
|
|
"typeclass": "typeclasses.readables.Letter",
|
|
"key": "letter",
|
|
"desc": "An envelope with a letter of familiar penmanship.",
|
|
})[0]
|
|
letter.db.inside = READ_LETTER.format(self.name.capitalize())
|
|
letter.location = self
|
|
|
|
def fix_letter(self):
|
|
"""
|
|
Adjust the letter initially created for the character.
|
|
|
|
This replaces the weird, auto-generated name with the
|
|
character's actual name.
|
|
"""
|
|
letter = self.search('letter', location=self, quiet=True)
|
|
if letter:
|
|
letter = letter[0]
|
|
|
|
if self.db.guest_account:
|
|
text = sub(r"My dear[^,]*", "My dearest friend",
|
|
letter.db.inside)
|
|
else:
|
|
text = sub(r"My dear[^,]*", f"My dear {self.name}",
|
|
letter.db.inside)
|
|
letter.db.inside = text
|
|
|
|
def create_ticket(self):
|
|
"""
|
|
Create a dated ticket.
|
|
"""
|
|
today = datetime.now()
|
|
days_ahead = (1 - today.weekday() + 7) % 7 # 1 is Tuesday
|
|
next_tuesday_date = today + timedelta(days=days_ahead)
|
|
tuesday = next_tuesday_date.date()
|
|
|
|
day = tuesday.day
|
|
month = tuesday.strftime("%B") # Full month name
|
|
if 10 <= day % 100 <= 20:
|
|
suffix = 'th'
|
|
else:
|
|
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
|
|
|
|
ticket = spawn({
|
|
"typeclass": "typeclasses.readables.Letter",
|
|
"key": "ticket",
|
|
"desc": "A ticket to |wWyldwood Bar|n.",
|
|
})[0]
|
|
ticket.db.inside = f"""
|
|
The ticket is dated, {day}{suffix} of {month} ...
|
|
It also reads that the portal opens in the field at seven.
|
|
"""
|
|
ticket.location = self
|
|
|
|
def get_display_things(self, looker, *args, **kwargs):
|
|
return super().get_display_things(looker, pose=False)
|
|
|
|
def do_take(self, to_take, victim):
|
|
"""
|
|
A character has a _steal_command. What are the limitations?
|
|
"""
|
|
if victim:
|
|
thing = None
|
|
if hasattr(victim, 'has') and callable(getattr(victim, 'has')):
|
|
thing = victim.has(to_take)
|
|
if thing and thing.db.can_take:
|
|
self.msg(f"You take {thing.key} from {victim.key}.")
|
|
thing.move_to(self, quiet=True, use_destination=True)
|
|
return
|
|
self.msg(f"{victim.key} doesn't have a {to_take} you can take.")
|
|
|
|
def pronoun_subjective(self, uppercase=False):
|
|
gender = self.attributes.get('gender')
|
|
if gender == "male":
|
|
results = 'He'
|
|
elif gender == "female":
|
|
results = 'She'
|
|
elif gender == "neutral":
|
|
results = 'It'
|
|
else:
|
|
results = 'They'
|
|
|
|
return results if uppercase else results.lower()
|
|
|
|
def gendered_text(self, text):
|
|
"""
|
|
Replace entries, like |s and |p with pronouns.
|
|
Like 'he' and 'her'
|
|
"""
|
|
gender_rx = compile(r"(?<!\|)\|(?!\|)[sSoOpPaA]")
|
|
return gender_rx.sub(lambda x: self._get_pronoun(x), text)
|
|
|
|
def return_appearance(self, looker, **kwargs):
|
|
"""
|
|
Can be overridden or appended with an effect.
|
|
Does the looker affect this?
|
|
"""
|
|
if self.db.transformed:
|
|
return self.db.desc
|
|
|
|
# To replace, temporarily, a description:
|
|
# @set woman/temp_desc:effect = "They fade away from view."
|
|
new_desc = self.attributes.get(category="effect",
|
|
key="temp_desc")
|
|
if new_desc:
|
|
return self.gendered_text(new_desc)
|
|
|
|
pre_desc = self.attributes.get(category="effect",
|
|
key="pre_desc") or ""
|
|
post_desc = self.attributes.get(category="effect",
|
|
key="post_desc") or ""
|
|
reg_desc = super().return_appearance(looker)
|
|
|
|
# pronoun = self.pronoun_subjective(True)
|
|
return self.gendered_text(pre_desc +
|
|
reg_desc.replace('|wYou see:|n',
|
|
'|S has') +
|
|
post_desc)
|
|
|
|
def at_pre_move(self, destination, *args, **kwargs):
|
|
"""
|
|
Called by self.move_to when trying to move somewhere. If this returns
|
|
False, the move is immediately canceled.
|
|
"""
|
|
self.db.tutorstate = (self.db.tutorstate or 0) | TutorialState.MOVE.value
|
|
|
|
if self.db.is_sitting:
|
|
self.msg("You stand up first...")
|
|
chair = self.db.is_sitting
|
|
chair.db.sitter = None
|
|
self.db.is_sitting = False
|
|
|
|
# @lock thing = tethered:id(#19)
|
|
# @set thing/tethered_msg = "Let's put that back"
|
|
for thing in self.contents:
|
|
to = thing.locks.get('tethered')
|
|
if to:
|
|
m = match(r".*:id\((.*)\)", to)
|
|
if m:
|
|
location = m.group(1)
|
|
dest = self.search(location)
|
|
msg = thing.db.tethered_msg
|
|
if dest and msg:
|
|
thing.location = dest
|
|
self.msg(msg)
|
|
else:
|
|
logger.warn(f"Found tethered, {thing} to #{location}, but dest is {dest} and msg is '{msg}'. Set both.")
|
|
else:
|
|
logger.warn(f"Found tethered, {thing} to #{to}, but that needs to be 'tethered:id(num) where num is the ID# of an object/place.")
|
|
|
|
# Tell the room and any puppets here that we are leaving:
|
|
if self.location.is_typeclass("typeclasses.rooms.Room"):
|
|
self.location.other_leave(self)
|
|
|
|
for puppet in self.puppets_here():
|
|
if puppet != self:
|
|
puppet.other_leave(self)
|
|
|
|
return super().at_pre_move(destination)
|
|
|
|
def at_post_move(self, past_location, move_type="move", **kwargs):
|
|
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("other_arrive"):
|
|
puppet.other_arrive(self)
|
|
|
|
def at_pre_say(self, message, **kwargs):
|
|
"While we could/should do 'at_say', this should be easier."
|
|
self.db.tutorstate = (self.db.tutorstate or 0) | TutorialState.SAY.value
|
|
return super().at_pre_say(message)
|
|
|
|
def at_look(self, target, **kwargs):
|
|
"""
|
|
When we look at something that _might_ be hidden, we check
|
|
if we are looking at something that _is_ hidden, i.e. has a
|
|
view lock of a particular tag, and if so, we interpret this as
|
|
'you are looking at something by name', therefore you see it.
|
|
So we give ourselves the right to see it from now on.
|
|
|
|
To use this, simply add the following lock:
|
|
|
|
@set thing/hidden_tag = target
|
|
@lock thing = view:tag(hidden_target)
|
|
|
|
Where thing is the single name of the hidden object.
|
|
And 'target' is some label. If not given, this defaults
|
|
to the name of the thing.
|
|
"""
|
|
|
|
# If they 'look' at a target with tags, they get those locks:
|
|
# For instance: @set waterfall/hidden_tag = "hidden_cave"
|
|
if target.db.hidden_tag:
|
|
for hidden_tag in target.db.hidden_tag.split(';'):
|
|
self.tags.add(hidden_tag)
|
|
|
|
if target.is_typeclass("typeclasses.rooms.Room"):
|
|
self.db.tutorstate = (self.db.tutorstate or 0) | TutorialState.LOOK.value
|
|
else:
|
|
self.db.tutorstate = (self.db.tutorstate or 0) | TutorialState.LOOKAT.value
|
|
|
|
# Regardless of what happened before, we return the normal
|
|
# function call.
|
|
return super().at_look(target)
|
|
|
|
def announce_action(self, message, exclude=None):
|
|
"""
|
|
Replaces a location's 'msg_contents' with an emote.
|
|
"""
|
|
# All this does is add the character's gender to the message.
|
|
# So $pron(you,op) will render "you" or "him"
|
|
# and $pron(you,sp) will render "you" or "he"
|
|
newmsg = sub(r"\$pron\(([^\)]*?),([^\)]*?)\)",
|
|
f"$pron(\\1,\\2 {self.db.gender})",
|
|
message)
|
|
|
|
# While $pron(you) will render "you" and "he":
|
|
newmsg = sub(r"\$pron\(([^,\)]*?)\)",
|
|
f"$pron(\\1, {self.db.gender})",
|
|
newmsg)
|
|
|
|
choose = choices(newmsg)
|
|
logger.info(choose)
|
|
self.location.msg_contents(f"{choose}", from_obj=self, exclude=exclude)
|
|
|
|
def spell_sequence(self, location, messages, time_delay=1):
|
|
"""
|
|
Send one or more messages to 'location' with a delay.
|
|
|
|
If the 'location' is None, then send it to the room the
|
|
character is in.
|
|
"""
|
|
for idx, msg in enumerate(messages):
|
|
if location:
|
|
delay(time_delay * idx, location.msg_contents, routput(msg))
|
|
else:
|
|
delay(time_delay * idx, self.announce_action, msg)
|
|
|
|
def do_fly(self, location):
|
|
"""
|
|
Allow a wizard to arrive thematically into a new location.
|
|
"""
|
|
if location == "home":
|
|
dest = self.home
|
|
else:
|
|
if location.startswith("*"):
|
|
acc = search_account(location[1:]).first()
|
|
if acc:
|
|
pups = acc.get_all_puppets()
|
|
dest = pups[0].location
|
|
else:
|
|
dest = search_object(location).first()
|
|
|
|
if not dest:
|
|
self.msg(f"Not sure where, '{location}' is.")
|
|
return
|
|
|
|
if dest.is_typeclass("typeclasses.characters.Character"):
|
|
dest = dest.location
|
|
elif dest.is_typeclass("typeclasses.accounts.Account"):
|
|
self.msg(f"Looking for account, eh?")
|
|
return
|
|
elif not dest.is_typeclass("typeclasses.rooms.Room"):
|
|
self.msg(f"Not sure where, '{location}' is.")
|
|
return
|
|
|
|
if self.db.disappear_msg:
|
|
self.spell_sequence(self.location,
|
|
self.db.disappear_msg.split(';;'),
|
|
self.db.appear_delay or 2)
|
|
self.move_to(dest, move_type="magic", quiet=True)
|
|
|
|
if self.db.reappear_msg:
|
|
self.spell_sequence(self.location,
|
|
self.db.reappear_msg.split(';;'),
|
|
self.db.appear_delay or 2)
|
|
|
|
def transmogrify(self, name, description):
|
|
"""
|
|
Convert the appearance of this character into an object.
|
|
"""
|
|
self.db.orig_name = self.name
|
|
self.db.orig_desc = self.db.desc
|
|
self.db.orig_sdesc = self.sdesc.get()
|
|
|
|
self.announce_action(f"Octarine sparks fly, as $you() $conj(transform) into a {name}.")
|
|
|
|
self.db.transformed = True
|
|
self.name = name
|
|
self.sdesc.add(name)
|
|
self.db.desc = description
|
|
|
|
def untransmogrify(self):
|
|
"""
|
|
Undo the conversion of this character from an object.
|
|
"""
|
|
self.name = self.db.orig_name
|
|
self.sdesc.add(self.db.orig_sdesc)
|
|
self.db.desc = self.db.orig_desc
|
|
self.db.transformed = False
|
|
|
|
self.announce_action(f"Octarine sparks glint, as $you() $conj(transform) back into $pron(your) old form.")
|
|
|
|
# Hooks to the puppets and storycubes:
|
|
|
|
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")
|
|
or puppet.is_typeclass("typeclasses.puzzles.StoryCube")
|
|
or puppet.is_typeclass("typeclasses.pets.FriendlyPet")
|
|
]
|
|
|
|
def deeper_search(self, item_str):
|
|
targets = self.search(item_str, quiet=True)
|
|
if len(targets) > 0:
|
|
return targets
|
|
|
|
for target in self.location.contents:
|
|
logger.info(f"Looking for books at {target}")
|
|
inv_items = self.search(item_str, quiet=True,
|
|
location=target)
|
|
if len(inv_items) > 0:
|
|
return inv_items
|
|
return None
|