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'#### ✨ {entry["name"]}\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)