"""Output parser.
Turns the model's raw text into (narrative, choices, list-of-deltas), then applies
the deltas to the GameState with full validation. This module is where "the model
proposes, Python disposes" actually happens.
It is intentionally forgiving about formatting (small models drift) but strict about
*effects*: an unparseable number is ignored, an unknown key is ignored, and every
applied change is clamped by GameState.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from .game_state import GameState, NPC, Enemy
@dataclass
class TurnResult:
narrative: str
choices: list[str]
applied: list[str] # human-readable list of what changed
raw: str # original model text (for debugging)
_BLOCK = lambda tag, text: _extract_block(tag, text)
def _extract_block(tag: str, text: str) -> str:
"""Grab the content between ... . Tolerates a missing closing tag
by reading to the next block or end of string."""
# normal, well-formed case
m = re.search(rf"<{tag}>(.*?){tag}>", text, re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip()
# lenient: with no close — read until the next <...> or EOS
m = re.search(rf"<{tag}>(.*?)(?=<\w+>|\Z)", text, re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip()
return ""
def parse(raw: str) -> tuple[str, list[str], list[str]]:
"""Return (narrative, choices, raw_state_lines) from model text."""
narrative = _extract_block("narrative", raw)
state_block = _extract_block("state", raw)
choices_block = _extract_block("choices", raw)
# Fallback: if there were no tags at all, treat the whole thing as narrative.
if not narrative and not state_block and not choices_block:
narrative = raw.strip()
choices: list[str] = []
for line in choices_block.splitlines():
line = line.strip()
if not line:
continue
# strip leading "1." / "1)" / "- " markers
line = re.sub(r"^\s*(?:\d+[.)]|[-*•])\s*", "", line)
if line:
choices.append(line)
state_lines = [ln.strip() for ln in state_block.splitlines() if ln.strip()]
return narrative, choices, state_lines
def _parse_int(token: str) -> int | None:
m = re.search(r"[-+]?\d+", token)
return int(m.group()) if m else None
def apply_state_changes(state: GameState, state_lines: list[str]) -> list[str]:
"""Validate and apply each proposed change. Returns a human-readable changelog."""
applied: list[str] = []
for line in state_lines:
if ":" not in line and not line.upper().startswith(("ENEMY_DEFEATED", "GAME_OVER")):
continue
key, _, value = line.partition(":")
key = key.strip().upper()
value = value.strip()
if key == "HP":
n = _parse_int(value)
if n is None:
continue
if n < 0:
state.damage(-n)
applied.append(f"HP {n}")
else:
state.heal(n)
applied.append(f"HP +{n}")
elif key == "GOLD":
n = _parse_int(value)
if n is not None:
state.add_gold(n)
applied.append(f"Gold {n:+d}")
elif key == "XP":
n = _parse_int(value)
if n is not None and n > 0:
before = state.level
state.add_xp(n)
applied.append(f"XP +{n}")
if state.level > before:
applied.append(f"Level up → {state.level}")
elif key == "ITEM_ADD":
if value and state.add_item(value):
applied.append(f"+ {value}")
elif key == "ITEM_REMOVE":
if value and state.remove_item(value):
applied.append(f"- {value}")
elif key == "LOCATION":
if value:
state.location = value
applied.append(f"Moved to {value}")
elif key == "QUEST":
if value:
state.quest = value
applied.append("Quest updated")
elif key == "NPC":
npc = _parse_npc(value)
if npc:
state.upsert_npc(npc)
applied.append(f"Met {npc.name}")
elif key == "ENEMY":
enemy = _parse_enemy(value)
if enemy:
state.start_combat(enemy)
applied.append(f"Combat: {enemy.name}")
elif key == "ENEMY_HP":
n = _parse_int(value)
if n is not None and state.enemy:
state.enemy.hp = max(0, state.enemy.hp + n)
applied.append(f"{state.enemy.name} HP {n:+d}")
if not state.enemy.alive:
applied.append(f"{state.enemy.name} defeated")
state.end_combat()
elif key.startswith("ENEMY_DEFEATED"):
if state.enemy:
applied.append(f"{state.enemy.name} defeated")
state.end_combat()
elif key.startswith("GAME_OVER"):
state.game_over = True
applied.append("GAME OVER")
return applied
def _parse_npc(value: str) -> NPC | None:
# format: Name|role|disposition|note (later fields optional)
parts = [p.strip() for p in value.split("|")]
if not parts or not parts[0]:
return None
name = parts[0]
role = parts[1] if len(parts) > 1 else ""
disp = parts[2] if len(parts) > 2 else "neutral"
note = parts[3] if len(parts) > 3 else ""
return NPC(name=name, role=role, disposition=disp, note=note)
def _parse_enemy(value: str) -> Enemy | None:
# format: Name|hp=12|atk=4
parts = [p.strip() for p in value.split("|")]
if not parts or not parts[0]:
return None
name = parts[0]
hp, atk = 10, 3
for p in parts[1:]:
m = re.search(r"(hp|atk|attack)\s*=\s*(\d+)", p, re.IGNORECASE)
if m:
if m.group(1).lower() == "hp":
hp = int(m.group(2))
else:
atk = int(m.group(2))
hp = max(1, min(hp, 200))
atk = max(0, min(atk, 50))
return Enemy(name=name, hp=hp, max_hp=hp, attack=atk)
def run_turn(state: GameState, raw: str) -> TurnResult:
"""Full pipeline: parse model text, apply changes, advance the turn counter."""
narrative, choices, state_lines = parse(raw)
applied = apply_state_changes(state, state_lines)
state.turn += 1
if applied:
state.log.append(f"Turn {state.turn}: " + "; ".join(applied))
return TurnResult(narrative=narrative, choices=choices, applied=applied, raw=raw)