diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 25ac2b5..19f5cf7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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),)