feat(combat): surface explicit action results and reject invalid actions

This commit is contained in:
2026-09-10 11:51:02 +02:00
parent e6125b04f6
commit 4e115f9176
3 changed files with 375 additions and 7 deletions
+115 -6
View File
@@ -340,6 +340,10 @@ class CombatantState:
# Dying & stabilization (not in the frozen data model): the hp at which
# this combatant stabilized while dying; None = not stabilized.
stabilized_hp: int | None = None
# Action feedback (not in the frozen data model): the ActionResults of the
# PREVIOUS turn, assigned at the end of each turn so the Observation built
# at the next policy invocation can expose them.
last_action_results: list[ActionResult] = field(default_factory=list)
def usable_attacks(self) -> list[AttackSpec]:
return [
@@ -516,6 +520,49 @@ class Action:
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
# missing_target and the downgraded:* reasons; no_weapon, out_of_range,
# no_line_of_effect, no_slot and insufficient_resource are declared here for
# the deeper execution-feedback package (U6b).
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource",
"downgraded:staggered", "downgraded:nauseated",
})
_REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
_REASON_MISSING_TARGET = "missing_target"
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
# Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops).
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
# Action kinds whose helper is a silent no-op (or worse) without a resolved
# target; _execute rejects them up front with missing_target instead.
_TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({
"attack", "full_attack", "charge", "withdraw", "move", "5foot_step",
"cast_spell", "maneuver",
})
@dataclass(frozen=True)
class ActionResult:
"""Explicit outcome of executing one Action (feedback for the policy).
``ok`` is True only when the action was dispatched to its helper;
rejections (``unsupported_kind``, ``missing_target``) and filter
downgrades (``downgraded:*``) carry ok=False and a reason drawn from
``ACTION_RESULT_REASONS``. ``detail`` is reserved for future packages.
"""
kind: str
ok: bool
reason: str = ""
detail: str = ""
@dataclass(frozen=True)
class ReactionEvent:
"""An off-turn event a ``reaction_policy`` may react to.
@@ -866,6 +913,10 @@ class CombatEngine:
else 0
),
"engaged_in_melee": self._engaged_in_melee(state),
"last_turn_results": tuple(
{"kind": r.kind, "ok": r.ok, "reason": r.reason}
for r in state.last_action_results
),
}
def _pair_snapshot(
@@ -912,27 +963,49 @@ class CombatEngine:
# Check cant_act flag from conditions
if self.has_condition_flag(state, "cant_act"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)")
state.last_action_results = []
return self._winner_side() is not None
self._apply_fear_aura(state)
state.moved_this_turn = False
state.power_attack = False
actions = self._policy(self, state)
original = self._policy(self, state)
actions = original
results: list[ActionResult] = []
if state.immediate_used:
actions = _without_swift_bonus(actions)
kept = _without_swift_bonus(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
state.immediate_used = False
if self._has_condition_named(state, "staggered"):
actions = _staggered_filter(actions)
kept = _staggered_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
elif self.has_condition_flag(state, "cant_attack"):
actions = _nauseated_filter(actions)
kept = _nauseated_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
before = len(self._transcript)
for action in actions:
if not state.active:
break
self._execute(state, action)
results.append(self._execute(state, action))
if self._winner_side() is not None:
break
if state.active and len(self._transcript) == before:
self._log(f"round {self._current_round} {state.combatant.id}: wait")
# Assigned at the END of the turn (never reset at the start): the
# Observation is built inside the policy call at the START of the next
# turn, so it must still see THIS turn's results at that moment.
state.last_action_results = results
return self._winner_side() is not None
def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
@@ -2227,8 +2300,36 @@ class CombatEngine:
attacker, target, weapon, label="charge", bonus_override=bonus
)
def _execute(self, state: CombatantState, action: Action) -> None:
def _execute(self, state: CombatantState, action: Action) -> ActionResult:
"""Execute one action and return its explicit result.
Rejections (unsupported kinds, missing target) are logged and never
reach the helpers; everything else dispatches exactly as before and
reports ok.
"""
kind = action.kind
if kind in _UNSUPPORTED_ACTION_KINDS:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNSUPPORTED_KIND})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNSUPPORTED_KIND
)
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if target is None and kind in _TARGET_REQUIRED_ACTION_KINDS:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_MISSING_TARGET})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
self._dispatch_action(state, target, action)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> None:
"""Route a validated action to its execution helper."""
kind = action.kind
if kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name)
@@ -4016,6 +4117,14 @@ def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS)
def _dropped_kinds(
before: tuple[Action, ...], after: tuple[Action, ...]
) -> tuple[str, ...]:
"""Kinds present in ``before`` but absent from the filtered ``after``."""
kept = {a.kind for a in after}
return tuple(a.kind for a in before if a.kind not in kept)
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
"""Staggered: one move OR one standard action per round, no full-round.