Files
pf1e-simulator/tests/test_observation_enriched.py
T

380 lines
13 KiB
Python

"""Tests for E1: enriched per-combatant combat stats in the Observation snapshot.
Adds the static combat-stat fields to each ``combatants`` dict of the
snapshot (name, level, size, speeds, ac, saves, cmb, cmd) — a strict
superset of the L1 schema. No existing key is modified or renamed.
Covers:
- The enriched schema 1v1 (exact values derived from the constructed
``Combatant``).
- ``cmb``/``cmd`` coherence with ``_maneuver_cmb``/``_maneuver_cmd`` on the
same state.
- The optional ``speed_fly_ft`` field (``int | None``).
- JSON serializability and frozen copies (same conventions as L1 tests 9/10).
"""
from __future__ import annotations
import json
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import dice_to_str, parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def make_combatant(
cid: str,
*,
size: str = "Medium",
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
speed_fly: int | None = None,
) -> Combatant:
"""Build a single-weapon melee combatant with known static stats."""
attack = AttackSpec(
id=f"{cid}-w",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
speed_fly_ft=speed_fly,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="test",
terrain=tuple(["." * 8] * 8),
legend=legend,
)
return Grid.from_spec(spec)
def make_engine(states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), make_grid(), states)
def _snapshot(engine: CombatEngine, state: CombatantState) -> dict:
return engine._build_observation(state).to_dict()
# ---------------------------------------------------------------------------
# 1. Enriched schema 1v1 — all 9 new fields present and correct
# ---------------------------------------------------------------------------
def test_observation_enriched_combat_stats_schema() -> None:
"""Given: a simple 1v1 with default state
When: an Observation is built for the acting combatant
Then: every enriched combat-stat field is present and correct."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
assert len(obs["combatants"]) == 2
c = obs["combatants"][0]
assert c["name"] == "hero"
assert c["level"] == 1
assert c["size"] == "Medium"
assert c["speed_land_ft"] == 30
assert c["speed_fly_ft"] is None
assert c["ac"] == {"total": 15, "touch": 15, "flat_footed": 15}
assert c["saves"] == {"fort": 0, "ref": 0, "will": 0}
# Default combatant: str 10 (mod 0), dex 10 (mod 0), bab 5, Medium (0)
assert c["cmb"] == 5
assert c["cmd"] == 15
# ---------------------------------------------------------------------------
# 2. cmb/cmd coherent with _maneuver_cmb / _maneuver_cmd on the same state
# ---------------------------------------------------------------------------
def test_observation_enriched_cmb_cmd_match_maneuver_helpers() -> None:
"""Given: a 1v1 with default state
When: the snapshot is built
Then: cmb/cmd equal _maneuver_cmb/_maneuver_cmd on the same state."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
for index, state in enumerate((hero_state, foe_state)):
c = obs["combatants"][index]
assert c["cmb"] == engine._maneuver_cmb(state)
assert c["cmd"] == engine._maneuver_cmd(state)
# ---------------------------------------------------------------------------
# 3. speed_fly_ft optional (int | None)
# ---------------------------------------------------------------------------
def test_observation_enriched_speed_fly_ft_optional() -> None:
"""Given: a combatant with a fly speed and one without
When: an Observation is built
Then: speed_fly_ft is exposed when set and None when absent."""
flyer = make_combatant("flyer", speed_fly=60)
walker = make_combatant("walker")
flyer_state = make_state(flyer, "players", (0, 0))
walker_state = make_state(walker, "monsters", (0, 3))
engine = make_engine([flyer_state, walker_state])
obs = _snapshot(engine, flyer_state)
assert obs["combatants"][0]["speed_fly_ft"] == 60
assert obs["combatants"][1]["speed_fly_ft"] is None
# ---------------------------------------------------------------------------
# 4. Enriched dict is JSON-serializable and frozen (L1 conventions 9/10)
# ---------------------------------------------------------------------------
def test_observation_enriched_json_serializable_and_frozen() -> None:
"""Given: an Observation already built
When: to_dict() is called and the live state is then mutated
Then: json.dumps succeeds on the enriched dict and the already-built
snapshot does not change."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = engine._build_observation(hero_state)
data_before = obs.to_dict()
json.dumps(data_before) # must not raise
# Mutate live state after construction
hero_state.hp = 5
hero_state.pos = (5, 5)
hero_state.weapon_broken.add("hero-w")
data_after = obs.to_dict()
assert data_after == data_before
c = data_after["combatants"][0]
assert c["name"] == "hero"
assert c["ac"] == {"total": 15, "touch": 15, "flat_footed": 15}
assert c["saves"] == {"fort": 0, "ref": 0, "will": 0}
assert c["cmb"] == 5
assert c["cmd"] == 15
# ---------------------------------------------------------------------------
# 5. E2: attacks reference — one dict per AttackSpec, broken/stolen included
# ---------------------------------------------------------------------------
def make_two_weapon_combatant(cid: str) -> Combatant:
"""Build a combatant with one melee and one ranged weapon."""
melee = AttackSpec(
id=f"{cid}-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
damage_bonus=2,
crit_range=19,
crit_mult=2,
reach_ft=5,
count=1,
firearm=False,
)
ranged = AttackSpec(
id=f"{cid}-bow",
name="short bow",
kind="ranged",
attack_bonus=4,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
crit_mult=3,
range_increment_ft=60,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=5,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[melee, ranged],
)
def test_observation_enriched_attacks_schema() -> None:
"""Given: a combatant with a melee and a ranged weapon
When: an Observation is built
Then: the attacks dict lists both weapons with full serialized fields."""
hero = make_two_weapon_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
attacks = obs["combatants"][0]["attacks"]
assert len(attacks) == 2
sword = attacks[0]
assert sword["id"] == "hero-sword"
assert sword["name"] == "short sword"
assert sword["kind"] == "melee"
assert sword["attack_bonus"] == 5
assert sword["damage"] == ({"formula": "1d6", "types": ("slashing",)},)
assert sword["damage_bonus"] == 2
assert sword["crit_range"] == 19
assert sword["crit_mult"] == 2
assert sword["reach_ft"] == 5
assert sword["range_increment_ft"] is None
assert sword["count"] == 1
assert sword["firearm"] is False
bow = attacks[1]
assert bow["id"] == "hero-bow"
assert bow["name"] == "short bow"
assert bow["kind"] == "ranged"
assert bow["attack_bonus"] == 4
assert bow["damage"] == ({"formula": "1d6", "types": ("piercing",)},)
assert bow["damage_bonus"] == 0
assert bow["crit_range"] == 20
assert bow["crit_mult"] == 3
assert bow["reach_ft"] == 5
assert bow["range_increment_ft"] == 60
assert bow["count"] == 1
assert bow["firearm"] is False
def test_observation_enriched_attacks_include_broken_and_stolen() -> None:
"""Given: a combatant whose melee weapon is broken and ranged weapon stolen
When: an Observation is built
Then: both weapons are still listed with their ids."""
hero = make_two_weapon_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
hero_state.weapon_broken.add("hero-sword")
hero_state.weapon_stolen.add("hero-bow")
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
attacks = obs["combatants"][0]["attacks"]
assert len(attacks) == 2
assert {a["id"] for a in attacks} == {"hero-sword", "hero-bow"}
assert obs["combatants"][0]["weapon_broken"] == ("hero-sword",)
assert obs["combatants"][0]["weapon_stolen"] == ("hero-bow",)
def test_observation_enriched_attacks_json_serializable() -> None:
"""Given: an Observation with the attacks reference
When: to_dict() is serialized
Then: json.dumps succeeds and the attacks dict is frozen."""
hero = make_two_weapon_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = engine._build_observation(hero_state)
data = obs.to_dict()
json.dumps(data) # must not raise
hero_state.weapon_broken.add("hero-sword")
data_after = obs.to_dict()
assert data_after == data
assert data_after["combatants"][0]["attacks"][0]["id"] == "hero-sword"
def test_observation_enriched_attacks_formula_uses_dice_to_str() -> None:
"""Given: a weapon with a bonus and a negative-bonus damage formula
When: an Observation is built
Then: the formula strings match dice_to_str canonical output."""
hero = make_two_weapon_combatant("hero")
hero = hero.model_copy(
update={
"attacks": [
hero.attacks[0].model_copy(
update={
"damage": [
DamageComponent(
formula=parse_dice("2d6+3"), types=["slashing"]
)
]
}
),
hero.attacks[1].model_copy(
update={
"damage": [
DamageComponent(
formula=parse_dice("2d6-1"), types=["piercing"]
)
]
}
),
]
}
)
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
attacks = obs["combatants"][0]["attacks"]
assert attacks[0]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6+3"))
assert attacks[1]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6-1"))