625 lines
22 KiB
Python
625 lines
22 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 not self.location.is_typeclass(Character):
|
|
self.location.other_leave(self)
|
|
for puppet in self.puppets_here():
|
|
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():
|
|
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):
|
|
"""
|
|
Return a list of puppets in the current location.
|
|
Only used for calling hooks on the animatronic dolls.
|
|
"""
|
|
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
|