diff --git a/src/pf1e_simulator/llm_view.py b/src/pf1e_simulator/llm_view.py new file mode 100644 index 0000000..95b9efe --- /dev/null +++ b/src/pf1e_simulator/llm_view.py @@ -0,0 +1,545 @@ +"""Compact, decision-sufficient observation view for LLM policies (P0.2). + +The raw ``Observation.to_dict()`` snapshot is ~64 KB (~16k tokens) for a +7-combatant encounter - too large to feed an LLM every turn (docs +``phase-2-llm-architecture.md`` section 16, target ~2-4k tokens). This module +builds a compact ``ObservationView`` that keeps every tactically relevant +fact while dropping duplicates, derivables and verbose expansions, and +renders it as a deterministic, line-based prompt string. + +Compaction rules +---------------- +- ``terrain``: sparse list of ``(row, col, type)`` for non-default cells + only. A cell is default when its semantics are ``move_cost == 1``, + ``blocks_los == False``, ``cover == False`` and ``cover_bonus == 0``. +- ``elevation``: sparse list of ``(row, col, height)`` for non-zero cells. +- ``threatened`` / ``ranged_threatened``: sparse list of ``(row, col)`` for + True cells (empty when no cell is covered). +- per-combatant: only decision-relevant keys are kept (see + ``_COMPACT_COMBATANT_KEYS``). Dropped: ``occupied_cells`` (derivable from + ``pos`` + ``size``), ``abilities``/``skills`` (raw scores, not used by the + candidate generator), ``initiative_mod``/``initiative_total`` (the + top-level order suffices), ``temp_hp_rounds_left`` (``temp_hp`` is the + current value), ``features``/``feature_details`` (the resources and + ``sneak_dice`` already expose the class abilities), ``carried_items``, + ``weapon_damage``, ``aware``, ``alignment``, ``armor_spell_failure``, + ``arcane_caster`` (all zero in the data) and ``run_rounds``. +- attacks: compacted to ``{id, name, kind, attack_bonus, damage (strings + like "1d6 slashing"), damage_bonus, crit_range, crit_mult, reach_ft, + range_increment_ft, count, firearm}`` - sunder-only object stats + (``hardness``/``hit_points``) and ``splash`` are dropped. +- spells: compacted to ``{name, level, range_ft, save, dc, cast_time, + area}`` - ``summary`` (the bulk of the raw size), ``school``, + ``half_on_save``, ``save_negates``, ``harmless`` and ``components`` are + dropped (no component failure in the engine's data). +- per-pair: all decision helpers are kept unchanged, except ``target_index`` + is replaced by the stable combatant ``target`` id. +- ``acting_combatant`` is rendered as the combatant id (not the index). +- ``condition_definitions`` is dropped (condition names suffice). +- every collection is deterministically ordered (fixed key order, row-major + or the raw snapshot's own sorted order). + +Render format +------------- +The render is line-based ``key=value`` text. The nested ``attacks`` and +``spells`` expansions are rendered as positional tuples (the field order is +declared once in the ``attack_fields`` / ``spell_fields`` legend lines) with +``-`` standing for a missing value (``None`` save/dc/area, no range +increment); this is what brings the view under the ~4k-token ceiling. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TypeGuard + +from .combat import OBSERVATION_SCHEMA_VERSION, Observation + +# Per-combatant keys kept in the compact view, in deterministic order. +# Everything else in the raw snapshot is derivable, verbose, or not +# decision-relevant for the candidate generator and the LLM. +_COMPACT_COMBATANT_KEYS: tuple[str, ...] = ( + "id", + "side", + "pos", + "hp", + "hp_max", + "nonlethal", + "active", + "dead", + "dying", + "stabilized", + "conditions", + "resources_left", + "weapon_broken", + "weapon_destroyed", + "weapon_stolen", + "weapon_dropped", + "grappled_with", + "hidden_from", + "moved_this_turn", + "power_attack", + "defensive_ac_bonus", + "fight_defensively", + "aid_attack_bonus", + "aid_ac_bonus", + "running", + "run_rest_rounds", + "bleed_damage", + "recurring_damage", + "aoo_remaining", + "name", + "level", + "size", + "natural_reach_ft", + "creature_type", + "bab", + "casting_ability", + "dr", + "metamagic_feats", + "summon_duration", + "immediate_used", + "deed_up_close_and_deadly", + "active_buffs", + "one_shot_bonuses", + "temp_hp", + "speed_land_ft", + "speed_fly_ft", + "speed_effective_ft", + "ac", + "saves", + "cmb", + "cmd", + "attacks", + "spells", + "spell_resistance", + "resistances", + "immunities", + "sneak_dice", + "engaged_in_melee", + "last_turn_results", + "readied", + "pending_cast", +) + +# Per-pair keys kept (all decision helpers), in the raw snapshot's order. +# ``target_index`` is replaced by the stable combatant ``target`` id. +_PAIR_KEYS: tuple[str, ...] = ( + "distance_ft", + "weapon_for", + "weapon_reason", + "range_penalty", + "can_charge", + "charge_reason", + "can_step5_to_attack", + "has_line_of_effect", + "flanking", + "movement_cost_ft", + "target_threatens_me", + "enemies_threatening_me", + "cover_bonus", +) + +# Attack keys kept in the compact expansion (sunder-only object stats and +# the splash marker are dropped). +_ATTACK_KEYS: tuple[str, ...] = ( + "id", + "name", + "kind", + "attack_bonus", + "damage", + "damage_bonus", + "crit_range", + "crit_mult", + "reach_ft", + "range_increment_ft", + "count", + "firearm", +) + +# Spell keys kept in the compact expansion (the summary is the bulk of the +# raw size; school/components/save flags are not decision-relevant). +_SPELL_KEYS: tuple[str, ...] = ( + "name", + "level", + "range_ft", + "save", + "dc", + "cast_time", + "area", +) + + +@dataclass(frozen=True) +class ObservationView: + """Compact, decision-sufficient view of one combat observation. + + All collections are deterministically ordered; ``terrain``/``elevation``/ + ``threatened``/``ranged_threatened`` are sparse (see module docstring). + ``acting_combatant`` is the acting combatant's id. + """ + + schema_version: int + round: int + acting_combatant: str + dimensions: tuple[int, int] + terrain: tuple[tuple[int, int, str], ...] + elevation: tuple[tuple[int, int, int], ...] + threatened: tuple[tuple[int, int], ...] + ranged_threatened: tuple[tuple[int, int], ...] + terrain_semantics: tuple[dict[str, object], ...] + initiative_order: tuple[str, ...] + combatants: tuple[dict[str, object], ...] + pairs: tuple[dict[str, object], ...] + + +def build_view(observation: Observation) -> ObservationView: + """Build a compact ``ObservationView`` from a raw ``Observation``.""" + height = len(observation.terrain) + width = len(observation.terrain[0]) if height else 0 + semantics_by_type = { + str(sem["type"]): sem for sem in observation.terrain_semantics + } + terrain = tuple( + (r, c, cell_type) + for r, row in enumerate(observation.terrain) + for c, cell_type in enumerate(row) + if cell_type is not None + and not _is_default_terrain(cell_type, semantics_by_type) + ) + elevation = tuple( + (r, c, height_at) + for r, row in enumerate(observation.elevation) + for c, height_at in enumerate(row) + if height_at != 0 + ) + combatants = tuple( + _compact_combatant(combatant) for combatant in observation.combatants + ) + return ObservationView( + schema_version=OBSERVATION_SCHEMA_VERSION, + round=observation.round, + acting_combatant=_combatant_id(combatants, observation.acting_combatant), + dimensions=(height, width), + terrain=terrain, + elevation=elevation, + threatened=_sparse_bool_grid(observation.threatened), + ranged_threatened=_sparse_bool_grid(observation.ranged_threatened), + terrain_semantics=observation.terrain_semantics, + initiative_order=observation.initiative_order, + combatants=combatants, + pairs=tuple(_compact_pair(pair, combatants) for pair in observation.pairs), + ) + + +def render(view: ObservationView) -> str: + """Render a compact view as a deterministic, line-based prompt string.""" + lines: list[str] = [ + f"schema_version={view.schema_version}", + f"round={view.round}", + f"acting={view.acting_combatant}", + f"map={view.dimensions[0]}x{view.dimensions[1]}", + f"terrain={_fmt_cells(view.terrain)}", + f"elevation={_fmt_cells(view.elevation)}", + f"threatened={_fmt_cells2(view.threatened)}", + f"ranged_threatened={_fmt_cells2(view.ranged_threatened)}", + f"terrain_semantics={_fmt_semantics(view.terrain_semantics)}", + f"initiative={','.join(view.initiative_order)}", + f"attack_fields={','.join(_ATTACK_KEYS)}", + f"spell_fields={','.join(_SPELL_KEYS)}", + "combatants:", + ] + for index, combatant in enumerate(view.combatants): + lines.append(f" {index} " + _fmt_combatant(combatant)) + lines.append("pairs:") + lines.extend(" " + _fmt_kv(pair) for pair in view.pairs) + return "\n".join(lines) + "\n" + + +def compact_observation(observation: Observation) -> str: + """One-shot: build the compact view of an observation and render it.""" + return render(build_view(observation)) + + +def _is_default_terrain( + cell_type: str, + semantics_by_type: dict[str, dict[str, object]], +) -> bool: + """True when a cell carries no tactical information worth listing.""" + sem = semantics_by_type.get(cell_type) + if sem is None: + return False + return ( + sem.get("move_cost") == 1 + and not sem.get("blocks_los") + and not sem.get("cover") + and sem.get("cover_bonus") == 0 + ) + + +def _sparse_bool_grid( + grid: tuple[tuple[bool, ...], ...], +) -> tuple[tuple[int, int], ...]: + """Sparse ``(row, col)`` list of True cells, row-major.""" + return tuple( + (r, c) + for r, row in enumerate(grid) + for c, value in enumerate(row) + if value + ) + + +def _compact_combatant(combatant: dict[str, object]) -> dict[str, object]: + """Select the decision-relevant keys and compact nested expansions.""" + compact = { + key: combatant[key] for key in _COMPACT_COMBATANT_KEYS if key in combatant + } + attacks = compact.get("attacks") + if _is_tuple(attacks): + compact["attacks"] = tuple( + _compact_attack(attack) for attack in attacks if _is_str_object_dict(attack) + ) + spells = compact.get("spells") + if _is_tuple(spells): + compact["spells"] = tuple( + _compact_spell(spell) for spell in spells if _is_str_object_dict(spell) + ) + return compact + + +def _compact_attack(attack: dict[str, object]) -> dict[str, object]: + """Compact one attack: damage becomes formula+type strings.""" + return { + "id": _as_str(attack["id"]), + "name": _as_str(attack["name"]), + "kind": _as_str(attack["kind"]), + "attack_bonus": _as_int(attack["attack_bonus"]), + "damage": _damage_strings(attack.get("damage")), + "damage_bonus": _as_int(attack["damage_bonus"]), + "crit_range": _as_int(attack["crit_range"]), + "crit_mult": _as_int(attack["crit_mult"]), + "reach_ft": _as_int(attack["reach_ft"]), + "range_increment_ft": _as_optional_int(attack["range_increment_ft"]), + "count": _as_int(attack["count"]), + "firearm": _as_bool(attack["firearm"]), + } + + +def _compact_spell(spell: dict[str, object]) -> dict[str, object]: + """Compact one spell: keep only the decision-relevant fields.""" + return { + "name": _as_str(spell["name"]), + "level": _as_optional_int(spell["level"]), + "range_ft": _as_int(spell["range_ft"]), + "save": _as_str(spell["save"]), + "dc": _as_optional_int(spell["dc"]), + "cast_time": _as_str(spell["cast_time"]), + "area": spell.get("area"), + } + + +def _compact_pair( + pair: dict[str, object], + combatants: tuple[dict[str, object], ...], +) -> dict[str, object]: + """Select the decision-helper keys of one pair, in fixed order.""" + compact = {key: pair[key] for key in _PAIR_KEYS if key in pair} + compact["target"] = _combatant_id(combatants, _as_int(pair["target_index"])) + return compact + + +def _combatant_id(combatants: tuple[dict[str, object], ...], index: int) -> str: + """Stable id of the combatant at ``index`` (falls back to the index).""" + if 0 <= index < len(combatants): + cid = combatants[index].get("id") + if isinstance(cid, str): + return cid + return str(index) + + +def _damage_strings(value: object) -> tuple[str, ...]: + """Render a damage component list as ``"1d6 slashing"`` strings.""" + if not _is_tuple(value): + return () + strings: list[str] = [] + for component in value: + if not _is_str_object_dict(component): + continue + formula = component.get("formula") + if not isinstance(formula, str): + continue + types = component.get("types") + if _is_tuple(types): + parts = [formula, *(t for t in types if isinstance(t, str))] + else: + parts = [formula] + strings.append(" ".join(parts)) + return tuple(strings) + + +def _fmt_combatant(combatant: dict[str, object]) -> str: + """Render one compact combatant, attacks/spells positionally.""" + parts: list[str] = [] + for key, value in combatant.items(): + if key == "attacks": + if _is_tuple(value): + parts.append( + "attacks=(" + + ",".join( + _fmt_attack(attack) + for attack in value + if _is_str_object_dict(attack) + ) + + ")" + ) + continue + if key == "spells": + if _is_tuple(value): + parts.append( + "spells=(" + + ",".join( + _fmt_spell(spell) + for spell in value + if _is_str_object_dict(spell) + ) + + ")" + ) + continue + parts.append(f"{key}={_fmt_value(value)}") + return " ".join(parts) + + +def _fmt_attack(attack: dict[str, object]) -> str: + """Positional attack tuple; ``-`` for a missing range increment.""" + damage = attack.get("damage") + damage_str = ( + "+".join(d for d in damage if isinstance(d, str)) if _is_tuple(damage) else "" + ) + if not damage_str: + damage_str = "-" + range_increment = _as_optional_int(attack["range_increment_ft"]) + range_str = "-" if range_increment is None else str(range_increment) + return ( + f"({_as_str(attack['id'])},{_as_str(attack['name'])}," + f"{_as_str(attack['kind'])},{_as_int(attack['attack_bonus'])}," + f"{damage_str},{_as_int(attack['damage_bonus'])}," + f"{_as_int(attack['crit_range'])},{_as_int(attack['crit_mult'])}," + f"{_as_int(attack['reach_ft'])},{range_str}," + f"{_as_int(attack['count'])},{1 if _as_bool(attack['firearm']) else 0})" + ) + + +def _fmt_spell(spell: dict[str, object]) -> str: + """Positional spell tuple; ``-`` for a missing save/dc/area.""" + save = _as_str(spell["save"]) + save_str = "-" if save == "none" else save + dc = _as_optional_int(spell["dc"]) + dc_str = "-" if dc is None else str(dc) + area = spell.get("area") + if area is None: + area_str = "-" + elif _is_str_object_dict(area): + shape = _as_str(area["shape"]) + radius = _as_int(area["radius_ft"]) + centered = _as_bool(area["centered_on_caster"]) + area_str = f"{shape}:{radius}" + (":c" if centered else "") + else: + area_str = "-" + level = _as_optional_int(spell["level"]) + level_str = "-" if level is None else str(level) + return ( + f"({_as_str(spell['name'])},{level_str}," + f"{_as_int(spell['range_ft'])},{save_str},{dc_str}," + f"{_as_str(spell['cast_time'])},{area_str})" + ) + + +def _fmt_cells(cells: tuple[tuple[int, int, object], ...]) -> str: + return "".join(f"({r},{c},{_fmt_value(v)})" for r, c, v in cells) + + +def _fmt_cells2(cells: tuple[tuple[int, int], ...]) -> str: + return "".join(f"({r},{c})" for r, c in cells) + + +def _fmt_semantics(semantics: tuple[dict[str, object], ...]) -> str: + parts: list[str] = [] + for sem in semantics: + name = str(sem.get("type", "?")) + move_cost = sem.get("move_cost") + blocks_los = sem.get("blocks_los") + cover = sem.get("cover") + cover_bonus = sem.get("cover_bonus") + parts.append( + f"{name}{{mc={move_cost},los={1 if blocks_los else 0}," + f"cov={1 if cover else 0},cb={cover_bonus}}}" + ) + return ",".join(parts) + + +def _fmt_kv(mapping: dict[str, object]) -> str: + return " ".join(f"{key}={_fmt_value(value)}" for key, value in mapping.items()) + + +def _fmt_value(value: object) -> str: + """Compact, unambiguous rendering of one snapshot value. + + ``None`` falls through to ``str(value)`` (already ``"None"``). + """ + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return ( + value + if value and all(c.isalnum() or c in "_-" for c in value) + else json.dumps(value) + ) + if _is_tuple(value): + return "(" + ",".join(_fmt_value(item) for item in value) + ")" + if _is_str_object_dict(value): + return ( + "{" + + ",".join(f"{key}:{_fmt_value(item)}" for key, item in value.items()) + + "}" + ) + return str(value) + + +def _as_str(value: object) -> str: + if not isinstance(value, str): + msg = f"expected str, got {type(value).__name__}" + raise TypeError(msg) + return value + + +def _as_int(value: object) -> int: + if not isinstance(value, int): + msg = f"expected int, got {type(value).__name__}" + raise TypeError(msg) + return value + + +def _as_bool(value: object) -> bool: + if not isinstance(value, bool): + msg = f"expected bool, got {type(value).__name__}" + raise TypeError(msg) + return value + + +def _as_optional_int(value: object) -> int | None: + if value is None: + return None + return _as_int(value) + + +def _is_tuple(value: object) -> TypeGuard[tuple[object, ...]]: + """Narrow ``object`` to a tuple (snapshot collections are tuples).""" + return isinstance(value, tuple) + + +def _is_str_object_dict(value: object) -> TypeGuard[dict[str, object]]: + """Narrow ``object`` to the snapshot's ``dict[str, object]`` shape.""" + return isinstance(value, dict) diff --git a/tests/test_phase2_view.py b/tests/test_phase2_view.py new file mode 100644 index 0000000..ab14579 --- /dev/null +++ b/tests/test_phase2_view.py @@ -0,0 +1,279 @@ +"""Phase-2 compact observation view (docs/phase-2-llm-architecture.md P0.2). + +``llm_view.build_view`` + ``render`` turn the raw ``Observation`` snapshot +into a compact, deterministic text view for the LLM prompt. This module +measures the compaction on a representative caster-heavy encounter (same +shape as the measurement spike, but with its OWN spec builder - the spike +file is out of scope), prints a summary block for the orchestrator, and +pins LOOSE upper bounds so a regression (a dropped key re-added, a sparse +grid turned dense, ...) fails loudly. It also pins the structural contract: +sparse grids only carry non-default cells, dropped keys stay dropped, the +nested attack/spell expansions stay compact, and the render is +deterministic. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from pf1e_simulator.combat import ( + OBSERVATION_SCHEMA_VERSION, + Action, + CombatantState, + CombatEngine, + Observation, + default_policy, +) +from pf1e_simulator.grid import Grid +from pf1e_simulator.llm_view import build_view, render +from pf1e_simulator.loaders import load_combatant +from pf1e_simulator.map import load_map +from pf1e_simulator.rng import SeededRng +from pf1e_simulator.runner import EncounterSpec, Side, build_states +from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, load_spell_registry + +if TYPE_CHECKING: + import pytest + + from pf1e_simulator.models import Combatant + +ROOT = Path(__file__).resolve().parents[1] +MAP = ROOT / "data" / "maps" / "sample_arena.yaml" + +# Loose ceilings (regression guards, not exact pins): the docs target a +# compact view of ~2-4k tokens against a raw snapshot of ~16k tokens, i.e. +# 12.5-25% of the raw size. 25% of raw bytes and a 4k-token byte ceiling +# (4 bytes/token) are the outer bounds. +MAX_COMPACT_RATIO = 0.25 +MAX_COMPACT_BYTES = 16_000 + +# Keys the compact view deliberately drops from each combatant snapshot. +_DROPPED_COMBATANT_KEYS = frozenset( + { + "occupied_cells", + "abilities", + "skills", + "initiative_mod", + "initiative_total", + "temp_hp_rounds_left", + "features", + "feature_details", + "carried_items", + "weapon_damage", + "aware", + "alignment", + "armor_spell_failure", + "arcane_caster", + "run_rounds", + } +) + +# Every pair key must survive (the pairs are the tactical core); the raw +# ``target_index`` is replaced by the stable combatant ``target`` id. +_PAIR_KEYS = frozenset( + { + "target", + "distance_ft", + "weapon_for", + "weapon_reason", + "range_penalty", + "can_charge", + "charge_reason", + "can_step5_to_attack", + "has_line_of_effect", + "flanking", + "movement_cost_ft", + "target_threatens_me", + "enemies_threatening_me", + "cover_bonus", + } +) + +# Keys dropped from the nested attack/spell expansions. +_ATTACK_DROPPED_KEYS = frozenset({"splash", "hardness", "hit_points"}) +_SPELL_DROPPED_KEYS = frozenset( + {"summary", "school", "half_on_save", "save_negates", "harmless", "components"} +) + + +class _Capture: + """Policy wrapper that snapshots the first observation, then delegates.""" + + def __init__(self) -> None: + self.observation: Observation | None = None + + def policy(self, engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if self.observation is None: + self.observation = engine._build_observation(state) + return default_policy(engine, state) + + +def _c(path: str) -> Combatant: + return load_combatant(ROOT / "data" / path) + + +def _representative_spec() -> EncounterSpec: + """Caster-heavy 3v4 on the sample arena (walls, rubble, water, pillars).""" + return EncounterSpec( + map=load_map(MAP), + round_cap=20, + sides=( + Side( + name="players", + combatants=(_c("pcs/oni.json"), _c("pcs/misty.json"), _c("pcs/nairda.json")), + zone="A", + ), + Side( + name="monsters", + combatants=( + _c("monsters/lich.json"), + _c("monsters/thrune_agent.json"), + _c("monsters/orc.json"), + _c("monsters/goblin.json"), + ), + zone="B", + ), + ), + ) + + +def _first_observation( + spec: EncounterSpec, seed: int, registry: dict[str, SpellSpec] +) -> Observation: + grid = Grid.from_spec(spec.map) + states = build_states(spec) + cap = _Capture() + CombatEngine( + SeededRng(seed), + grid, + states, + round_cap=spec.round_cap, + policy=cap.policy, + spell_registry=registry, + ).run() + assert cap.observation is not None + return cap.observation + + +def _emit(line: str) -> None: + sys.stdout.write(line + "\n") + + +def _is_default_terrain(semantics: dict[str, object]) -> bool: + """Mirror of ``llm_view._is_default_terrain`` for the structural test.""" + return ( + semantics.get("move_cost") == 1 + and not semantics.get("blocks_los") + and not semantics.get("cover") + and semantics.get("cover_bonus") == 0 + ) + + +def test_compact_view_size(capsys: pytest.CaptureFixture[str]) -> None: + """Measure raw-vs-compact on the representative battle; pin loose bounds.""" + registry = load_spell_registry(SPELLS_DIR) + obs = _first_observation(_representative_spec(), seed=1, registry=registry) + raw = json.dumps(obs.to_dict()) + view = build_view(obs) + compact = render(view) + + raw_bytes = len(raw) + compact_bytes = len(compact) + + # Determinism: rebuilding the view from the same observation renders + # byte-identically. + assert render(build_view(obs)) == compact + + # Schema version is pinned to the engine's contract. + assert view.schema_version == OBSERVATION_SCHEMA_VERSION + + # Loose bounds (regression guards, not exact pins). + assert compact_bytes < raw_bytes * MAX_COMPACT_RATIO + assert compact_bytes < MAX_COMPACT_BYTES + + _emit("=== PHASE2 SUMMARY (compact observation view) ===") + _emit("encounter: 7 combatants, round_cap=20, seed=1") + _emit(f"raw observation bytes: {raw_bytes} (~{raw_bytes // 4} tokens)") + _emit(f"compact view bytes: {compact_bytes} (~{compact_bytes // 4} tokens)") + _emit(f"compaction ratio: {compact_bytes / raw_bytes:.1%} of raw") + _emit(f"combatants in view: {len(view.combatants)}") + _emit(f"pairs in view: {len(view.pairs)}") + _emit(f"sparse terrain cells: {len(view.terrain)}") + _emit(f"sparse elevation cells: {len(view.elevation)}") + _emit(f"threatened cells: {len(view.threatened)}") + _emit(f"ranged_threatened cells: {len(view.ranged_threatened)}") + out = capsys.readouterr().out + assert "PHASE2 SUMMARY" in out + + +def test_view_sparse_grids() -> None: + """Pin the sparse-grid contract: only non-default/non-zero/True cells.""" + registry = load_spell_registry(SPELLS_DIR) + obs = _first_observation(_representative_spec(), seed=1, registry=registry) + raw = obs.to_dict() + view = build_view(obs) + semantics_by_type = {str(sem["type"]): sem for sem in raw["terrain_semantics"]} + + # Dimensions match the raw terrain grid. + assert view.dimensions == (len(raw["terrain"]), len(raw["terrain"][0])) + + # Sparse terrain only carries non-default cells, with the raw char. + for r, c, terrain_char in view.terrain: + assert not _is_default_terrain(semantics_by_type[terrain_char]) + assert raw["terrain"][r][c] == terrain_char + + # Sparse elevation only carries non-zero cells. + for r, c, height in view.elevation: + assert height != 0 + assert raw["elevation"][r][c] == height + + # Sparse threatened grids only carry True cells. + for r, c in view.threatened: + assert raw["threatened"][r][c] is True + for r, c in view.ranged_threatened: + assert raw["ranged_threatened"][r][c] is True + + +def test_view_dropped_keys() -> None: + """Pin the key contract: dropped keys stay dropped, pair keys survive.""" + registry = load_spell_registry(SPELLS_DIR) + obs = _first_observation(_representative_spec(), seed=1, registry=registry) + view = build_view(obs) + + for combatant in view.combatants: + assert _DROPPED_COMBATANT_KEYS.isdisjoint(combatant.keys()) + for pair in view.pairs: + assert _PAIR_KEYS.issubset(pair.keys()) + + # Nested expansions stay compact: no dropped attack/spell keys, and the + # attack damage is rendered as formula+type strings. + for combatant in view.combatants: + for attack in combatant["attacks"]: + assert _ATTACK_DROPPED_KEYS.isdisjoint(attack.keys()) + assert all(isinstance(d, str) for d in attack["damage"]) + for spell in combatant["spells"]: + assert _SPELL_DROPPED_KEYS.isdisjoint(spell.keys()) + + +def test_view_ids_and_render() -> None: + """Pin the id contract and the deterministic line-based render.""" + registry = load_spell_registry(SPELLS_DIR) + obs = _first_observation(_representative_spec(), seed=1, registry=registry) + raw = obs.to_dict() + view = build_view(obs) + + # The acting combatant and pair targets are rendered as ids. + assert view.acting_combatant in raw["initiative_order"] + ids = {combatant["id"] for combatant in view.combatants} + for pair in view.pairs: + assert pair["target"] in ids + + # The render is line-based and self-describing. + text = render(view) + assert text.startswith("schema_version=1\n") + assert "combatants:" in text + assert "pairs:" in text + assert "condition_definitions" not in text