feat(combat): expose combat stats in observation snapshot
This commit is contained in:
@@ -596,6 +596,23 @@ class CombatEngine:
|
||||
"grappled_with": state.grappled_with,
|
||||
"moved_this_turn": state.moved_this_turn,
|
||||
"power_attack": state.power_attack,
|
||||
"name": state.combatant.name,
|
||||
"level": state.combatant.level,
|
||||
"size": state.combatant.size,
|
||||
"speed_land_ft": state.combatant.speed_land_ft,
|
||||
"speed_fly_ft": state.combatant.speed_fly_ft,
|
||||
"ac": {
|
||||
"total": state.combatant.ac.total,
|
||||
"touch": state.combatant.ac.touch,
|
||||
"flat_footed": state.combatant.ac.flat_footed,
|
||||
},
|
||||
"saves": {
|
||||
"fort": state.combatant.saves.fort,
|
||||
"ref": state.combatant.saves.ref,
|
||||
"will": state.combatant.saves.will,
|
||||
},
|
||||
"cmb": self._maneuver_cmb(state),
|
||||
"cmd": self._maneuver_cmd(state),
|
||||
}
|
||||
|
||||
def _pair_snapshot(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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 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
|
||||
Reference in New Issue
Block a user