From f3d874790aaa9d641b963d1d71313ce693ca5a6a Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 6 Sep 2026 13:46:12 +0200 Subject: [PATCH] feat(combat): expose weapon reference in observation snapshot --- src/pf1e_simulator/combat.py | 24 ++++ src/pf1e_simulator/dice.py | 17 +++ tests/test_dice.py | 68 ++++++++++- tests/test_observation_enriched.py | 177 ++++++++++++++++++++++++++++- 4 files changed, 284 insertions(+), 2 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b3c3056..ff8e337 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -86,6 +86,7 @@ from pathlib import Path from typing import TYPE_CHECKING 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.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS @@ -613,6 +614,29 @@ class CombatEngine: }, "cmb": self._maneuver_cmb(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( diff --git a/src/pf1e_simulator/dice.py b/src/pf1e_simulator/dice.py index 1f85299..5e45945 100644 --- a/src/pf1e_simulator/dice.py +++ b/src/pf1e_simulator/dice.py @@ -122,3 +122,20 @@ def parse_dice(text: str) -> DiceExpr: raise DiceParseError(text) from None 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 diff --git a/tests/test_dice.py b/tests/test_dice.py index 2cf7bd5..0ffe400 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -6,7 +6,7 @@ from collections import Counter 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 # ── Parsing: accepted forms ────────────────────────────────────────────────── @@ -194,3 +194,69 @@ class TestDiceExprFlat: rng = ScriptedRng([]) expr = DiceExpr.flat(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 diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 57d5d5f..4c03892 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -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"))