feat(combat): expose weapon reference in observation snapshot
This commit is contained in:
@@ -86,6 +86,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
|
from pf1e_simulator.dice import dice_to_str
|
||||||
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
||||||
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
||||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||||
@@ -613,6 +614,29 @@ class CombatEngine:
|
|||||||
},
|
},
|
||||||
"cmb": self._maneuver_cmb(state),
|
"cmb": self._maneuver_cmb(state),
|
||||||
"cmd": self._maneuver_cmd(state),
|
"cmd": self._maneuver_cmd(state),
|
||||||
|
"attacks": tuple(
|
||||||
|
{
|
||||||
|
"id": attack.id,
|
||||||
|
"name": attack.name,
|
||||||
|
"kind": attack.kind,
|
||||||
|
"attack_bonus": attack.attack_bonus,
|
||||||
|
"damage": tuple(
|
||||||
|
{
|
||||||
|
"formula": dice_to_str(component.formula),
|
||||||
|
"types": tuple(component.types),
|
||||||
|
}
|
||||||
|
for component in attack.damage
|
||||||
|
),
|
||||||
|
"damage_bonus": attack.damage_bonus,
|
||||||
|
"crit_range": attack.crit_range,
|
||||||
|
"crit_mult": attack.crit_mult,
|
||||||
|
"reach_ft": attack.reach_ft,
|
||||||
|
"range_increment_ft": attack.range_increment_ft,
|
||||||
|
"count": attack.count,
|
||||||
|
"firearm": attack.firearm,
|
||||||
|
}
|
||||||
|
for attack in state.combatant.attacks
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _pair_snapshot(
|
def _pair_snapshot(
|
||||||
|
|||||||
@@ -122,3 +122,20 @@ def parse_dice(text: str) -> DiceExpr:
|
|||||||
raise DiceParseError(text) from None
|
raise DiceParseError(text) from None
|
||||||
|
|
||||||
return DiceExpr.flat(n)
|
return DiceExpr.flat(n)
|
||||||
|
|
||||||
|
|
||||||
|
def dice_to_str(expr: DiceExpr) -> str:
|
||||||
|
"""Serialize a DiceExpr into canonical dice notation.
|
||||||
|
|
||||||
|
Inverse of `parse_dice`: flat expressions (count=0) render as the bare
|
||||||
|
integer, dice expressions render as "NdS" with a signed bonus appended
|
||||||
|
only when non-zero.
|
||||||
|
"""
|
||||||
|
if expr.count == 0:
|
||||||
|
return str(expr.bonus)
|
||||||
|
base = f"{expr.count}d{expr.sides}"
|
||||||
|
if expr.bonus > 0:
|
||||||
|
return f"{base}+{expr.bonus}"
|
||||||
|
if expr.bonus < 0:
|
||||||
|
return f"{base}{expr.bonus}"
|
||||||
|
return base
|
||||||
|
|||||||
+67
-1
@@ -6,7 +6,7 @@ from collections import Counter
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
from pf1e_simulator.dice import DiceExpr, DiceParseError, dice_to_str, parse_dice
|
||||||
from pf1e_simulator.rng import ScriptedRng
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
# ── Parsing: accepted forms ──────────────────────────────────────────────────
|
# ── Parsing: accepted forms ──────────────────────────────────────────────────
|
||||||
@@ -194,3 +194,69 @@ class TestDiceExprFlat:
|
|||||||
rng = ScriptedRng([])
|
rng = ScriptedRng([])
|
||||||
expr = DiceExpr.flat(5)
|
expr = DiceExpr.flat(5)
|
||||||
assert expr.roll(rng) == 5
|
assert expr.roll(rng) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── dice_to_str: canonical string representation ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiceToStr:
|
||||||
|
"""Given: a DiceExpr
|
||||||
|
When: dice_to_str is called
|
||||||
|
Then: returns the canonical string inverse of parse_dice."""
|
||||||
|
|
||||||
|
def test_2d6_plus_3(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=3)) == "2d6+3"
|
||||||
|
|
||||||
|
def test_1d4_no_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=1, sides=4, bonus=0)) == "1d4"
|
||||||
|
|
||||||
|
def test_flat_positive(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=7)) == "7"
|
||||||
|
|
||||||
|
def test_flat_negative(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=-5)) == "-5"
|
||||||
|
|
||||||
|
def test_flat_zero(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=0)) == "0"
|
||||||
|
|
||||||
|
def test_negative_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=-1)) == "2d6-1"
|
||||||
|
|
||||||
|
def test_d20_no_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=1, sides=20, bonus=0)) == "1d20"
|
||||||
|
|
||||||
|
def test_large_dice(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=10, sides=100, bonus=50)) == "10d100+50"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiceToStrRoundTrip:
|
||||||
|
"""Given: a valid dice string
|
||||||
|
When: parse_dice → dice_to_str → parse_dice
|
||||||
|
Then: the final DiceExpr equals the first."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"2d6+3",
|
||||||
|
"2d6-1",
|
||||||
|
"1d4",
|
||||||
|
"d20",
|
||||||
|
"7",
|
||||||
|
"-5",
|
||||||
|
"0",
|
||||||
|
"10d100+50",
|
||||||
|
"1d6",
|
||||||
|
"4d8-2",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_round_trip_equivalence(self, text: str) -> None:
|
||||||
|
first = parse_dice(text)
|
||||||
|
serialized = dice_to_str(first)
|
||||||
|
second = parse_dice(serialized)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
def test_round_trip_any_flat(self) -> None:
|
||||||
|
for n in (-100, -1, 0, 1, 42, 999):
|
||||||
|
expr = parse_dice(str(n))
|
||||||
|
assert dice_to_str(expr) == str(n)
|
||||||
|
assert parse_dice(dice_to_str(expr)) == expr
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
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.grid import Grid
|
||||||
from pf1e_simulator.map import MapSpec, TerrainType
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
from pf1e_simulator.models import (
|
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["saves"] == {"fort": 0, "ref": 0, "will": 0}
|
||||||
assert c["cmb"] == 5
|
assert c["cmb"] == 5
|
||||||
assert c["cmd"] == 15
|
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