feat(combat): combat maneuvers CMB/CMD
This commit is contained in:
@@ -251,9 +251,11 @@ class Action:
|
||||
kind: Literal[
|
||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||
"maneuver",
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -894,7 +896,8 @@ class CombatEngine:
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
||||
elif action.kind == "cast_spell":
|
||||
self._execute_cast_spell(state, target, action.spell_name)
|
||||
|
||||
elif action.kind == "maneuver":
|
||||
self._execute_maneuver(state, target, action.maneuver)
|
||||
def _execute_cast_spell(
|
||||
self,
|
||||
state: CombatantState,
|
||||
@@ -905,6 +908,72 @@ class CombatEngine:
|
||||
return
|
||||
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(
|
||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||
) -> None:
|
||||
@@ -1484,7 +1553,50 @@ class CombatEngine:
|
||||
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:
|
||||
"""Find a heal spell in known spells."""
|
||||
for name in spells_known:
|
||||
|
||||
@@ -272,8 +272,10 @@ class Combatant(_FrozenModel):
|
||||
source: str = ""
|
||||
notes: str = ""
|
||||
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
|
||||
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
|
||||
skills: dict[str, SkillRank] = Field(default_factory=dict)
|
||||
spells: list[str] = Field(default_factory=list)
|
||||
|
||||
Reference in New Issue
Block a user