From 3c31b88b1373b21cfd06173d1650e4ba27ced5f4 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): activate standard+move economy (full-speed movement, move-then-attack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - default_policy returns (move, attack) when target out of reach: full-speed move along the Dijkstra path (up to speed cells, 5-10-5 diagonals, stops adjacent to nearest enemy), then attack if a weapon is usable; returns (attack,) when already in range. - _move logs the full path in one line (start)->(step1)->...->(end); silent on empty path. _execute is silent on invalid attacks/moves (no spurious wait log); run() adds a wait line only when a combatant's turn produced no transcript line. - Extract _take_turn helper from run() to keep cyclomatic complexity <= 10. - Module docstring updated for activated economy. - 4 pinned transcripts regenerated (move-then-attack shifts combat pacing): test_default_policy_moves_toward_enemy, test_scripted_2v2_transcript (monsters win in R2 instead of R3 — orc-2 moves + attacks same turn), test_ranged_no_line_of_effect_moves_around_wall (archer rounds wall and shoots in 1 turn), test_ranged_weapon_unusable_beyond_maximum_range (archer moves 6 cells then attacks at -14 penalty). - README: demo numbers refreshed (1v2: 49.6%->22.0%, 11.4->5.2 rounds; 3v2: 97.8%->91.4%, 6.9->6.0 rounds), rules and non-modeled sections updated for the activated economy. --- README.md | 39 ++++++++---- src/pf1e_simulator/combat.py | 118 ++++++++++++++++++++--------------- tests/test_combat.py | 58 +++++++++-------- 3 files changed, 127 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index df87a22..9568843 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ players : 3 combatants monsters : 2 combatants 1000 combats simulés (seed 1, plafond 100 rounds) -Victoires players : 97.8% (978) — bande 3σ : [96.4%, 99.2%] -Victoires monsters : 2.2% (22) — bande 3σ : [0.8%, 3.6%] +Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%] +Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%] Nuls : 0.0% (0) -Rounds moyens : 6.9 +Rounds moyens : 6.0 ``` ## Utilisation de la CLI @@ -130,11 +130,13 @@ confiance à 3σ calculée en forme fermée (modèle binomial) : immobiles, camps inaccessibles…). Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs, -seed 1 : players 49,6 %, bande 3σ [44,9 % ; 54,3 %], 0 nuls, 11,4 rounds moyens. +seed 1 : players 22,0 %, bande 3σ [18,1 % ; 25,9 %], 0 nuls, 5,2 rounds moyens. Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner -le mur central avant de tirer ; une fois la ligne de visée gagnée, il tire de -loin avec la pénalité de portée au lieu de s'approcher — c'est ce qui équilibre -le face-à-face à 1 contre 2. +le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs +parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le +gobelin solitaire ne peut plus les distancer et tombe vite en mêlée sous les +2d4+4 des falchions. C'est ce déséquilibre de cadence qui penche fortement le +face-à-face à 1 contre 2 du côté des monstres. ## Formats de données @@ -264,14 +266,24 @@ Règles modélisées : multiplication, les types perforants annulent la RD, les dégâts plancher à 0. - Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. -- Une action par tour : se déplacer OU attaquer. +- Économie d'action : un tour normal donne une action standard + une action de + mouvement (ou une action à round complet), plus swift/free/immediate. La + politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ; + `default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est + 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 (AoO, flanquement, attaque à outrance, charge, retraite) ne sont + pas encore modélisées. - 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 rafale. Les itératifs de BAB (2e attaque à BAB +6…) ne sont pas modélisés. -- Mêlée : allonge d'une case ; mouvement : un pas par action, le long du plus - court chemin réel (champ de coût Dijkstra depuis la cible — les combattants - contournent les murs au lieu d'osciller contre eux). +- Mêlée : allonge d'une case ; mouvement : un déplacement parcourt jusqu'à la + vitesse du combattant en cases le long du plus court chemin réel (champ de + coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à + l'ennemi le plus proche — les combattants contournent les murs au lieu + d'osciller contre eux. - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. @@ -290,8 +302,9 @@ appliquées dans la résolution) : - Sorts, jets de sauvegarde, conditions et états. - Attaques d'opportunité, flanquement, manœuvres de combat. - Effets mécaniques de hauteur/élévation. -- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action - complète (charge, pas de placement…). +- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, actions spéciales + (attaque à outrance, charge, pas de placement, retraite) et actions + immédiates hors-tour. ## Architecture diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 19f5cf7..c64dfaf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1,12 +1,16 @@ """Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds. Phase 0 documented deviations from PF1e (conventions): -- Action economy framework: `Action.kind` covers the six PF1e action types - (attack=standard, move, full_round, swift, free, immediate); the engine - executes a policy-returned sequence of actions per turn. The default policy - currently emits one action per turn (attack OR move); standard+move - activation and special actions (AoO, flanking, full attack, charge, - withdraw) are deferred. +- Action economy: a normal turn grants one standard action + one move action + (or one full-round action), plus swift/free/immediate. The policy returns an + ordered sequence of `Action` per turn; the engine executes them in order, + stopping early if the actor drops or one side is wiped. `default_policy` + returns `(move, attack)` when the nearest enemy is out of reach (move at + full speed along the Dijkstra path, then attack if a weapon is usable) and + `(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 + (AoO, flanking, full attack, charge, withdraw) are deferred. - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks in one attack action; remaining swings are lost once the target drops (down or dead) mid-routine. BAB iterative attacks are not modeled. @@ -15,8 +19,9 @@ Phase 0 documented deviations from PF1e (conventions): - DR applies once, after crit multiplication; any bypassing type defeats DR. - Death when hp < min(-10, -CON); hp <= 0 cannot act. - Initiative ties: higher initiative_mod first, then list order (no re-roll). -- Movement: one step per move action toward the nearest enemy, following the - true shortest path (Dijkstra cost field from the target); ties keep delta order. +- Movement: a move action travels up to the combatant's speed in cells along + the true shortest path (Dijkstra cost field from the target, 5-10-5 + diagonals), stopping adjacent to the nearest enemy; ties keep delta order. - Line of effect gates all attacks: a target fully behind blocking terrain cannot be attacked, and the policy moves to gain sight instead. - Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and @@ -163,6 +168,20 @@ class CombatEngine: self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} + def _take_turn(self, state: CombatantState, round_no: int) -> bool: + """Execute one combatant's full turn; return True if the battle is over.""" + actions = self._policy(self, state) + before = len(self._transcript) + for action in actions: + if not state.active: + break + self._execute(state, action, round_no) + if self._winner_side() is not None: + break + if state.active and len(self._transcript) == before: + self._log(f"round {round_no} {state.combatant.id}: wait") + return self._winner_side() is not None + def run(self) -> CombatResult: """Run the battle and return the final result and transcript.""" order = self._roll_initiative() @@ -171,14 +190,7 @@ class CombatEngine: for state in order: if not state.active: continue - actions = self._policy(self, state) - for action in actions: - if not state.active: - break - self._execute(state, action, round_no) - if self._winner_side() is not None: - break - if self._winner_side() is not None: + if self._take_turn(state, round_no): break if self._winner_side() is not None: break @@ -308,19 +320,16 @@ class CombatEngine: target = next((s for s in self._states if s.combatant.id == action.target_id), None) if action.kind == "attack": if target is None or not target.active: - self._log(f"round {round_no} {state.combatant.id}: wait") return weapon = self.weapon_for(state, target) if weapon is None: - self._log(f"round {round_no} {state.combatant.id}: wait") return self._attack(state, target, weapon, round_no) elif action.kind == "move": if target is None: - self._log(f"round {round_no} {state.combatant.id}: wait") return self._move(state, target, round_no) - else: + elif action.kind == "wait": self._log(f"round {round_no} {state.combatant.id}: wait") def _attack( @@ -358,40 +367,48 @@ class CombatEngine: return def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None: - step = self._step_toward(state, target) - if step is None: - self._log(f"round {round_no} {state.combatant.id}: wait") + path = self._move_path(state, target) + if not path: return - self._log( - f"round {round_no} {state.combatant.id}: move " - f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})" - ) - state.pos = step + coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path)) + self._log(f"round {round_no} {state.combatant.id}: move {coords}") + state.pos = path[-1] - def _step_toward(self, state: CombatantState, target: CombatantState) -> Pos | None: + def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: + """Full movement path toward the target, up to the combatant's speed.""" speed_cells = state.combatant.speed_land_ft // _SQUARE_FT if speed_cells <= 0: - return None + return [] blocked = frozenset(s.pos for s in self._states if s is not state and s.active) to_target = self._grid.reachable(target.pos, None, blocked) if state.pos not in to_target: - return None - best: tuple[int, Pos] | None = None - row, col = state.pos - for d_row, d_col in _STEP_DELTAS: - nxt = (row + d_row, col + d_col) - cost = to_target.get(nxt) - if cost is None: - continue - step = self._grid.step_cost(state.pos, nxt, 0) - if step > speed_cells: - continue - score = step + cost - if best is None or score < best[0]: - best = (score, nxt) - if best is None: - return None - return best[1] + return [] + path: list[Pos] = [] + current = state.pos + budget = speed_cells + while budget > 0: + current_cost = to_target[current] + best: tuple[int, int, Pos] | None = None + row, col = current + for d_row, d_col in _STEP_DELTAS: + nxt = (row + d_row, col + d_col) + if nxt in blocked: + continue + nxt_cost = to_target.get(nxt) + if nxt_cost is None or nxt_cost >= current_cost: + continue + step = self._grid.step_cost(current, nxt, 0) + if step > budget: + continue + score = step + nxt_cost + if best is None or score < best[0]: + best = (score, step, nxt) + if best is None: + break + current = best[2] + path.append(current) + budget -= best[1] + return path def _log(self, line: str) -> None: self._transcript.append(line) @@ -401,10 +418,13 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Attack the nearest active enemy when a weapon is in range, else approach.""" + """Attack the nearest enemy in range; otherwise approach at full speed then try to attack.""" 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="attack", target_id=target.combatant.id),) - return (Action(kind="move", target_id=target.combatant.id),) + return ( + Action(kind="move", target_id=target.combatant.id), + Action(kind="attack", target_id=target.combatant.id), + ) diff --git a/tests/test_combat.py b/tests/test_combat.py index 603a715..7a965a8 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -348,13 +348,12 @@ def test_scripted_2v2_transcript() -> None: 14, 1, # R1 gob-2 hits orc-2 for 1 17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9 19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4 - 7, # R3 gob-1 misses orc-2 - 15, 1, 4, # R3 orc-2 hits gob-1 for 1+4+4=9 + 15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9 ] engine = make_engine(queue, states) result = engine.run() assert result.winner == "monsters" - assert result.rounds == 3 + assert result.rounds == 2 assert result.transcript == ( "initiative: gob-1 d20=15+6=21", "initiative: orc-1 d20=14+0=14", @@ -368,10 +367,9 @@ def test_scripted_2v2_transcript() -> None: "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 3 gob-1: short sword vs orc-2 d20=7+2=9 AC 13 -> MISS", - "round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)", - "round 3 orc-2: gob-1 down", - "battle over: monsters win in 3 rounds", + "round 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)", + "round 2 orc-2: gob-1 down", + "battle over: monsters win in 2 rounds", ) assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9) assert result.stats["gob-2"] == CombatantStats(hits=1, crits=0, damage_dealt=1, damage_taken=9) @@ -402,15 +400,16 @@ def test_default_policy_moves_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))] - engine = make_engine([10, 9], states, round_cap=2) + engine = make_engine([10, 9, 12, 3, 11, 2, 10, 1], states, round_cap=2) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", - "round 1 gob: move (1,1)->(0,2)", - "round 1 orc: wait", - "round 2 gob: move (0,2)->(0,3)", - "round 2 orc: wait", + "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 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", ) @@ -527,7 +526,7 @@ def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None: def test_ranged_no_line_of_effect_moves_around_wall() -> None: - """An archer without line of effect cannot shoot; the policy moves until it gains sight.""" + """An archer without line of effect moves around the wall at full speed, then shoots.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), @@ -537,21 +536,21 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None: archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] - engine = CombatEngine(ScriptedRng([10, 9, 11, 3, 5, 14, 2, 8]), grid, states, round_cap=4) + rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1]) + engine = CombatEngine(rng, grid, states, round_cap=4) result = engine.run() - assert result.winner is None + assert result.winner == "players" assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", - "round 1 archer: move (0,1)->(1,2)", - "round 1 target: wait", - "round 2 archer: short bow vs target d20=11+4=15 AC 13 -> HIT 3 damage (6->3)", - "round 2 target: wait", - "round 3 archer: short bow vs target d20=5+4=9 AC 13 -> MISS", - "round 3 target: wait", - "round 4 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 2 damage (3->1)", - "round 4 target: wait", - "battle over: draw after 4 rounds", + "round 1 archer: move (0,1)->(1,2)->(0,3)", + "round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", + "round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS", + "round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)", + "round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS", + "round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)", + "round 3 archer: target down", + "battle over: players win in 3 rounds", ) @@ -660,10 +659,17 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None: archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 21))] - engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1) + engine = CombatEngine(ScriptedRng([10, 9, 8]), grid, states, round_cap=1) assert engine.weapon_for(states[0], states[1]) is None result = engine.run() - assert result.transcript[2] == "round 1 archer: move (0,0)->(0,1)" + assert result.transcript == ( + "initiative: archer d20=10+0=10", + "initiative: target d20=9+0=9", + "round 1 archer: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)->(0,6)", + "round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS", + "round 1 target: wait", + "battle over: draw after 1 rounds", + ) def test_ranged_soft_cover_from_creature_between() -> None: