feat(combat): add action economy framework (6 action types, sequence policy)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent bb53d049ee
commit 791199150b
+28 -10
View File
@@ -1,7 +1,12 @@
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
Phase 0 documented deviations from PF1e (conventions):
- One action per turn: move OR attack (no move+attack, no AoO, no flanking).
- Action economy framework: `Action.kind` covers the six PF1e action types
(attack=standard, move, full_round, swift, free, immediate); the engine
executes a policy-returned sequence of actions per turn. The default policy
currently emits one action per turn (attack OR move); standard+move
activation and special actions (AoO, flanking, full attack, charge,
withdraw) are deferred.
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks are not modeled.
@@ -106,9 +111,17 @@ class AttackResult:
@dataclass(frozen=True)
class Action:
"""What a policy wants a combatant to do this turn."""
"""What a policy wants a combatant to do this turn.
kind: Literal["attack", "move", "wait"]
Kinds map to the six PF1e action types: ``attack`` is a standard action
(single attack), ``move`` is a move action, ``full_round`` consumes the
whole turn (full attack, charge, withdraw — deferred), ``swift`` and
``free`` are minor actions, ``immediate`` is an off-turn reaction
(deferred). ``wait`` is a no-op. The engine executes a policy-returned
sequence per turn; the default policy currently emits one action.
"""
kind: Literal["attack", "move", "wait", "swift", "free", "immediate", "full_round"]
target_id: str | None = None
@@ -158,8 +171,13 @@ class CombatEngine:
for state in order:
if not state.active:
continue
action = self._policy(self, state)
self._execute(state, action, round_no)
actions = self._policy(self, state)
for action in actions:
if not state.active:
break
self._execute(state, action, round_no)
if self._winner_side() is not None:
break
if self._winner_side() is not None:
break
if self._winner_side() is not None:
@@ -379,14 +397,14 @@ class CombatEngine:
self._transcript.append(line)
Policy = Callable[[CombatEngine, CombatantState], Action]
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def default_policy(engine: CombatEngine, state: CombatantState) -> Action:
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Attack the nearest active enemy when a weapon is in range, else approach."""
target = engine.nearest_enemy(state)
if target is None:
return Action(kind="wait")
return (Action(kind="wait"),)
if engine.weapon_for(state, target) is not None:
return Action(kind="attack", target_id=target.combatant.id)
return Action(kind="move", target_id=target.combatant.id)
return (Action(kind="attack", target_id=target.combatant.id),)
return (Action(kind="move", target_id=target.combatant.id),)