Spaces:
Sleeping
Sleeping
| 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) |