feat(combat): combat maneuvers CMB/CMD

This commit is contained in:
2026-09-04 22:55:43 +02:00
parent 8ae344fd16
commit 136ddcf7aa
2 changed files with 118 additions and 4 deletions
+114 -2
View File
@@ -251,9 +251,11 @@ 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",
] ]
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"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -894,7 +896,8 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif action.kind == "cast_spell": elif action.kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name) self._execute_cast_spell(state, target, action.spell_name)
elif action.kind == "maneuver":
self._execute_maneuver(state, target, action.maneuver)
def _execute_cast_spell( def _execute_cast_spell(
self, self,
state: CombatantState, state: CombatantState,
@@ -905,6 +908,72 @@ class CombatEngine:
return return
self._cast_spell(state, target, spell_name) self._cast_spell(state, target, spell_name)
def _execute_maneuver(self, state: CombatantState, target: CombatantState | None, maneuver: str | None) -> None:
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder)."""
if target is None or not target.active:
return
if self.has_condition_flag(state, "cant_attack"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
return
if maneuver not in ("trip", "disarm", "bull_rush", "sunder"):
return
# Calculate CMB and CMD
attacker_cmb = state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state))
target_cmd = target.combatant.cmd or (10 + (target.combatant.cmb or (target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target))) + target.combatant.abilities.dex_mod)
roll = self._rng.d20()
total = roll + attacker_cmb
success = roll != 1 and total >= target_cmd
if maneuver == "trip":
if success:
target.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["prone"],
rounds_left=1,
source=f"maneuver: trip",
)
)
target.conditions.append(CONDITIONS_BY_NAME["prone"])
self._log(f"round {self._current_round} {state.combatant.id}: trip vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (prone)")
else:
self._log(f"round {self._current_round} {state.combatant.id}: trip vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED")
elif maneuver == "disarm":
if success:
# Disarm: target's weapon is dropped (mark as unusable)
# For simplicity, apply a condition that prevents weapon use
target.active_conditions.append(
ActiveCondition(
condition=Condition(
name="disarmed",
modifiers=(StatModifier(target="attack", value=-4),),
),
rounds_left=1,
source=f"maneuver: disarm",
)
)
self._log(f"round {self._current_round} {state.combatant.id}: disarm vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (disarmed)")
else:
self._log(f"round {self._current_round} {state.combatant.id}: disarm vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED")
elif maneuver == "bull_rush":
if success:
# Push target back 5ft (1 cell) away from attacker
pushed = self._bull_rush_push(state, target)
if pushed:
self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (pushed)")
else:
self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (no space)")
else:
self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED")
elif maneuver == "sunder":
if success:
# Sunder: deal damage to weapon (simplified: deal damage to target)
dmg = state.combatant.abilities.str_mod + 2 # simplified
target.hp -= dmg
self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS ({dmg} damage to weapon)")
else:
self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED")
def _execute_attack( def _execute_attack(
self, state: CombatantState, target: CombatantState | None, kind: str self, state: CombatantState, target: CombatantState | None, kind: str
) -> None: ) -> None:
@@ -1484,7 +1553,50 @@ class CombatEngine:
self._transcript.append(line) self._transcript.append(line)
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def _size_mod(self, state: CombatantState) -> int:
"""Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc."""
size = state.combatant.size.lower()
if size == "fine":
return -8
elif size == "diminutive":
return -4
elif size == "tiny":
return -2
elif size == "small":
return -1
elif size == "medium":
return 0
elif size == "large":
return 1
elif size == "huge":
return 2
elif size == "gargantuan":
return 4
elif size == "colossal":
return 8
return 0
def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool:
"""Push target 5ft away from attacker. Returns True if push succeeded."""
# Calculate direction away from attacker
ar, ac = attacker.pos
tr, tc = target.pos
dr = tr - ar
dc = tc - ac
# Normalize to single step
if dr != 0:
dr = 1 if dr > 0 else -1
if dc != 0:
dc = 1 if dc > 0 else -1
new_pos = (tr + dr, tc + dc)
# Check if destination is valid (in bounds, not blocked)
if not (0 <= new_pos[0] < self._grid.height and 0 <= new_pos[1] < self._grid.width):
return False
# Check if occupied
if any(s.pos == new_pos for s in self._states if s.active and s is not target):
return False
target.pos = new_pos
return True
def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
"""Find a heal spell in known spells.""" """Find a heal spell in known spells."""
for name in spells_known: for name in spells_known:
+4 -2
View File
@@ -272,8 +272,10 @@ class Combatant(_FrozenModel):
source: str = "" source: str = ""
notes: str = "" notes: str = ""
features: list[AbilitySpec] = [] features: list[AbilitySpec] = []
spells: list[str] = [] skills: dict[str, SkillRank] = Field(default_factory=dict)
cmb: int | None = None # Combat Maneuver Bonus
cmd: int | None = None # Combat Maneuver Defense
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha
resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
skills: dict[str, SkillRank] = Field(default_factory=dict) spells: list[str] = Field(default_factory=list)