Hogwarts_SBox_AI-RPG / systems /spell_system.py
cereeenn120's picture
Add spell system feature phase two
24e80a3
Raw
History Blame Contribute Delete
7.26 kB
import re
from game.constants import SPELL_INFO
# ----------------------------
# SPELL & LEVEL SYSTEM
# Mirrors relationship_system.py's pattern: trait modifiers apply only to
# positive progress; backfires/failures are never scaled down further.
# ----------------------------
SPELL_LEVELS = ["Novice", "Intermediate", "Advanced", "Master"]
PROGRESS_TO_ADVANCE = 100 # flat threshold per level
LEVEL_SUCCESS_RATES = {
"Novice": 70, "Intermediate": 85, "Advanced": 95, "Master": 100,
}
# Base progress granted per successful practice, by source.
SOURCE_BASE_PROGRESS = {
"class": 15,
"book": 10,
"npc": 20,
"quest": 40, # future quest_system.py hook calls apply_spell_progress with this
}
# Multiplier applied to the base progress, by outcome.
OUTCOME_MULTIPLIER = {
"success": 1.0,
"partial": 0.5,
"backfire": 0.0,
}
# Positive-only trait modifiers (same convention as relationship_system.py).
SPELL_TRAIT_MODIFIERS = {
"Bookworm": 1.5,
"Ambitious": 1.15,
"Daydreamer": 0.7,
"Lazy": 0.6,
}
def _clamp(value, low=0, high=100):
return max(low, min(high, value))
def _spell_slug(name: str) -> str:
"""'Expecto Patronum' -> 'expecto_patronum'"""
return re.sub(r'[^a-z0-9]+', '_', name.strip().lower()).strip('_')
def _trait_modifier(player_traits: list) -> float:
modifier = 1.0
for t in player_traits:
if t in SPELL_TRAIT_MODIFIERS:
modifier *= SPELL_TRAIT_MODIFIERS[t]
return modifier
def init_spell(state, spell_slug, spell_name, source="class"):
"""Creates a known_spells entry on first practice. No-op if it already exists."""
state.setdefault("known_spells", {})
if spell_slug not in state["known_spells"]:
state["known_spells"][spell_slug] = {
"name": spell_name,
"level": "Novice",
"progress": 0,
"learned_via": source,
"master_unlocked": False,
}
return state["known_spells"][spell_slug]
def apply_spell_progress(state, spell_name: str, source: str, outcome: str):
"""
Main entry point — called from turn_engine.py today, and intended to be
called from a future quest_system.py the same way (source="quest").
spell_name : display name from LLM SCENE_DATA (or quest reward config)
source : "class" | "book" | "npc" | "quest"
outcome : "success" | "partial" | "backfire"
Returns the updated spell entry dict, or None if inputs are invalid.
"""
if not spell_name or source not in SOURCE_BASE_PROGRESS or outcome not in OUTCOME_MULTIPLIER:
print(f"--- DEBUG ERROR [SPELL SYSTEM]: Invalid input | spell='{spell_name}' source='{source}' outcome='{outcome}'")
return None
spell_slug = _spell_slug(spell_name)
entry = init_spell(state, spell_slug, spell_name, source)
# Already maxed at Advanced and Master isn't unlocked -> no further progress possible
if entry["level"] == "Advanced" and not entry["master_unlocked"]:
print(f"--- DEBUG INFO [SPELL SYSTEM]: '{spell_name}' capped at Advanced (Master locked).")
return entry
if entry["level"] == "Master":
print(f"--- DEBUG INFO [SPELL SYSTEM]: '{spell_name}' already at Master.")
return entry
base = SOURCE_BASE_PROGRESS[source]
outcome_mult = OUTCOME_MULTIPLIER[outcome]
trait_mult = _trait_modifier(state.get("traits", []))
# Backfire (outcome_mult == 0) always lands at 0 gain regardless of traits —
# matches the "negative shifts aren't discounted" convention elsewhere.
gain = round(base * outcome_mult * trait_mult) if outcome_mult > 0 else 0
entry["progress"] = _clamp(entry["progress"] + gain)
leveled_up = False
if entry["progress"] >= PROGRESS_TO_ADVANCE:
current_idx = SPELL_LEVELS.index(entry["level"])
# Don't auto-advance past Advanced into Master — that's a separate unlock.
if entry["level"] != "Advanced":
entry["level"] = SPELL_LEVELS[current_idx + 1]
entry["progress"] = 0
leveled_up = True
else:
entry["progress"] = PROGRESS_TO_ADVANCE # sit at cap until unlocked
print(
f"--- DEBUG INFO [SPELL SYSTEM]: '{spell_name}' | source={source} outcome={outcome} | "
f"+{gain} progress -> {entry['level']} ({entry['progress']}/100)"
+ (" | LEVEL UP!" if leveled_up else "")
)
return entry
def unlock_master(state, spell_name: str):
"""
Explicit unlock hook — intended to be called when the 'Spell Master' aspiration
completes, or from a future quest reward. Requires the spell to already be Advanced.
"""
spell_slug = _spell_slug(spell_name)
entry = state.get("known_spells", {}).get(spell_slug)
if not entry:
print(f"--- DEBUG ERROR [SPELL SYSTEM]: Cannot unlock Master for unknown spell '{spell_name}'")
return None
if entry["level"] != "Advanced":
print(f"--- DEBUG INFO [SPELL SYSTEM]: '{spell_name}' must be Advanced before Master unlock (currently {entry['level']}).")
return entry
entry["master_unlocked"] = True
entry["level"] = "Master"
entry["progress"] = 0
print(f"--- DEBUG INFO [SPELL SYSTEM]: '{spell_name}' unlocked to MASTER.")
return entry
# ----------------------------
# FORMATTING
# ----------------------------
def _bar(value: int, length: int = 10) -> str:
value = _clamp(value)
filled = round(value / 100 * length)
return "▰" * filled + "▱" * (length - filled)
def _escape_tooltip(text: str) -> str:
"""Prevents a stray quote in a description from breaking the HTML title attribute."""
return text.replace('"', """)
def get_known_spells_markdown(state):
"""
Ready for the 'Spells' tab in the UI. Card layout mirrors get_relationships_markdown.
Each spell name carries an HTML title attribute — the browser shows it as a native
tooltip on hover, in the player's chosen game language.
"""
spells = state.get("known_spells", {})
if not spells:
return (
"*You haven't learned any spells yet. Attend a class, study a book, "
"or ask someone to teach you one.*"
)
language = state.get("language", "English")
cards = []
for slug, entry in spells.items():
info = SPELL_INFO.get(entry["name"], {})
desc_map = info.get("descriptions", {})
desc = desc_map.get(language) or desc_map.get("English", "")
rate = LEVEL_SUCCESS_RATES.get(entry["level"], 70)
card = (
f'#### <span title="{_escape_tooltip(desc)}">✨ {entry["name"]}</span>\n'
f"{entry['level']} · {rate}% success chance\n\n"
f"`{_bar(entry['progress'])}` {entry['progress']}/100"
)
cards.append(card)
return "\n\n---\n\n".join(cards)
def format_known_spells_for_llm(state, cap=10):
"""Compact summary injected into llm_engine's dynamic context."""
spells = state.get("known_spells", {})
if not spells:
return ""
lines = [
f" • {e['name']}: {e['level']}" for e in list(spells.values())[:cap]
]
return "[KNOWN SPELLS — reflect these levels in success chance and dialogue]\n" + "\n".join(lines)