File size: 6,708 Bytes
7fe39f3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """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 <tag> ... </tag>. 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: <tag> 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)
|