feat(combat): class-ability action kinds, engine RNG only
This commit is contained in:
@@ -277,12 +277,15 @@ class Action:
|
|||||||
kind: Literal[
|
kind: Literal[
|
||||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
"maneuver", "channel_energy", "hex",
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
|
"inspire_courage",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
spell_name: str | None = None
|
spell_name: str | None = None
|
||||||
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape"
|
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape"
|
||||||
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
||||||
|
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||||
|
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -951,22 +954,36 @@ class CombatEngine:
|
|||||||
def _execute(self, state: CombatantState, action: Action) -> None:
|
def _execute(self, state: CombatantState, action: Action) -> None:
|
||||||
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
||||||
kind = action.kind
|
kind = action.kind
|
||||||
if kind in ("attack", "full_attack"):
|
if kind == "cast_spell":
|
||||||
self._execute_attack(state, target, kind)
|
self._execute_cast_spell(state, target, action.spell_name)
|
||||||
|
elif kind == "maneuver":
|
||||||
|
self._execute_maneuver(state, target, action.maneuver)
|
||||||
|
elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"):
|
||||||
|
self._execute_class_ability(state, target, action)
|
||||||
elif kind == "charge":
|
elif kind == "charge":
|
||||||
self._execute_charge(state, target)
|
self._execute_charge(state, target)
|
||||||
elif kind == "withdraw":
|
elif kind == "withdraw":
|
||||||
self._withdraw(state, target)
|
self._withdraw(state, target)
|
||||||
elif kind == "cast_spell":
|
elif kind in ("attack", "full_attack"):
|
||||||
self._execute_cast_spell(state, target, action.spell_name)
|
self._execute_attack(state, target, kind)
|
||||||
elif kind == "maneuver":
|
else:
|
||||||
self._execute_maneuver(state, target, action.maneuver)
|
self._execute_movement(state, target, kind)
|
||||||
elif kind == "channel_energy":
|
|
||||||
|
def _execute_class_ability(
|
||||||
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||||
|
) -> None:
|
||||||
|
"""Dispatch a class-ability action by kind, reading its carrier field."""
|
||||||
|
kind = action.kind
|
||||||
|
if kind == "channel_energy":
|
||||||
self._execute_channel_energy(state, target)
|
self._execute_channel_energy(state, target)
|
||||||
elif kind == "hex":
|
elif kind == "hex":
|
||||||
self._execute_hex(state, target, action.hex_name)
|
self._execute_hex(state, target, action.hex_name)
|
||||||
else:
|
elif kind == "deed":
|
||||||
self._execute_movement(state, target, kind)
|
self._execute_deed(state, target, action.deed_name)
|
||||||
|
elif kind == "spell_combat":
|
||||||
|
self._execute_spell_combat(state, target, action.spell_name)
|
||||||
|
elif kind == "inspire_courage":
|
||||||
|
self._execute_inspire_courage(state, target)
|
||||||
|
|
||||||
def _execute_movement(
|
def _execute_movement(
|
||||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Determinism regression and class-ability action routing tests.
|
||||||
|
|
||||||
|
Every die roll in the engine flows through the injected ``Rng`` stream, never
|
||||||
|
a module-global ``random``. Two runs with the same seed must produce identical
|
||||||
|
transcripts — including a channel-energy caster that draws from the engine RNG
|
||||||
|
via ``roll_die``. The class-ability ``Action`` kinds (deed, spell_combat,
|
||||||
|
inspire_courage, hex, channel_energy) must route through ``_execute`` and read
|
||||||
|
their carrier fields without raising AttributeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
Action,
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
CombatResult,
|
||||||
|
default_policy,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.loaders.combatant import load_combatant
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.rng import SeededRng
|
||||||
|
|
||||||
|
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
_ONI_JSON = _DATA_DIR / "pcs" / "oni.json"
|
||||||
|
|
||||||
|
|
||||||
|
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_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
"""Emit a channel_energy on an alpha, otherwise the default policy."""
|
||||||
|
if state.combatant.id == "oni-a":
|
||||||
|
return (Action(kind="channel_energy", target_id="oni-b"),)
|
||||||
|
return default_policy(_engine, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_state(combatant_id: str, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||||
|
combatant = load_combatant(_ONI_JSON)
|
||||||
|
effective = combatant if combatant.id == combatant_id else combatant.model_copy(
|
||||||
|
update={"id": combatant_id}
|
||||||
|
)
|
||||||
|
return CombatantState(
|
||||||
|
combatant=effective,
|
||||||
|
side=side,
|
||||||
|
pos=pos,
|
||||||
|
hp=effective.hp_max,
|
||||||
|
resources_left=dict(effective.resources),
|
||||||
|
deed_up_close_and_deadly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_energy_draws_from_engine_rng_same_seed_identical_transcripts() -> None:
|
||||||
|
"""Given the same seed, two runs of a channel-energy caster have identical transcripts."""
|
||||||
|
|
||||||
|
def make_run() -> CombatResult:
|
||||||
|
states = [
|
||||||
|
_make_state("oni-a", "players", (1, 2)),
|
||||||
|
_make_state("oni-b", "monsters", (2, 4)),
|
||||||
|
]
|
||||||
|
return CombatEngine(
|
||||||
|
SeededRng(7), _make_grid(), states, policy=_make_channel_policy
|
||||||
|
).run()
|
||||||
|
|
||||||
|
result_one = make_run()
|
||||||
|
result_two = make_run()
|
||||||
|
|
||||||
|
assert result_one.transcript == result_two.transcript
|
||||||
|
assert result_one.transcript
|
||||||
|
assert any("channel" in line for line in result_one.transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_class_ability_action_kinds_route_without_attribute_error() -> None:
|
||||||
|
"""Given class-ability actions, _execute dispatches them reading carrier fields."""
|
||||||
|
attacker = _make_state("oni-att", "players", (1, 2))
|
||||||
|
target = _make_state("oni-tgt", "monsters", (2, 4))
|
||||||
|
states = [attacker, target]
|
||||||
|
engine = CombatEngine(SeededRng(9), _make_grid(), states)
|
||||||
|
|
||||||
|
cases: list[Action] = [
|
||||||
|
Action(kind="deed", target_id="oni-tgt", deed_name="up_close_and_deadly"),
|
||||||
|
Action(kind="spell_combat", target_id="oni-tgt"),
|
||||||
|
Action(kind="inspire_courage", target_id="oni-tgt"),
|
||||||
|
Action(kind="hex", target_id="oni-tgt", hex_name="evil_eye"),
|
||||||
|
Action(kind="channel_energy", target_id="oni-tgt"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for action in cases:
|
||||||
|
engine._execute(attacker, action)
|
||||||
Reference in New Issue
Block a user