| """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 |
|
|
|
|
| @dataclass |
| 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 |
| |
| |
| weakness_override: tuple[str, ...] | None = None |
| resistance_override: tuple[str, ...] | None = None |
|
|
| @property |
| 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 () |
|
|
| @property |
| 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 () |
|
|
|
|
| |
| |
| |
| _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) |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| if cursed: |
| raw_damage = int(round(raw_damage * 1.5)) |
|
|
| |
| player_delta = heal |
| if cursed: |
| player_delta -= 1 |
|
|
| chaos = min(10, 2 + len(runes) + (3 if cursed else 0) + (2 if combo else 0)) |
|
|
| |
| 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_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 |
|
|