From 7336109ab20d30a81c4b8dfb242de24fd62b0c49 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:49 +0200 Subject: [PATCH] chore(lint): restore green gate --- src/pf1e_simulator/combat.py | 1034 +++++++++++++---------- src/pf1e_simulator/effects.py | 1 + src/pf1e_simulator/loaders/combatant.py | 51 +- src/pf1e_simulator/models.py | 10 + src/pf1e_simulator/spells.py | 152 +++- tests/test_abilities.py | 11 +- tests/test_combatant_loader.py | 5 +- tests/test_power_attack.py | 6 +- tests/test_spells.py | 3 +- 9 files changed, 733 insertions(+), 540 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 372e685..e0a96d7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -75,11 +75,14 @@ Phase 0 documented deviations from PF1e (conventions): from __future__ import annotations +from collections.abc import Callable from dataclasses import asdict, dataclass, field +from pathlib import Path from typing import TYPE_CHECKING -from pf1e_simulator.conditions import CONDITIONS_BY_NAME +from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers +from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.spells import ( AreaEffect, @@ -95,7 +98,6 @@ from pf1e_simulator.spells import ( if TYPE_CHECKING: from typing import Literal - from pf1e_simulator.conditions import Condition from pf1e_simulator.grid import Grid from pf1e_simulator.map import Pos from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction @@ -117,6 +119,13 @@ _MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1) _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) +_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft + + +_SIZE_MODIFIERS: dict[str, int] = { + "fine": -8, "diminutive": -4, "tiny": -2, "small": -1, + "medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8, +} def _save_label(result: SaveResult | None) -> str: @@ -187,6 +196,10 @@ class CombatantState: resources_left: dict[str, int] = field(default_factory=dict) moved_this_turn: bool = False power_attack: bool = False + grappled_with: str | None = None + deed_up_close_and_deadly: bool = False + summon_duration: int = 0 + summon_caster_id: str | None = None def spend_resource(self, name: str, n: int = 1) -> bool: """Spend n points of a resource. Returns True if successful, False if insufficient.""" @@ -251,11 +264,12 @@ class Action: kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", - "maneuver", + "maneuver", "channel_energy", "hex", ] target_id: str | None = None spell_name: str | None = None maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape" + hex_name: str | None = None # "evil_eye", "slumber", "cackle" @dataclass(frozen=True) @@ -276,6 +290,16 @@ class _LiveStats: damage_taken: int = 0 +@dataclass +class _ManeuverRoll: + """Roll context shared by all combat-maneuver handlers.""" + roll: int + attacker_cmb: int + total: int + target_cmd: int + success: bool + + class CombatEngine: """Runs one battle to completion on a grid with a single RNG stream.""" @@ -300,6 +324,21 @@ class CombatEngine: self._current_round = 0 self._aoo_used: set[str] = set() + @property + def states(self) -> list[CombatantState]: + """The live list of combatant states in initiative order.""" + return self._states + + @property + def grid(self) -> Grid: + """The combat grid.""" + return self._grid + + @property + def spell_registry(self) -> dict[str, SpellSpec]: + """The spell registry used for spell lookups.""" + return self._spell_registry + def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" self._decrement_durations(state) @@ -878,30 +917,39 @@ class CombatEngine: def _execute(self, state: CombatantState, action: Action) -> None: target = next((s for s in self._states if s.combatant.id == action.target_id), None) - if action.kind in ("attack", "full_attack"): - self._execute_attack(state, target, action.kind) - elif action.kind == "charge": + kind = action.kind + if kind in ("attack", "full_attack"): + self._execute_attack(state, target, kind) + elif kind == "charge": self._execute_charge(state, target) - elif action.kind == "withdraw": + elif kind == "withdraw": self._withdraw(state, target) - elif action.kind == "5foot_step": + elif kind == "cast_spell": + self._execute_cast_spell(state, target, action.spell_name) + elif kind == "maneuver": + self._execute_maneuver(state, target, action.maneuver) + elif kind == "channel_energy": + self._execute_channel_energy(state, target) + elif kind == "hex": + self._execute_hex(state, target, action.hex_name) + else: + self._execute_movement(state, target, kind) + + def _execute_movement( + self, state: CombatantState, target: CombatantState | None, kind: str + ) -> None: + """Execute a movement-style action (5foot_step, move, wait).""" + if kind == "5foot_step": if target is None: return self._step5(state, target) - elif action.kind == "move": + elif kind == "move": if target is None: return self._move(state, target) - elif action.kind == "wait": + elif kind == "wait": 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) - elif action.kind == "channel_energy": - self._execute_channel_energy(state, target) - elif action.kind == "hex": - self._execute_hex(state, target, action.hex_name) + def _execute_cast_spell( self, state: CombatantState, @@ -912,252 +960,372 @@ class CombatEngine: return self._cast_spell(state, target, spell_name) - def _execute_maneuver(self, state: CombatantState, target: CombatantState | None, maneuver: str | None) -> None: + 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", "grapple", "escape"): + if maneuver == "escape": + self._maneuver_escape(state, target) + return + if maneuver not in ("trip", "disarm", "bull_rush", "sunder", "grapple"): 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) - + attacker_cmb = self._maneuver_cmb(state) + target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() total = roll + attacker_cmb - success = roll != 1 and total >= target_cmd + m = _ManeuverRoll( + roll=roll, attacker_cmb=attacker_cmb, total=total, + target_cmd=target_cmd, 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="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="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") - elif maneuver == "grapple": - if success: - # Initiate grapple - target.active_conditions.append( - ActiveCondition( - condition=CONDITIONS_BY_NAME["grappled"], - rounds_left=10, # Lasts until broken - source=f"maneuver: grapple by {state.combatant.id}", - ) - ) - target.conditions.append(CONDITIONS_BY_NAME["grappled"]) - target.grappled_with = state.combatant.id - state.grappled_with = target.combatant.id - self._log(f"round {self._current_round} {state.combatant.id}: grapple vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (grappled)") - else: - self._log(f"round {self._current_round} {state.combatant.id}: grapple vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") - elif maneuver == "escape": - # Escape uses CMB or Escape Artist skill - escape_bonus = max( - state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)), - self.resolve_skill(state, "escape_artist", 0).total if hasattr(state.combatant, 'skills') and 'escape_artist' in state.combatant.skills else 0 + handlers: dict[str, Callable[[], None]] = { + "trip": lambda: self._maneuver_trip(state, target, m), + "disarm": lambda: self._maneuver_disarm(state, target, m), + "bull_rush": lambda: self._maneuver_bull_rush(state, target, m), + "sunder": lambda: self._maneuver_sunder(state, target, m), + "grapple": lambda: self._maneuver_grapple(state, target, m), + } + handlers[maneuver]() + + def _maneuver_cmb(self, state: CombatantState) -> int: + return ( + state.combatant.cmb + or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)) + ) + + def _maneuver_cmd(self, target: CombatantState) -> int: + target_cmb = self._maneuver_cmb(target) + return target.combatant.cmd or (10 + target_cmb + target.combatant.abilities.dex_mod) + + def _maneuver_trip( + self, state: CombatantState, target: CombatantState, m: _ManeuverRoll + ) -> None: + if not m.success: + self._maneuver_fail(state, target, "trip", m) + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["prone"], + rounds_left=1, + source="maneuver: trip", ) - target_cmb = target.combatant.cmb or (target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target)) - target_cmd = 10 + target_cmb + target.combatant.abilities.dex_mod - roll = self._rng.d20() - total = roll + escape_bonus - success = roll != 1 and total >= target_cmd - if success: - # Remove grappled condition from both - target.active_conditions = [ac for ac in target.active_conditions if ac.condition.name != "grappled"] - target.conditions = [c for c in target.conditions if c.name != "grappled"] - state.active_conditions = [ac for ac in state.active_conditions if ac.condition.name != "grappled"] - state.conditions = [c for c in state.conditions if c.name != "grappled"] - target.grappled_with = None - state.grappled_with = None - else: - self._log(f"round {self._current_round} {state.combatant.id}: escape vs {target.combatant.id} d20={roll}+{escape_bonus}={total} CMD {target_cmd} -> FAILED") + ) + target.conditions.append(CONDITIONS_BY_NAME["prone"]) + self._log( + f"round {self._current_round} {state.combatant.id}: trip vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (prone)" + ) - def _execute_channel_energy(self, state: CombatantState, target: CombatantState | None) -> None: - """Execute channel energy (burst 30ft, 2d6 heal/damage, consumes channel_energy resource).""" + def _maneuver_disarm( + self, state: CombatantState, target: CombatantState, m: _ManeuverRoll + ) -> None: + if not m.success: + self._maneuver_fail(state, target, "disarm", m) + return + target.active_conditions.append( + ActiveCondition( + condition=Condition( + name="disarmed", + modifiers=(StatModifier(target="attack", value=-4),), + ), + rounds_left=1, + source="maneuver: disarm", + ) + ) + self._log( + f"round {self._current_round} {state.combatant.id}: disarm vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (disarmed)" + ) + + def _maneuver_bull_rush( + self, state: CombatantState, target: CombatantState, m: _ManeuverRoll + ) -> None: + if not m.success: + self._maneuver_fail(state, target, "bull_rush", m) + return + pushed = self._bull_rush_push(state, target) + outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)" + self._log( + f"round {self._current_round} {state.combatant.id}: bull rush vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> {outcome}" + ) + + def _maneuver_sunder( + self, state: CombatantState, target: CombatantState, m: _ManeuverRoll + ) -> None: + if not m.success: + self._maneuver_fail(state, target, "sunder", m) + return + dmg = state.combatant.abilities.str_mod + 2 + target.hp -= dmg + self._log( + f"round {self._current_round} {state.combatant.id}: sunder vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)" + ) + + def _maneuver_grapple( + self, state: CombatantState, target: CombatantState, m: _ManeuverRoll + ) -> None: + if not m.success: + self._maneuver_fail(state, target, "grapple", m) + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["grappled"], + rounds_left=10, + source=f"maneuver: grapple by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["grappled"]) + target.grappled_with = state.combatant.id + state.grappled_with = target.combatant.id + self._log( + f"round {self._current_round} {state.combatant.id}: grapple vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (grappled)" + ) + + def _maneuver_fail( + self, state: CombatantState, target: CombatantState, maneuver: str, + m: _ManeuverRoll, + ) -> None: + self._log( + f"round {self._current_round} {state.combatant.id}: {maneuver} vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> FAILED" + ) + + def _maneuver_escape( + self, state: CombatantState, target: CombatantState + ) -> None: + escape_cmb = self._maneuver_cmb(state) + escape_skill = ( + self.resolve_skill(state, "escape_artist", 0).total + if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills + else 0 + ) + escape_bonus = max(escape_cmb, escape_skill) + target_cmd = self._maneuver_cmd(target) + roll = self._rng.d20() + total = roll + escape_bonus + success = roll != 1 and total >= target_cmd + if success: + target.active_conditions = [ + ac for ac in target.active_conditions if ac.condition.name != "grappled" + ] + target.conditions = [c for c in target.conditions if c.name != "grappled"] + state.active_conditions = [ + ac for ac in state.active_conditions if ac.condition.name != "grappled" + ] + state.conditions = [c for c in state.conditions if c.name != "grappled"] + target.grappled_with = None + state.grappled_with = None + else: + self._log( + f"round {self._current_round} {state.combatant.id}: escape vs " + f"{target.combatant.id} d20={roll}+{escape_bonus}={total} " + f"CMD {target_cmd} -> FAILED" + ) + + def _execute_channel_energy( + self, state: CombatantState, target: CombatantState | None + ) -> None: + """Execute channel energy (burst 30ft, 2d6 heal/damage, spends channel energy).""" if not state.spend_resource("channel_energy", 1): - self._log(f"round {self._current_round} {state.combatant.id}: channel energy failed (no resources)") + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy failed (no resources)" + ) return if target is None or not target.active: return - + # Channel energy affects all creatures within 30ft (6 cells) radius_cells = 30 // _SQUARE_FT # 6 cells center = state.pos - affected: list[CombatantState] = [] - for state_other in self._states: - if not state_other.active: - continue - dist = self._grid.distance(center, state_other.pos) - if dist <= radius_cells: - affected.append(state_other) - + affected = self._creatures_in_range(center, radius_cells) + if not affected: - self._log(f"round {self._current_round} {state.combatant.id}: channel energy - no targets in range") + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy - no targets in range" + ) return - + # Determine if healing or harming based on target relationship - is_harm = target is not None and target.side != state.side - + is_harm = target.side != state.side + # 2d6 damage/healing - import random - dmg = random.randint(2, 12) # 2d6 - + dmg = 1 + self._rng.roll_die(11) # 2d6 + for affected_state in affected: is_ally = affected_state.side == state.side if is_ally and not is_harm: - # Heal allies - old_hp = affected_state.hp - affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) - actual = affected_state.hp - old_hp - self._stats[state.combatant.id].damage_dealt += actual - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"channel energy heals {affected_state.combatant.id} +{actual} HP" - ) + self._channel_heal(state, affected_state, dmg) elif not is_ally and is_harm: - # Damage enemies - affected_state.hp -= dmg - self._stats[state.combatant.id].damage_dealt += dmg - self._stats[affected_state.combatant.id].damage_taken += dmg - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"channel energy damages {affected_state.combatant.id} {dmg} ({affected_state.hp_before if hasattr(affected_state, 'hp_before') else '?'}->{affected_state.hp})" - ) - if affected_state.dead: - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"{affected_state.combatant.id} dead" - ) - else: - # Neutral (enemies when healing, allies when harming) - pass - + self._channel_damage(state, affected_state, dmg) + self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy ({'harm' if is_harm else 'heal'}) {dmg} -> {len(affected)} targets" ) - def _execute_hex(self, state: CombatantState, target: CombatantState | None, hex_name: str | None) -> None: + def _channel_heal( + self, state: CombatantState, affected_state: CombatantState, dmg: int + ) -> None: + old_hp = affected_state.hp + affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) + actual = affected_state.hp - old_hp + self._stats[state.combatant.id].damage_dealt += actual + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy heals {affected_state.combatant.id} +{actual} HP" + ) + + def _channel_damage( + self, state: CombatantState, affected_state: CombatantState, dmg: int + ) -> None: + hp_before = affected_state.hp + affected_state.hp -= dmg + self._stats[state.combatant.id].damage_dealt += dmg + self._stats[affected_state.combatant.id].damage_taken += dmg + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy damages {affected_state.combatant.id} {dmg} " + f"({hp_before}->{affected_state.hp})" + ) + if affected_state.dead: + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{affected_state.combatant.id} dead" + ) + + def _execute_hex( + self, state: CombatantState, target: CombatantState | None, hex_name: str | None + ) -> None: """Execute a witch hex (evil_eye, slumber, cackle).""" if not state.spend_resource("hexes", 1): - self._log(f"round {self._current_round} {state.combatant.id}: hex failed (no resources)") + self._log( + f"round {self._current_round} {state.combatant.id}: hex failed (no resources)" + ) return if target is None or not target.active: return if hex_name not in ("evil_eye", "slumber", "cackle"): - self._log(f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}") + self._log( + f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}" + ) return - - if hex_name == "evil_eye": - # Evil Eye: shaken (-2 AC or saves), 1 round, Will negates - save_result = self.resolve_save(target, "will", 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2) - if save_result.success: - self._log(f"round {self._current_round} {state.combatant.id}: evil eye on {target.combatant.id} -> SAVED") - return - target.active_conditions.append( - ActiveCondition( - condition=CONDITIONS_BY_NAME["shaken"], - rounds_left=1, - source=f"hex: evil_eye by {state.combatant.id}", - ) - ) - target.conditions.append(CONDITIONS_BY_NAME["shaken"]) - self._log(f"round {self._current_round} {state.combatant.id}: evil eye on {target.combatant.id} -> shaken (1r)") - - elif hex_name == "slumber": - # Slumber: asleep/disabled if Will fails - save_result = self.resolve_save(target, "will", 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2) - if save_result.success: - self._log(f"round {self._current_round} {state.combatant.id}: slumber on {target.combatant.id} -> SAVED") - return - target.active_conditions.append( - ActiveCondition( - condition=CONDITIONS_BY_NAME["asleep"], - rounds_left=1, - source=f"hex: slumber by {state.combatant.id}", - ) - ) - target.conditions.append(CONDITIONS_BY_NAME["asleep"]) - self._log(f"round {self._current_round} {state.combatant.id}: slumber on {target.combatant.id} -> asleep (1r)") - - elif hex_name == "cackle": - # Cackle: prolong one hex by 1 round - # For simplicity, extend all hex conditions on target by 1 round - extended = False - for ac in target.active_conditions: - if ac.source.startswith("hex:"): - # Can't modify frozen dataclass, need to recreate - target.active_conditions.remove(ac) - target.active_conditions.append( - ActiveCondition( - condition=ac.condition, - rounds_left=ac.rounds_left + 1, - source=ac.source, - ) - ) - extended = True - if extended: - self._log(f"round {self._current_round} {state.combatant.id}: cackle extends hex on {target.combatant.id}") - else: - self._log(f"round {self._current_round} {state.combatant.id}: cackle - no hex to extend on {target.combatant.id}") - def _execute_deed(self, state: CombatantState, target: CombatantState | None, deed_name: str | None) -> None: + hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2 + + if hex_name == "evil_eye": + self._hex_evil_eye(state, target, hex_dc) + elif hex_name == "slumber": + self._hex_slumber(state, target, hex_dc) + elif hex_name == "cackle": + self._hex_cackle(state, target) + + def _hex_evil_eye( + self, state: CombatantState, target: CombatantState, hex_dc: int + ) -> None: + """Evil Eye: shaken (-2 AC or saves), 1 round, Will negates.""" + save_result = self.resolve_save(target, "will", hex_dc) + if save_result.success: + self._log( + f"round {self._current_round} {state.combatant.id}: evil eye on " + f"{target.combatant.id} -> SAVED" + ) + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["shaken"], + rounds_left=1, + source=f"hex: evil_eye by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["shaken"]) + self._log( + f"round {self._current_round} {state.combatant.id}: evil eye on " + f"{target.combatant.id} -> shaken (1r)" + ) + + def _hex_slumber( + self, state: CombatantState, target: CombatantState, hex_dc: int + ) -> None: + """Slumber: asleep/disabled if Will fails.""" + save_result = self.resolve_save(target, "will", hex_dc) + if save_result.success: + self._log( + f"round {self._current_round} {state.combatant.id}: slumber on " + f"{target.combatant.id} -> SAVED" + ) + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["asleep"], + rounds_left=1, + source=f"hex: slumber by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["asleep"]) + self._log( + f"round {self._current_round} {state.combatant.id}: slumber on " + f"{target.combatant.id} -> asleep (1r)" + ) + + def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None: + """Cackle: prolong one hex by 1 round (extends all hex conditions).""" + extended = False + for ac in target.active_conditions: + if ac.source.startswith("hex:"): + target.active_conditions.remove(ac) + target.active_conditions.append( + ActiveCondition( + condition=ac.condition, + rounds_left=ac.rounds_left + 1, + source=ac.source, + ) + ) + extended = True + if extended: + self._log( + f"round {self._current_round} {state.combatant.id}: cackle extends " + f"hex on {target.combatant.id}" + ) + else: + self._log( + f"round {self._current_round} {state.combatant.id}: cackle - no hex " + f"to extend on {target.combatant.id}" + ) + + def _execute_deed( + self, state: CombatantState, target: CombatantState | None, deed_name: str | None + ) -> None: """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" if deed_name not in ("up_close_and_deadly", "quick_clear"): return if not state.spend_resource("grit", 1): - self._log(f"round {self._current_round} {state.combatant.id}: deed failed (no grit)") + self._log( + f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" + ) return if deed_name == "up_close_and_deadly": # Up Close and Deadly: +1d6 damage within 30ft if target is None or not target.active: return dist_ft = self._grid.distance(state.pos, target.pos) * _SQUARE_FT - if dist_ft <= 30: + if dist_ft <= _UP_CLOSE_RANGE_FT: # Apply +1d6 damage to next attack this round state.deed_up_close_and_deadly = True self._log( @@ -1165,7 +1333,10 @@ class CombatEngine: f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" ) else: - self._log(f"round {self._current_round} {state.combatant.id}: deed Up Close and Deadly - target out of range") + self._log( + f"round {self._current_round} {state.combatant.id}: deed Up Close " + f"and Deadly - target out of range" + ) elif deed_name == "quick_clear": # Quick Clear: swift action, clears misfire self._log( @@ -1174,9 +1345,11 @@ class CombatEngine: ) # In a full implementation, this would clear the misfire condition - def _execute_spell_combat(self, state: CombatantState, target: CombatantState | None, spell_name: str | None) -> None: + def _execute_spell_combat( + self, state: CombatantState, target: CombatantState | None, spell_name: str | None + ) -> None: """Execute Spell Combat (cast spell + full attack in same round).""" - if target is None or not target.active: + if target is None or not target.active or spell_name is None: return if not state.spend_resource("arcane_pool", 1): # Arcane pool not tracked yet, allow for now @@ -1186,10 +1359,15 @@ class CombatEngine: # Then make a full attack self._execute_attack(state, target, "full_attack") - def _execute_inspire_courage(self, state: CombatantState, target: CombatantState | None) -> None: + def _execute_inspire_courage( + self, state: CombatantState, target: CombatantState | None + ) -> None: """Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds).""" if not state.spend_resource("bardic_rounds", 1): - self._log(f"round {self._current_round} {state.combatant.id}: inspire courage failed (no bardic rounds)") + self._log( + f"round {self._current_round} {state.combatant.id}: inspire courage " + f"failed (no bardic rounds)" + ) return if target is None: target = state @@ -1204,11 +1382,14 @@ class CombatEngine: dist = self._grid.distance(center, state_other.pos) if dist <= radius_cells: affected.append(state_other) - + if not affected: - self._log(f"round {self._current_round} {state.combatant.id}: inspire courage - no allies in range") + self._log( + f"round {self._current_round} {state.combatant.id}: inspire courage - " + f"no allies in range" + ) return - + # Apply +1 morale bonus to attack and damage for ally in affected: ally.active_buffs.append( @@ -1236,6 +1417,8 @@ class CombatEngine: if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return + if target is None: + return weapon = self.weapon_for(state, target) if weapon is None: return @@ -1299,7 +1482,7 @@ class CombatEngine: hp_before = target.hp parts = self._apply_spell_effects_from_list( metamagic_effects, target, save_result, caster_live, target_live, - caster, spell_name=spell.name, + caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats, ) @@ -1337,8 +1520,8 @@ class CombatEngine: save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, - caster: CombatantState, *, + caster: CombatantState, spell_name: str, empower: bool = False, maximize: bool = False, @@ -1346,9 +1529,9 @@ class CombatEngine: saved = save_result is not None and save_result.success return [ self._apply_single_spell_effect( - effect, target, caster_live, target_live, caster, - spell_name=spell_name, empower=empower, maximize=maximize, - saved=saved + effect, target, caster_live, target_live, + caster=caster, spell_name=spell_name, + empower=empower, maximize=maximize, saved=saved, ) for effect in effects ] @@ -1367,48 +1550,91 @@ class CombatEngine: saved: bool, ) -> str: if isinstance(effect, AreaEffect): - return self._apply_area_effect(effect, target, caster_live, target_live, caster, spell_name, saved, empower=empower, maximize=maximize) + return self._apply_area_effect( + effect, target, caster_live, target_live, + caster=caster, spell_name=spell_name, saved=saved, + empower=empower, maximize=maximize, + ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) if isinstance(effect, DamageEffect): - raw = effect.formula.roll(self._rng) - if maximize: - raw = effect.formula.max_value() - dmg = max(0, raw // 2) if saved and effect.half_on_save else raw - if empower: - dmg = int(dmg * 1.5) - dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) - target.hp -= dmg - caster_live.damage_dealt += dmg - target_live.damage_taken += dmg - return f"{dmg} damage" + return self._apply_damage_effect( + effect, target, caster_live, target_live, + saved=saved, empower=empower, maximize=maximize, + ) if isinstance(effect, ConditionEffect): - if saved and effect.save_negates: - return f"{effect.condition_name} negated" - cond = CONDITIONS_BY_NAME.get(effect.condition_name) - if cond is not None: - target.active_conditions.append( - ActiveCondition( - condition=cond, - rounds_left=effect.duration_rounds, - source=f"spell: {spell_name}", - ) - ) - # Also update old conditions field for backwards compatibility - target.conditions.append(cond) - return f"{effect.condition_name} ({effect.duration_rounds}r)" - return "" + return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) if isinstance(effect, HealEffect): - heal = effect.formula.roll(self._rng) - if maximize: - heal = effect.formula.max_value() - if empower: - heal = int(heal * 1.5) - old_hp = target.hp - target.hp = min(target.hp + heal, target.combatant.hp_max) - actual = target.hp - old_hp - return f"+{actual} HP" - # BuffEffect + return self._apply_heal_effect(effect, target, empower=empower, maximize=maximize) + return self._apply_buff_effect(effect, target, spell_name=spell_name) + + def _apply_damage_effect( + self, + effect: DamageEffect, + target: CombatantState, + caster_live: _LiveStats, + target_live: _LiveStats, + *, + saved: bool, + empower: bool, + maximize: bool, + ) -> str: + raw = effect.formula.roll(self._rng) + if maximize: + raw = effect.formula.max_value() + dmg = max(0, raw // 2) if saved and effect.half_on_save else raw + if empower: + dmg = int(dmg * 1.5) + dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) + target.hp -= dmg + caster_live.damage_dealt += dmg + target_live.damage_taken += dmg + return f"{dmg} damage" + + def _apply_condition_effect( + self, + effect: ConditionEffect, + target: CombatantState, + *, + saved: bool, + spell_name: str, + ) -> str: + if saved and effect.save_negates: + return f"{effect.condition_name} negated" + cond = CONDITIONS_BY_NAME.get(effect.condition_name) + if cond is None: + return "" + target.active_conditions.append( + ActiveCondition( + condition=cond, + rounds_left=effect.duration_rounds, + source=f"spell: {spell_name}", + ) + ) + target.conditions.append(cond) + return f"{effect.condition_name} ({effect.duration_rounds}r)" + + def _apply_heal_effect( + self, + effect: HealEffect, + target: CombatantState, + *, + empower: bool, + maximize: bool, + ) -> str: + heal = effect.formula.roll(self._rng) + if maximize: + heal = effect.formula.max_value() + if empower: + heal = int(heal * 1.5) + old_hp = target.hp + target.hp = min(target.hp + heal, target.combatant.hp_max) + actual = target.hp - old_hp + return f"+{actual} HP" + + def _apply_buff_effect( + self, effect: BuffEffect, target: CombatantState, *, spell_name: str + ) -> str: target.active_buffs.append( ActiveBuff( modifiers=effect.modifiers, @@ -1416,7 +1642,6 @@ class CombatEngine: source=f"spell: {spell_name}", ) ) - # Also update old effects field for backwards compatibility target.effects.extend(effect.modifiers) return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)" @@ -1426,28 +1651,20 @@ class CombatEngine: target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, + *, caster: CombatantState, spell_name: str, saved: bool, - *, empower: bool = False, maximize: bool = False, ) -> str: """Apply an area effect to all creatures within radius. - + The target parameter is used as the center point for the burst. """ radius_cells = effect.radius_ft // _SQUARE_FT center = target.pos - - # Find all creatures in radius - affected: list[CombatantState] = [] - for state in self._states: - if not state.active: - continue - dist = self._grid.distance(center, state.pos) - if dist <= radius_cells: - affected.append(state) + affected = self._creatures_in_range(center, radius_cells) if not affected: return "no targets in area" @@ -1457,23 +1674,15 @@ class CombatEngine: total_buffs = 0 for affected_state in affected: - # Each creature gets a save - creature_saved = False - if effect.save_for_half: - # Use the primary target's save result for the "primary" target - # Others roll their own - if affected_state is target: - creature_saved = saved - else: - # Roll save for this creature - # For fireball, it's a Reflex save - save_result = self.resolve_save(affected_state, "ref", 15) # DC from spell - creature_saved = save_result.success - + creature_saved = self._area_save( + affected_state, target, effect, saved=saved + ) for sub_effect in effect.effects: + target_live = self._stats[affected_state.combatant.id] result = self._apply_single_spell_effect( - sub_effect, affected_state, caster_live, self._stats[affected_state.combatant.id], - caster, spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize + sub_effect, affected_state, caster_live, target_live, + caster=caster, spell_name=spell_name, saved=creature_saved, + empower=empower, maximize=maximize, ) if "damage" in result: total_damage += int(result.split()[0]) @@ -1483,7 +1692,39 @@ class CombatEngine: elif "buff" in result: total_buffs += 1 - return f"area: {len(affected)} targets, {total_damage} total damage, {total_conditions} conditions, {total_buffs} buffs" + return ( + f"area: {len(affected)} targets, {total_damage} total damage, " + f"{total_conditions} conditions, {total_buffs} buffs" + ) + + def _creatures_in_range( + self, center: tuple[int, int], radius_cells: int + ) -> list[CombatantState]: + """Return active creatures within radius_cells of center.""" + affected: list[CombatantState] = [] + for state in self._states: + if not state.active: + continue + dist = self._grid.distance(center, state.pos) + if dist <= radius_cells: + affected.append(state) + return affected + + def _area_save( + self, + affected_state: CombatantState, + target: CombatantState, + effect: AreaEffect, + *, + saved: bool, + ) -> bool: + """Save result for one creature in an area effect.""" + if not effect.save_for_half: + return False + if affected_state is target: + return saved + save_result = self.resolve_save(affected_state, "ref", 15) + return save_result.success def _apply_metamagic( self, spell: SpellSpec, metamagic_feats: list[str] @@ -1491,85 +1732,52 @@ class CombatEngine: """Apply metamagic feats to spell effects on the fly.""" if not metamagic_feats: return spell.effects - new_effects: list[SpellEffect] = [] has_extend = "Extend Spell" in metamagic_feats + return tuple( + self._metamagic_transform_sub(effect, has_extend=has_extend) + for effect in spell.effects + ) - for effect in spell.effects: - if isinstance(effect, DamageEffect): - # Empower: 1.5x damage (applied at roll time in _apply_spell_effects_from_list) - # Maximize: max damage (applied at roll time) - # We mark these via a custom attribute on the formula - new_effects.append(effect) - elif isinstance(effect, ConditionEffect): - new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds - new_effects.append( - ConditionEffect( - condition_name=effect.condition_name, - duration_rounds=new_duration, - save_negates=effect.save_negates, - ) - ) - elif isinstance(effect, HealEffect): - # Empower/Maximize also apply to healing - new_effects.append(effect) - elif isinstance(effect, AreaEffect): - new_sub_effects = [] - for sub in effect.effects: - if isinstance(sub, DamageEffect): - new_sub_effects.append(sub) - elif isinstance(sub, ConditionEffect): - new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds - new_sub_effects.append( - ConditionEffect( - condition_name=sub.condition_name, - duration_rounds=new_duration, - save_negates=sub.save_negates, - ) - ) - elif isinstance(sub, HealEffect): - new_sub_effects.append(sub) - else: # BuffEffect - new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds - new_sub_effects.append( - BuffEffect( - modifiers=sub.modifiers, - duration_rounds=new_duration, - ) - ) - new_effects.append( - AreaEffect( - radius_ft=effect.radius_ft, - effects=tuple(new_sub_effects), - save_for_half=effect.save_for_half, - ) - ) - else: # BuffEffect - new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds - new_effects.append( - BuffEffect( - modifiers=effect.modifiers, - duration_rounds=new_duration, - ) - ) - return tuple(new_effects) + def _metamagic_transform_sub( + self, effect: SpellEffect, *, has_extend: bool + ) -> SpellEffect: + """Apply Extend Spell to one effect (recursing into AreaEffect sub-effects).""" + if isinstance(effect, (DamageEffect, HealEffect)): + return effect + if isinstance(effect, ConditionEffect): + duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds + return ConditionEffect( + condition_name=effect.condition_name, + duration_rounds=duration, + save_negates=effect.save_negates, + ) + if isinstance(effect, BuffEffect): + duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds + return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration) + if isinstance(effect, AreaEffect): + return AreaEffect( + radius_ft=effect.radius_ft, + effects=tuple( + self._metamagic_transform_sub(sub, has_extend=has_extend) + for sub in effect.effects + ), + save_for_half=effect.save_for_half, + ) + return effect def _apply_summon_effect( self, effect: SummonEffect, state: CombatantState, - caster_live: _LiveStats, - spell_name: str, + _caster_live: _LiveStats, + _spell_name: str, ) -> str: """Apply a summoning effect - create a summoned creature.""" # Load the summoned creature - from pathlib import Path - - from pf1e_simulator.loaders.combatant import load_combatant - try: summoned_combatant = load_combatant(Path(effect.creature)) - except Exception: + except CombatantLoadError: return f"summon failed: could not load {effect.creature}" # Find a valid adjacent position for the summoned creature @@ -1613,65 +1821,6 @@ class CombatEngine: ) return f"summoned {summoned_combatant.id}" - for effect in spell.effects: - if isinstance(effect, DamageEffect): - # Empower: 1.5x damage (applied at roll time in _apply_spell_effects_from_list) - # Maximize: max damage (applied at roll time) - # We mark these via a custom attribute on the formula - new_effects.append(effect) - elif isinstance(effect, ConditionEffect): - new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds - new_effects.append( - ConditionEffect( - condition_name=effect.condition_name, - duration_rounds=new_duration, - save_negates=effect.save_negates, - ) - ) - elif isinstance(effect, HealEffect): - # Empower/Maximize also apply to healing - new_effects.append(effect) - elif isinstance(effect, AreaEffect): - new_sub_effects = [] - for sub in effect.effects: - if isinstance(sub, DamageEffect): - new_sub_effects.append(sub) - elif isinstance(sub, ConditionEffect): - new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds - new_sub_effects.append( - ConditionEffect( - condition_name=sub.condition_name, - duration_rounds=new_duration, - save_negates=sub.save_negates, - ) - ) - elif isinstance(sub, HealEffect): - new_sub_effects.append(sub) - else: # BuffEffect - new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds - new_sub_effects.append( - BuffEffect( - modifiers=sub.modifiers, - duration_rounds=new_duration, - ) - ) - new_effects.append( - AreaEffect( - radius_ft=effect.radius_ft, - effects=tuple(new_sub_effects), - save_for_half=effect.save_for_half, - ) - ) - else: # BuffEffect - new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds - new_effects.append( - BuffEffect( - modifiers=effect.modifiers, - duration_rounds=new_duration, - ) - ) - return tuple(new_effects) - def _resolve_swing( self, attacker: CombatantState, @@ -1933,26 +2082,7 @@ class CombatEngine: 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 - if size == "diminutive": - return -4 - if size == "tiny": - return -2 - if size == "small": - return -1 - if size == "medium": - return 0 - if size == "large": - return 1 - if size == "huge": - return 2 - if size == "gargantuan": - return 4 - if size == "colossal": - return 8 - return 0 + return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0) def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool: """Push target 5ft away from attacker. Returns True if push succeeded.""" @@ -1978,7 +2108,7 @@ class CombatEngine: def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: """Find a heal spell in known spells.""" for name in spells_known: - spell = engine._spell_registry.get(name) + spell = engine.spell_registry.get(name) if spell and any(isinstance(e, HealEffect) for e in spell.effects): return name return None @@ -1986,8 +2116,9 @@ def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | Non def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> CombatantState | None: """Find an ally below 50% HP.""" - for ally_state in engine._states: - if ally_state.side == caster.side and ally_state.active and ally_state.hp < ally_state.combatant.hp_max // 2: + for ally_state in engine.states: + below_half = ally_state.hp < ally_state.combatant.hp_max // 2 + if ally_state.side == caster.side and ally_state.active and below_half: return ally_state return None @@ -1996,9 +2127,9 @@ def _find_offensive_spell( engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState ) -> str | None: """Find an offensive spell that can reach the target.""" - dist_ft = engine._grid.distance(caster.pos, target.pos) * _SQUARE_FT + dist_ft = engine.grid.distance(caster.pos, target.pos) * _SQUARE_FT for name in spells_known: - spell = engine._spell_registry.get(name) + spell = engine.spell_registry.get(name) if not spell: continue # Check for damage effect @@ -2006,7 +2137,7 @@ def _find_offensive_spell( if not has_damage: continue max_range = spell_range_ft(spell, caster.combatant.level) - if dist_ft <= max_range and has_line_of_effect(engine._grid, caster.pos, target.pos): + if dist_ft <= max_range and has_line_of_effect(engine.grid, caster.pos, target.pos): return name return None @@ -2014,7 +2145,7 @@ def _find_offensive_spell( def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: """Find a buff spell.""" for name in spells_known: - spell = engine._spell_registry.get(name) + spell = engine.spell_registry.get(name) if spell and any(isinstance(e, BuffEffect) for e in spell.effects): return name return None @@ -2044,7 +2175,9 @@ def _choose_spell_action( # 2. Offensive spell if enemy in range offensive_spell = _find_offensive_spell(engine, spells_known, caster, target) if offensive_spell: - return (Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),) + return ( + Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell), + ) # 3. Buff spell buff_spell = _find_buff_spell(engine, spells_known) @@ -2054,6 +2187,9 @@ def _choose_spell_action( return None +Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] + + def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: """Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range. @@ -2082,10 +2218,16 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, if spell_action is not None: return spell_action + return _melee_action(engine, state, target) + + +def _melee_action( + engine: CombatEngine, state: CombatantState, target: CombatantState +) -> tuple[Action, ...]: + """Choose a non-spell action: full-attack, charge, step, or approach.""" if engine.weapon_for(state, target) is not None: return (Action(kind="full_attack", target_id=target.combatant.id),) - charge_weapon = engine.can_charge(state, target) - if charge_weapon is not None: + if engine.can_charge(state, target) is not None: return (Action(kind="charge", target_id=target.combatant.id),) if engine.can_step5_to_attack(state, target): return ( diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 85da18a..992e7ea 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -53,6 +53,7 @@ StatTarget = Literal[ "linguistics", "perception", "perform", "profession", "ride", "sense_motive", "sleight_of_hand", "spellcraft", "stealth", "survival", "swim", "use_magic_device", + "dex_skills", ] _STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"}) diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index b02b107..2eba2a7 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -26,56 +26,14 @@ if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json from pf1e_simulator.abilities import ( - ADOPTED, ALERTNESS, - ARMOR_PROFICIENCY_LIGHT, - ARMOR_PROFICIENCY_MEDIUM, - AXE_TO_GRIND, - BITTER, - BORN_DAMNED, - BREEZE_KISSED, - BREW_POTION, - COMBAT_REFLEXES, - CRAFT_WONDROUS_ITEM, - DAZZLING_DISPLAY, DODGE, - EXTRA_HEX, - ESCHEW_MATERIALS, - EXALTED_SOCIETY, - EXTREMELY_FASHIONABLE, - FATES_FAVORED, - FEY_TOUCHED, - FOCUSED_STUDY, - FORTIFIED_DRINKER, - FRIENDLY_FACE, - GIFTED_ADEPT, GREAT_FORTITUDE, - GUNSMITHING, - HARMONIC_SPELL, IRON_WILL, - LIKE_THE_WIND, LIGHTNING_REFLEXES, - LONELY, - LARGER_THAN_LIFE, - MAW_OR_CLAW, - MOUNTED_COMBAT, POINT_BLANK_SHOT, POWER_ATTACK, PRECISE_SHOT, - PREHENSILE_TAIL, - RAPID_RELOAD, - SACRED_SUMS, - SELECTIVE_CHANNELING, - SKILL_FOCUS_PERCEPTION, - SPELL_VULNERABILITY_DIVINATION, - STEALTH_SYNERGY, - SUMMON_GOOD_MONSTER, - THOROUGHBRED, - VAGABOND_CHILD, - WHISPERING_WIND, - VAGABOND_CHILD, - WHISPERING_WIND, - WEAPON_FINESSE, AbilitySpec, toughness, weapon_focus, @@ -175,16 +133,17 @@ def load_combatant(path: Path) -> Combatant: _auto_attack_ids(data, monster_id) # Resolve features (list of strings → list of AbilitySpec) - level = data.get("level", 1) if isinstance(data.get("level"), int) else 1 + raw_level = data.get("level") + level = raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else 1 raw_features = data.pop("features", None) + validate_payload: dict[str, object] = dict(data) if isinstance(raw_features, list): - # Type ignore: we assign AbilitySpec list into a Json dict before Pydantic validation - data["features"] = _resolve_features(raw_features, level) # type: ignore[assignment] + validate_payload["features"] = _resolve_features(raw_features, level) # spells stays as list[str] — Pydantic validates it directly try: - return Combatant.model_validate(data) + return Combatant.model_validate(validate_payload) except ValidationError as exc: raise CombatantLoadError(path, str(exc)) from exc diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 96361a7..5aa30d4 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -27,6 +27,16 @@ class AbilityScores(_FrozenModel): wis_score: int cha_score: int + @property + def str_mod(self) -> int: + """Strength ability modifier.""" + return (self.str_score - 10) // 2 + + @property + def dex_mod(self) -> int: + """Dexterity ability modifier.""" + return (self.dex_score - 10) // 2 + class ACProfile(_FrozenModel): total: int diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 91a88f7..8511b60 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -17,10 +17,10 @@ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal, TypeGuard, get_args from pf1e_simulator.dice import DiceExpr, parse_dice -from pf1e_simulator.effects import BonusType, StatModifier +from pf1e_simulator.effects import BonusType, StatModifier, StatTarget if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json @@ -57,7 +57,7 @@ class BuffEffect: @dataclass(frozen=True) class AreaEffect: """Area of effect spell (burst only for Phase 0). - + - shape: only "burst" supported (circle around target point) - radius_ft: radius in feet (e.g., 20 for fireball) - effects: the effects applied to each creature in the area @@ -71,7 +71,7 @@ class AreaEffect: @dataclass(frozen=True) class SummonEffect: """Summoning spell effect. - + - creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json") - duration_rounds: how long the summoned creature stays """ @@ -106,64 +106,138 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: return 400 + 40 * caster_level -def _parse_effect(data: dict[str, Json]) -> SpellEffect: - effect_type = str(data["type"]) +def _as_dict(value: Json) -> dict[str, Json]: + if isinstance(value, dict): + return value + msg = f"expected object, got {value!r}" + raise ValueError(msg) + + +def _as_list(value: Json) -> list[Json]: + if isinstance(value, list): + return value + msg = f"expected list, got {value!r}" + raise ValueError(msg) + + +def _as_str(value: Json) -> str: + if isinstance(value, str): + return value + msg = f"expected string, got {value!r}" + raise ValueError(msg) + + +def _as_int(value: Json) -> int: + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + return int(value) + msg = f"expected integer, got {value!r}" + raise ValueError(msg) + + +def _as_optional_str(value: Json) -> str | None: + if value is None: + return None + return _as_str(value) + + +def _is_spell_range(value: str) -> TypeGuard[SpellRange]: + return value in ("personal", "touch", "close", "medium", "long") + + +def _is_save_type(value: str) -> TypeGuard[SaveType]: + return value in ("fort", "ref", "will", "none") + + +def _is_stat_target(value: str) -> TypeGuard[StatTarget]: + return value in get_args(StatTarget) + + +def _is_bonus_type(value: str) -> TypeGuard[BonusType]: + return value in get_args(BonusType) + + +def _parse_modifier(value: Json) -> StatModifier: + m = _as_dict(value) + target = _as_str(m["target"]) + if not _is_stat_target(target): + msg = f"unknown stat target: {target!r}" + raise ValueError(msg) + bonus = _as_str(m.get("bonus_type", "")) + if not _is_bonus_type(bonus): + msg = f"unknown bonus type: {bonus!r}" + raise ValueError(msg) + return StatModifier( + target=target, + value=_as_int(m["value"]), + bonus_type=bonus, + condition=_as_optional_str(m.get("condition")), + weapon_filter=_as_optional_str(m.get("weapon_filter")), + ) + + +def _parse_effect(data: Json) -> SpellEffect: + effect = _as_dict(data) + effect_type = _as_str(effect["type"]) if effect_type == "damage": return DamageEffect( - formula=parse_dice(str(data["formula"])), - types=tuple(str(t) for t in data["types"]), # type: ignore[index] - half_on_save=bool(data.get("half_on_save", True)), + formula=parse_dice(_as_str(effect["formula"])), + types=tuple(_as_str(t) for t in _as_list(effect["types"])), + half_on_save=bool(effect.get("half_on_save", True)), ) if effect_type == "condition": return ConditionEffect( - condition_name=str(data["condition_name"]), - duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] - save_negates=bool(data.get("save_negates", True)), + condition_name=_as_str(effect["condition_name"]), + duration_rounds=_as_int(effect["duration_rounds"]), + save_negates=bool(effect.get("save_negates", True)), ) if effect_type == "heal": return HealEffect( - formula=parse_dice(str(data["formula"])), + formula=parse_dice(_as_str(effect["formula"])), ) if effect_type == "buff": return BuffEffect( - modifiers=tuple( - StatModifier( - target=str(m["target"]), # type: ignore[index] - value=int(m["value"]), # type: ignore[arg-type] - bonus_type=cast("BonusType", str(m.get("bonus_type", ""))), # type: ignore[union-attr] - condition=m.get("condition"), # type: ignore[assignment] - weapon_filter=m.get("weapon_filter"), # type: ignore[assignment] - ) - for m in cast("list[dict[str, Json]]", data["modifiers"]) - ), - duration_rounds=int(data.get("duration_rounds", 0)), # type: ignore[arg-type] + modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])), + duration_rounds=_as_int(effect.get("duration_rounds", 0)), ) if effect_type == "area": return AreaEffect( - radius_ft=int(data.get("radius_ft", 20)), # type: ignore[arg-type] - effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index] - save_for_half=bool(data.get("save_for_half", True)), + radius_ft=_as_int(effect.get("radius_ft", 20)), + effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])), + save_for_half=bool(effect.get("save_for_half", True)), ) if effect_type == "summon": return SummonEffect( - creature=str(data["creature"]), # type: ignore[index] - duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] + creature=_as_str(effect["creature"]), + duration_rounds=_as_int(effect["duration_rounds"]), ) - raise ValueError(f"Unknown effect type: {effect_type}") + msg = f"Unknown effect type: {effect_type}" + raise ValueError(msg) def load_spell(path: str | Path) -> SpellSpec: - data: dict[str, Json] = json.loads(Path(path).read_text()) - effects = tuple(_parse_effect(e) for e in data.get("effects", [])) # type: ignore[arg-type] + data = _as_dict(json.loads(Path(path).read_text())) + effects = tuple(_parse_effect(e) for e in _as_list(data.get("effects", []))) + range_raw = _as_str(data.get("range", "medium")) + if not _is_spell_range(range_raw): + msg = f"unknown spell range: {range_raw!r}" + raise ValueError(msg) + save_raw = _as_str(data.get("save", "none")) + if not _is_save_type(save_raw): + msg = f"unknown save type: {save_raw!r}" + raise ValueError(msg) return SpellSpec( - name=str(data["name"]), - level=int(data["level"]), # type: ignore[arg-type] - school=str(data["school"]), - range=str(data.get("range", "medium")), # type: ignore[assignment] - save=str(data.get("save", "none")), # type: ignore[assignment] - base_dc=int(data.get("base_dc", 10)), # type: ignore[arg-type] + name=_as_str(data["name"]), + level=_as_int(data["level"]), + school=_as_str(data["school"]), + range=range_raw, + save=save_raw, + base_dc=_as_int(data.get("base_dc", 10)), effects=effects, - description=str(data.get("description", "")), + description=_as_str(data.get("description", "")), ) diff --git a/tests/test_abilities.py b/tests/test_abilities.py index 8da77d7..63030d0 100644 --- a/tests/test_abilities.py +++ b/tests/test_abilities.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + from pf1e_simulator.abilities import ( ALERTNESS, DODGE, @@ -9,6 +11,7 @@ from pf1e_simulator.abilities import ( IRON_WILL, LIGHTNING_REFLEXES, POINT_BLANK_SHOT, + AbilitySpec, weapon_focus, ) from pf1e_simulator.combat import CombatantState, CombatEngine @@ -30,16 +33,16 @@ from pf1e_simulator.rng import ScriptedRng def _make_hero( cid: str = "hero", *, - features: list[object] | None = None, + features: list[AbilitySpec] | None = None, weapon_name: str = "sword", - weapon_kind: str = "melee", + weapon_kind: Literal["melee", "ranged", "touch"] = "melee", attack_bonus: int = 5, damage_bonus: int = 2, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name=weapon_name, - kind=weapon_kind, # type: ignore[arg-type] + kind=weapon_kind, attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], damage_bonus=damage_bonus, @@ -61,7 +64,7 @@ def _make_hero( speed_land_ft=30, saves=Saves(fort=5, ref=4, will=3), attacks=[attack], - features=features or [], # type: ignore[arg-type] + features=features or [], ) diff --git a/tests/test_combatant_loader.py b/tests/test_combatant_loader.py index 78297f1..ea14670 100644 --- a/tests/test_combatant_loader.py +++ b/tests/test_combatant_loader.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path + from pf1e_simulator.loaders.foundry import Json + from pf1e_simulator.abilities import ( DODGE, IRON_WILL, @@ -79,7 +81,8 @@ class TestResolveFeatures: assert result[1] is DODGE def test_filters_non_strings(self) -> None: - result = _resolve_features(["Iron Will", 42, None, True], 1) # type: ignore[list-item] + raw: list[Json] = ["Iron Will", 42, None, True] + result = _resolve_features(raw, 1) assert len(result) == 1 def test_empty_list(self) -> None: diff --git a/tests/test_power_attack.py b/tests/test_power_attack.py index 1052269..1d13710 100644 --- a/tests/test_power_attack.py +++ b/tests/test_power_attack.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pf1e_simulator.abilities import POWER_ATTACK +from pf1e_simulator.abilities import POWER_ATTACK, AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid @@ -24,7 +24,7 @@ def _make_fighter( bab: int = 4, attack_bonus: int = 5, damage_bonus: int = 2, - features: list[object] | None = None, + features: list[AbilitySpec] | None = None, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", @@ -50,7 +50,7 @@ def _make_fighter( speed_land_ft=30, saves=Saves(fort=6, ref=2, will=2), attacks=[attack], - features=features or [], # type: ignore[arg-type] + features=features or [], ) diff --git a/tests/test_spells.py b/tests/test_spells.py index 67cdbc3..072c509 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -407,7 +407,8 @@ class TestSpellDamageWithDr: "dr": DamageReduction(amount=5, bypass=frozenset()), }) # 2 save rolls (2 targets in area) + 6 damage dice - engine, c, t = _make_engine(caster, target_with_dr, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]) + queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3] + engine, c, t = _make_engine(caster, target_with_dr, queue) hp_before = t.hp engine._cast_spell(c, t, "Fireball") # Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage