Spaces:
Sleeping
Sleeping
| """The Rune Goblin spell-physics engine. | |
| Two responsibilities: | |
| 1. ``resolve_spell`` — given a game state + drawn runes, compute a *correct* | |
| :class:`SpellResult`. This is the rule-based oracle used to generate training | |
| targets, so the fine-tuned model learns to imitate these rules. | |
| 2. ``clamp_spell`` — given any (possibly model-produced) :class:`SpellResult` | |
| and the current state, bound the numeric deltas so HP can never go out of | |
| range (Risk 3 in the plan: "Let Python enforce final HP limits"). | |
| """ | |
| from __future__ import annotations | |
| import random | |
| from dataclasses import dataclass | |
| from .runelang import ( | |
| ENEMIES, | |
| GLYPHS, | |
| find_combo, | |
| rune_matches, | |
| ) | |
| from .schema import SpellResult | |
| class GameState: | |
| player_hp: int = 10 | |
| player_max_hp: int = 10 | |
| enemy_name: str = "Mirror Fungus" | |
| enemy_hp: int = 5 | |
| enemy_max_hp: int = 5 | |
| room_mood: str = "paranoid" | |
| inventory: tuple[str, ...] = () | |
| courage: int = 5 | |
| # RPG entities carry their own weakness/resistance (not in the ENEMIES table); | |
| # set these to override the lookup. | |
| weakness_override: tuple[str, ...] | None = None | |
| resistance_override: tuple[str, ...] | None = None | |
| def weakness(self) -> tuple[str, ...]: | |
| if self.weakness_override is not None: | |
| return self.weakness_override | |
| e = ENEMIES.get(self.enemy_name) | |
| return e.weakness if e else () | |
| def resistance(self) -> tuple[str, ...]: | |
| if self.resistance_override is not None: | |
| return self.resistance_override | |
| e = ENEMIES.get(self.enemy_name) | |
| return e.resistance if e else () | |
| # --------------------------------------------------------------------------- | |
| # Funny-name generation (Risk 4: avoid generic "Fireball" outputs) | |
| # --------------------------------------------------------------------------- | |
| _ADJECTIVES = [ | |
| "Cursed", "Triplicate", "Moist", "Bureaucratic", "Looping", "Forbidden", | |
| "Mildly", "Overdue", "Spiteful", "Recursive", "Laminated", "Haunted", | |
| "Passive-Aggressive", "Expired", "Reluctant", "Tax-Deductible", | |
| ] | |
| _NOUNS = [ | |
| "Panic Spiral", "Ember Cage", "Regret", "Refund Bell", "Bone Swarm", | |
| "Foresight Loop", "Paperwork Storm", "Moth Tax", "Echo", "Hex", | |
| "Reflection", "Whisper", "Gambit", "Tantrum", "Prophecy", "Audit", | |
| ] | |
| def _make_spell_name(rng: random.Random, runes: list[str], spell_type: str) -> str: | |
| adj = rng.choice(_ADJECTIVES) | |
| noun = rng.choice(_NOUNS) | |
| if "broken_mark" in runes and rng.random() < 0.6: | |
| adj = "Cursed" | |
| return f"{adj} {noun}" | |
| def _flavor(rng: random.Random, state: GameState, runes: list[str], combo) -> str: | |
| glyph_labels = [GLYPHS[r].label.lower() for r in runes if r in GLYPHS] | |
| bits = ", ".join(glyph_labels) | |
| if combo is not None: | |
| return ( | |
| f"The {state.enemy_name.lower()} reacts to a {combo.meaning} woven from " | |
| f"{bits}." | |
| ) | |
| return f"A spell of {bits} unfurls in the {state.room_mood} air." | |
| def _side_effect(rng: random.Random, state: GameState, cursed: bool) -> str: | |
| if not cursed: | |
| return "" | |
| pool = [ | |
| "Player loses 1 courage from a sudden vision of their own future haircut.", | |
| "A copy of your fear refuses to leave and follows you to the next room.", | |
| "Room temperature rises; your next water spell is weaker.", | |
| "You sign a contract you do not remember agreeing to.", | |
| "A small debt appears in your inventory.", | |
| ] | |
| return rng.choice(pool) | |
| # --------------------------------------------------------------------------- | |
| # Core resolution | |
| # --------------------------------------------------------------------------- | |
| def resolve_spell(state: GameState, runes: list[str], seed: int | None = None) -> SpellResult: | |
| """Deterministically (given ``seed``) compute the correct spell outcome.""" | |
| rng = random.Random(seed) | |
| runes = [r for r in runes if r in GLYPHS] | |
| if not runes: | |
| from .schema import FALLBACK_SPELL | |
| return FALLBACK_SPELL.model_copy() | |
| cursed = "broken_mark" in runes | |
| combo = find_combo(runes) | |
| # --- base damage from glyphs --- | |
| raw_damage = sum(GLYPHS[r].base_damage for r in runes) | |
| heal = sum(GLYPHS[r].heal for r in runes) | |
| statuses: list[str] = [] | |
| for r in runes: | |
| st = GLYPHS[r].status | |
| if st and st not in statuses: | |
| statuses.append(st) | |
| # --- weakness / resistance interaction --- | |
| hits_weakness = any(rune_matches(r, state.weakness) for r in runes) | |
| hits_resistance = any(rune_matches(r, state.resistance) for r in runes) | |
| if hits_weakness: | |
| raw_damage += 2 | |
| statuses.append("weakness_revealed") | |
| if hits_resistance: | |
| raw_damage = max(0, raw_damage - 2) | |
| # --- combo adjustments --- | |
| spell_type = combo.spell_type if combo else "generic" | |
| if combo is not None: | |
| if combo.spell_type in {"reflect", "shield_burn"}: | |
| statuses.append("damage_reflected") | |
| if combo.spell_type == "reveal_weakness": | |
| statuses.append("weakness_revealed") | |
| raw_damage += 1 | |
| if combo.spell_type == "heal_decay": | |
| heal += 1 | |
| cursed = True # decay risk | |
| # --- curse modifier --- | |
| if cursed: | |
| raw_damage = int(round(raw_damage * 1.5)) | |
| # --- player cost --- | |
| player_delta = heal | |
| if cursed: | |
| player_delta -= 1 # courage/HP cost of using the broken mark | |
| chaos = min(10, 2 + len(runes) + (3 if cursed else 0) + (2 if combo else 0)) | |
| # de-duplicate statuses, keep order | |
| seen: set[str] = set() | |
| statuses = [s for s in statuses if not (s in seen or seen.add(s))] | |
| effect_bits = [] | |
| if raw_damage > 0: | |
| effect_bits.append(f"Deals {raw_damage} damage") | |
| if "enemy_confused" in statuses: | |
| effect_bits.append("confuses the enemy for 1 turn") | |
| if "enemy_burning" in statuses: | |
| effect_bits.append("sets the enemy on fire") | |
| if "player_shielded" in statuses: | |
| effect_bits.append("shields the player") | |
| if heal > 0: | |
| effect_bits.append(f"heals the player {heal}") | |
| if not effect_bits: | |
| effect_bits.append("disturbs the enemy mildly") | |
| effect = ", ".join(effect_bits).capitalize() + "." | |
| spell = SpellResult( | |
| spell_name=_make_spell_name(rng, runes, spell_type), | |
| spell_type=spell_type, | |
| flavor=_flavor(rng, state, runes, combo), | |
| effect=effect, | |
| side_effect=_side_effect(rng, state, cursed), | |
| enemy_hp_delta=-raw_damage, | |
| player_hp_delta=player_delta, | |
| status_effects=statuses, | |
| chaos=chaos, | |
| ) | |
| return clamp_spell(spell, state) | |
| def clamp_spell(spell: SpellResult, state: GameState) -> SpellResult: | |
| """Bound HP deltas so they can never push HP outside [0, max].""" | |
| # Enemy can lose at most its current HP, gain at most up to max. | |
| enemy_delta = max(-state.enemy_hp, min(state.enemy_max_hp - state.enemy_hp, spell.enemy_hp_delta)) | |
| player_delta = max(-state.player_hp, min(state.player_max_hp - state.player_hp, spell.player_hp_delta)) | |
| spell.enemy_hp_delta = enemy_delta | |
| spell.player_hp_delta = player_delta | |
| return spell | |