feat(combat): combat maneuvers CMB/CMD
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user