From 7543c55ed435ca431ae2ee3ea17d4100e3c58a7e Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): add charge action (straight-line 2x speed, +2 attack, -2 AC) --- README.md | 15 ++- src/pf1e_simulator/combat.py | 237 ++++++++++++++++++++++++++++++++--- tests/test_combat.py | 81 +++++++++++- 3 files changed, 306 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d6bbdfd..9764686 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,9 @@ Règles modélisées : hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra, puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions immédiates (hors-tour, consomment le prochain swift) et les actions - spéciales (charge, retraite) ne sont pas encore modélisées. + spéciales (retraite) ne sont pas encore modélisées. `default_policy` + choisit la charge quand l'ennemi est hors de portée mais joignable par + une ligne droite (voir ci-dessous). - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») résout `count` balayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de @@ -306,6 +308,13 @@ Règles modélisées : `_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées (PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de placement (5 ft) et la retraite (à venir) évitent les AoO de mouvement. +- Charge : action à round complet. Le combattant se déplace en ligne droite + (Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut + frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La + charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La + ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de + créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la + charge quand l'ennemi est hors de portée mais joignable en ligne droite. - Distance : pénalité cumulative de −2 par incrément de portée complet au-delà du premier, appliquée au jet d'attaque et à la confirmation de critique ; l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet @@ -318,7 +327,7 @@ appliquées dans la résolution) : - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft, actions spéciales - (charge, pas de placement, retraite) et actions immédiates hors-tour. + (pas de placement, retraite) et actions immédiates hors-tour. ## Architecture @@ -352,7 +361,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 210 tests +uv run pytest -q # 213 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 8703818..3af0832 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -10,7 +10,7 @@ Phase 0 documented deviations from PF1e (conventions): `(attack,)` when already in range. The move action may replace the standard action (e.g. a double move) but the default policy does not need this. Immediate actions (off-turn, consume next swift) and special actions - (charge, withdraw) are deferred; flanking and full attack are modeled; + (withdraw) are deferred; flanking, full attack, and charge are modeled; attacks of opportunity are resolved inline (see below). - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks in one attack action; remaining swings are lost once the target drops @@ -45,6 +45,14 @@ Phase 0 documented deviations from PF1e (conventions): combatant per round (tracked in ``_aoo_used``, cleared at round start); AoOs always strike (PF1e allows declining, the simulator does not). A 5-foot step and the withdraw action (deferred) avoid AoOs from movement. +- Charge: a full-round action that moves in a straight line (Bresenham) up to + 2x speed toward the closest square from which the charger can melee the + target, then makes a single melee attack at +2. The charger takes -2 AC + until the start of their next turn (tracked in ``ac_penalty``, cleared in + ``_take_turn``). The straight line must be clear of difficult terrain, + obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy`` + chooses a charge when the enemy is out of reach but a valid charge path + exists. - Ranged: cumulative -2 per full range increment beyond the first, up to 10 range increments; the penalty applies to attack and crit-confirm rolls. Thrown weapons (5 increments max) are not distinguished. @@ -76,6 +84,9 @@ _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side _ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous _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) _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), @@ -89,6 +100,29 @@ _STEP_DELTAS: tuple[Pos, ...] = ( ) +def _bresenham_line(start: Pos, end: Pos) -> list[Pos]: + """Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" + r0, c0 = start + r1, c1 = end + dr = abs(r1 - r0) + dc = abs(c1 - c0) + sr = 1 if r1 > r0 else -1 + sc = 1 if c1 > c0 else -1 + err = dr - dc + path: list[Pos] = [] + r, c = r0, c0 + while (r, c) != (r1, c1): + e2 = 2 * err + if e2 > -dc: + err -= dc + r += sr + if e2 < dr: + err += dr + c += sc + path.append((r, c)) + return path + + @dataclass(frozen=True) class CombatantStats: """Aggregated outcomes for one combatant over a battle.""" @@ -107,6 +141,7 @@ class CombatantState: side: str pos: Pos hp: int + ac_penalty: int = 0 @property def active(self) -> bool: @@ -140,17 +175,17 @@ class AttackResult: class Action: """What a policy wants a combatant to do this turn. - Kinds map to the six PF1e action types: ``attack`` is a standard action + Kinds map to PF1e action types: ``attack`` is a standard action (single attack), ``move`` is a move action, ``full_attack`` is a full-round - action (BAB iteratives), ``full_round`` is a generic full-round action - (charge, withdraw — deferred), ``swift`` and ``free`` are minor actions, - ``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op. - The engine executes a policy-returned sequence per turn. + action (BAB iteratives), ``charge`` is a special full-round action + (2x speed, straight line, +2 attack, -2 AC), ``swift`` and ``free`` are + minor actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` + is a no-op. The engine executes a policy-returned sequence per turn. """ kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", - "full_attack", + "full_attack", "charge", ] target_id: str | None = None @@ -197,6 +232,7 @@ class CombatEngine: def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" + state.ac_penalty = 0 actions = self._policy(self, state) before = len(self._transcript) for action in actions: @@ -303,7 +339,7 @@ class CombatEngine: flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus total = roll + base_bonus + penalty + flank - ac = target.combatant.ac.total + ac = target.combatant.ac.total - target.ac_penalty occupied = frozenset( s.pos for s in self._states if s.active and s is not attacker and s is not target ) @@ -419,18 +455,152 @@ class CombatEngine: return True return False + def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None: + """Return a melee weapon if ``state`` can charge ``target`` this turn, else None. + + PF1e charge requirements (CRB): + - Must have line of sight to the target at the start of the turn. + - Must move at least 10 ft (2 squares) and at most double speed. + - Must move in a straight line (Bresenham) to the closest attackable square. + - The path must not pass through blocking terrain, difficult terrain, or creatures. + - Must end adjacent to the target (within melee reach). + - Only a single melee attack is allowed. + """ + if not has_line_of_effect(self._grid, state.pos, target.pos): + return None + speed_cells = state.combatant.speed_land_ft // _SQUARE_FT + max_cells = 2 * speed_cells + blocked = frozenset( + s.pos for s in self._states if s is not state and s is not target and s.active + ) + for weapon in state.combatant.attacks: + if weapon.kind != "melee": + continue + reach_cells = weapon.reach_ft // _SQUARE_FT + end = self._charge_end(state, target, reach_cells, max_cells, blocked) + if end is not None: + return weapon + return None + + def _charge_end( + self, + state: CombatantState, + target: CombatantState, + reach_cells: int, + max_cells: int, + blocked: frozenset[Pos], + ) -> Pos | None: + """Find the closest square from which ``state`` can melee ``target`` via a charge. + + Scans squares within reach of the target, picks the one whose Bresenham + line from ``state.pos`` is the shortest valid charge path (>=2 cells, + <=max_cells, no blocking terrain or creatures, all passable). + Ties break toward lower distance from state. + """ + candidates: list[tuple[int, Pos]] = [] + for end in self._charge_candidates(target, reach_cells, blocked, state.pos): + path = _bresenham_line(state.pos, end) + if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells: + continue + if not self._charge_path_clear(path, blocked): + continue + candidates.append((len(path), end)) + if not candidates: + return None + candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1]))) + return candidates[0][1] + + def _charge_candidates( + self, + target: CombatantState, + reach_cells: int, + blocked: frozenset[Pos], + start: Pos, + ) -> list[Pos]: + """Squares within reach of target, passable, unoccupied, not the start.""" + tr, tc = target.pos + result: list[Pos] = [] + for dr in range(-reach_cells, reach_cells + 1): + for dc in range(-reach_cells, reach_cells + 1): + if abs(dr) > reach_cells or abs(dc) > reach_cells: + continue + if dr == 0 and dc == 0: + continue + end = (tr + dr, tc + dc) + if end == start: + continue + if not self._grid.in_bounds(end) or not self._grid.passable(end): + continue + if end in blocked: + continue + if self._grid.distance(end, target.pos) > reach_cells: + continue + result.append(end) + return result + + def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool: + """True if every square on the charge path is passable and clear.""" + for pos in path: + if not self._grid.in_bounds(pos) or not self._grid.passable(pos): + return False + if pos in blocked: + return False + terrain = self._grid.terrain_at(pos) + if terrain.move_cost is not None and terrain.move_cost > 1: + return False + return True + + def _charge( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> None: + """Execute a charge: move in a straight line, then make a single melee attack at +2. + + Sets a -2 AC penalty on the attacker that lasts until the start of their next turn + (cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step + as for any move action. + """ + reach_cells = weapon.reach_ft // _SQUARE_FT + speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT + max_cells = 2 * speed_cells + blocked = frozenset( + s.pos for s in self._states if s is not attacker and s is not target and s.active + ) + end = self._charge_end(attacker, target, reach_cells, max_cells, blocked) + if end is None: + return + path = _bresenham_line(attacker.pos, end) + start = attacker.pos + actual_path: list[Pos] = [] + for step in path: + for enemy in self._enemies_threatening(attacker, attacker.pos): + if enemy.combatant.id in self._aoo_used: + continue + self._resolve_aoo(enemy, attacker) + if not attacker.active: + break + if not attacker.active: + break + attacker.pos = step + actual_path.append(step) + if actual_path: + coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) + self._log( + f"round {self._current_round} {attacker.combatant.id}: charge {coords}" + ) + if not attacker.active: + return + attacker.ac_penalty = _CHARGE_AC_PENALTY + bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS + self._resolve_swing( + attacker, target, weapon, label="charge", bonus_override=bonus + ) + 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"): - if target is None or not target.active: - return - weapon = self.weapon_for(state, target) - if weapon is None: - return - if action.kind == "full_attack": - self._full_attack(state, target, weapon) - else: - self._attack(state, target, weapon) + self._execute_attack(state, target, action.kind) + elif action.kind == "charge": + self._execute_charge(state, target) elif action.kind == "move": if target is None: return @@ -438,6 +608,27 @@ class CombatEngine: elif action.kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") + def _execute_attack( + self, state: CombatantState, target: CombatantState | None, kind: str + ) -> None: + if target is None or not target.active: + return + weapon = self.weapon_for(state, target) + if weapon is None: + return + if kind == "full_attack": + self._full_attack(state, target, weapon) + else: + self._attack(state, target, weapon) + + def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: + if target is None or not target.active: + return + weapon = self.can_charge(state, target) + if weapon is None: + return + self._charge(state, target, weapon) + def _resolve_swing( self, attacker: CombatantState, @@ -585,12 +776,22 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Full-attack the nearest enemy in range; otherwise approach then full-attack.""" + """Full-attack the nearest enemy in range; charge if reachable; otherwise approach. + + Decision order: + 1. Full-attack if a weapon is usable against the nearest enemy this turn. + 2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). + 3. Move toward the target then full-attack (standard + move economy). + 4. Wait if nothing is possible. + """ target = engine.nearest_enemy(state) if target is None: return (Action(kind="wait"),) 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: + return (Action(kind="charge", target_id=target.combatant.id),) return ( Action(kind="move", target_id=target.combatant.id), Action(kind="full_attack", target_id=target.combatant.id), diff --git a/tests/test_combat.py b/tests/test_combat.py index ddeae78..3337bab 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -367,8 +367,8 @@ def test_scripted_2v2_transcript() -> None: "round 1 orc-2: gob-2 down", "round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)", "round 2 gob-1: orc-1 down", - "round 2 orc-2: move (3,4)->(2,3)", - "round 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)", + "round 2 orc-2: charge (3,4)->(2,4)->(1,3)", + "round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)", "round 2 orc-2: gob-1 down", "battle over: monsters win in 2 rounds", ) @@ -397,7 +397,7 @@ def test_same_seed_replay_is_identical() -> None: assert first.rounds == second.rounds -def test_default_policy_moves_toward_enemy() -> None: +def test_default_policy_charges_toward_enemy() -> None: gob = make_combatant("gob", speed=30) orc = make_combatant("orc", speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))] @@ -406,9 +406,9 @@ def test_default_policy_moves_toward_enemy() -> None: assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", - "round 1 gob: move (1,1)->(0,2)->(0,3)->(0,4)", - "round 1 gob: short sword vs orc d20=12+2=14 AC 13 -> HIT 3 damage (6->3)", - "round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)", + "round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)", + "round 1 gob: charge vs orc d20=12+4=16 AC 13 -> HIT 3 damage (6->3)", + "round 1 orc: short sword vs gob d20=11+2=13 AC 11 -> HIT 2 damage (6->4)", "round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS", "round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS", "battle over: draw after 2 rounds", @@ -1076,3 +1076,72 @@ def test_two_guards_each_one_aoo() -> None: "round 1 dest: wait", "battle over: draw after 1 rounds", ) + + +# --------------------------------------------------------------------------- # +# Charge (CRB: straight-line 2x speed, +2 attack, -2 AC, single melee attack) # +# --------------------------------------------------------------------------- # + + +def _gob_charges_orc_attacks( + engine: CombatEngine, state: CombatantState +) -> tuple[Action, ...]: + if state.combatant.id == "gob": + return (Action(kind="charge", target_id="orc"),) + return (Action(kind="attack", target_id="gob"),) + + +def test_charge_straight_line_and_bonus() -> None: + """Charge moves in a straight line and attacks at +2; enemy retaliates.""" + gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30) + orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0) + states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))] + engine = CombatEngine( + ScriptedRng([10, 9, 12, 3, 11, 2]), + make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks, + ) + result = engine.run() + assert result.transcript == ( + "initiative: gob d20=10+0=10", + "initiative: orc d20=9+0=9", + "round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)", + "round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (10->7)", + "round 1 orc: short sword vs gob d20=11+0=11 AC 13 -> MISS", + "battle over: draw after 1 rounds", + ) + + +def test_charge_ac_penalty_applies() -> None: + """The -2 AC penalty from charging applies to the enemy's attack the same round.""" + gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30) + orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0) + states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 4))] + engine = CombatEngine( + ScriptedRng([10, 9, 12, 3, 11, 2]), + make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks, + ) + result = engine.run() + assert result.transcript == ( + "initiative: gob d20=10+0=10", + "initiative: orc d20=9+0=9", + "round 1 gob: charge (1,1)->(1,2)->(0,3)", + "round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (20->17)", + "round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (20->18)", + "battle over: draw after 1 rounds", + ) + + +def test_charge_too_close_full_attacks_instead() -> None: + """When adjacent (cannot charge minimum 2 squares), default_policy full-attacks.""" + gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30) + orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0) + states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))] + engine = make_engine([10, 9, 12, 3], states, round_cap=1) + result = engine.run() + assert result.transcript == ( + "initiative: gob d20=10+0=10", + "initiative: orc d20=9+0=9", + "round 1 gob: short sword vs orc d20=12+2=14 AC 15 -> MISS", + "round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS", + "battle over: draw after 1 rounds", + )