feat(combat): expose weapon reference in observation snapshot
This commit is contained in:
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
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 (
|
||||
@@ -202,3 +202,178 @@ def test_observation_enriched_json_serializable_and_frozen() -> None:
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user