feat(combat): activate standard+move economy (full-speed movement, move-then-attack)

- 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.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 791199150b
commit 3c31b88b13
3 changed files with 127 additions and 88 deletions
+26 -13
View File
@@ -72,10 +72,10 @@ players : 3 combatants
monsters : 2 combatants monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds) 1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 97.8% (978) — bande 3σ : [96.4%, 99.2%] Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%]
Victoires monsters : 2.2% (22) — bande 3σ : [0.8%, 3.6%] Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%]
Nuls : 0.0% (0) Nuls : 0.0% (0)
Rounds moyens : 6.9 Rounds moyens : 6.0
``` ```
## Utilisation de la CLI ## Utilisation de la CLI
@@ -130,11 +130,13 @@ confiance à 3σ calculée en forme fermée (modèle binomial) :
immobiles, camps inaccessibles…). immobiles, camps inaccessibles…).
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs, 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 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 le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs
loin avec la pénalité de portée au lieu de s'approcher — c'est ce qui équilibre parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le
le face-à-face à 1 contre 2. 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 ## 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. multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - 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 ») - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages 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 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. 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 - Mêlée : allonge d'une case ; mouvement : un déplacement parcourt jusqu'à la
court chemin réel (champ de coût Dijkstra depuis la cible — les combattants vitesse du combattant en cases le long du plus court chemin réel (champ de
contournent les murs au lieu d'osciller contre eux). 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 - 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 peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée. visée.
@@ -290,8 +302,9 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états. - Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, flanquement, manœuvres de combat. - Attaques d'opportunité, flanquement, manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action - Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, actions spéciales
complète (charge, pas de placement…). (attaque à outrance, charge, pas de placement, retraite) et actions
immédiates hors-tour.
## Architecture ## Architecture
+63 -43
View File
@@ -1,12 +1,16 @@
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds. """Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
Phase 0 documented deviations from PF1e (conventions): Phase 0 documented deviations from PF1e (conventions):
- Action economy framework: `Action.kind` covers the six PF1e action types - Action economy: a normal turn grants one standard action + one move action
(attack=standard, move, full_round, swift, free, immediate); the engine (or one full-round action), plus swift/free/immediate. The policy returns an
executes a policy-returned sequence of actions per turn. The default policy ordered sequence of `Action` per turn; the engine executes them in order,
currently emits one action per turn (attack OR move); standard+move stopping early if the actor drops or one side is wiped. `default_policy`
activation and special actions (AoO, flanking, full attack, charge, returns `(move, attack)` when the nearest enemy is out of reach (move at
withdraw) are deferred. 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 - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks are not modeled. (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. - DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act. - Death when hp < min(-10, -CON); hp <= 0 cannot act.
- Initiative ties: higher initiative_mod first, then list order (no re-roll). - Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: one step per move action toward the nearest enemy, following the - Movement: a move action travels up to the combatant's speed in cells along
true shortest path (Dijkstra cost field from the target); ties keep delta order. 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 - Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead. 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 - 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._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} 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: def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript.""" """Run the battle and return the final result and transcript."""
order = self._roll_initiative() order = self._roll_initiative()
@@ -171,14 +190,7 @@ class CombatEngine:
for state in order: for state in order:
if not state.active: if not state.active:
continue continue
actions = self._policy(self, state) if self._take_turn(state, round_no):
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:
break break
if self._winner_side() is not None: if self._winner_side() is not None:
break break
@@ -308,19 +320,16 @@ class CombatEngine:
target = next((s for s in self._states if s.combatant.id == action.target_id), None) target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if action.kind == "attack": if action.kind == "attack":
if target is None or not target.active: if target is None or not target.active:
self._log(f"round {round_no} {state.combatant.id}: wait")
return return
weapon = self.weapon_for(state, target) weapon = self.weapon_for(state, target)
if weapon is None: if weapon is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return return
self._attack(state, target, weapon, round_no) self._attack(state, target, weapon, round_no)
elif action.kind == "move": elif action.kind == "move":
if target is None: if target is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return return
self._move(state, target, round_no) self._move(state, target, round_no)
else: elif action.kind == "wait":
self._log(f"round {round_no} {state.combatant.id}: wait") self._log(f"round {round_no} {state.combatant.id}: wait")
def _attack( def _attack(
@@ -358,40 +367,48 @@ class CombatEngine:
return return
def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None: def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None:
step = self._step_toward(state, target) path = self._move_path(state, target)
if step is None: if not path:
self._log(f"round {round_no} {state.combatant.id}: wait")
return return
self._log( coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path))
f"round {round_no} {state.combatant.id}: move " self._log(f"round {round_no} {state.combatant.id}: move {coords}")
f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})" state.pos = path[-1]
)
state.pos = step
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 speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
if speed_cells <= 0: if speed_cells <= 0:
return None return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active) 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) to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target: if state.pos not in to_target:
return None return []
best: tuple[int, Pos] | None = None path: list[Pos] = []
row, col = state.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: for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col) nxt = (row + d_row, col + d_col)
cost = to_target.get(nxt) if nxt in blocked:
if cost is None:
continue continue
step = self._grid.step_cost(state.pos, nxt, 0) nxt_cost = to_target.get(nxt)
if step > speed_cells: if nxt_cost is None or nxt_cost >= current_cost:
continue continue
score = step + cost step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = step + nxt_cost
if best is None or score < best[0]: if best is None or score < best[0]:
best = (score, nxt) best = (score, step, nxt)
if best is None: if best is None:
return None break
return best[1] current = best[2]
path.append(current)
budget -= best[1]
return path
def _log(self, line: str) -> None: def _log(self, line: str) -> None:
self._transcript.append(line) self._transcript.append(line)
@@ -401,10 +418,13 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def default_policy(engine: CombatEngine, state: 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) target = engine.nearest_enemy(state)
if target is None: if target is None:
return (Action(kind="wait"),) return (Action(kind="wait"),)
if engine.weapon_for(state, target) is not None: if engine.weapon_for(state, target) is not None:
return (Action(kind="attack", target_id=target.combatant.id),) 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),
)
+32 -26
View File
@@ -348,13 +348,12 @@ def test_scripted_2v2_transcript() -> None:
14, 1, # R1 gob-2 hits orc-2 for 1 14, 1, # R1 gob-2 hits orc-2 for 1
17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9 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 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, # R2 orc-2 hits gob-1 for 1+4+4=9
15, 1, 4, # R3 orc-2 hits gob-1 for 1+4+4=9
] ]
engine = make_engine(queue, states) engine = make_engine(queue, states)
result = engine.run() result = engine.run()
assert result.winner == "monsters" assert result.winner == "monsters"
assert result.rounds == 3 assert result.rounds == 2
assert result.transcript == ( assert result.transcript == (
"initiative: gob-1 d20=15+6=21", "initiative: gob-1 d20=15+6=21",
"initiative: orc-1 d20=14+0=14", "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: 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 gob-1: orc-1 down",
"round 2 orc-2: move (3,4)->(2,3)", "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 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
"round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)", "round 2 orc-2: gob-1 down",
"round 3 orc-2: gob-1 down", "battle over: monsters win in 2 rounds",
"battle over: monsters win in 3 rounds",
) )
assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9) 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) 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) gob = make_combatant("gob", speed=30)
orc = make_combatant("orc", speed=0) orc = make_combatant("orc", speed=0)
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))] 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() result = engine.run()
assert result.transcript == ( assert result.transcript == (
"initiative: gob d20=10+0=10", "initiative: gob d20=10+0=10",
"initiative: orc d20=9+0=9", "initiative: orc d20=9+0=9",
"round 1 gob: move (1,1)->(0,2)", "round 1 gob: move (1,1)->(0,2)->(0,3)->(0,4)",
"round 1 orc: wait", "round 1 gob: short sword vs orc d20=12+2=14 AC 13 -> HIT 3 damage (6->3)",
"round 2 gob: move (0,2)->(0,3)", "round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)",
"round 2 orc: wait", "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", "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: 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 = { legend = {
".": TerrainType(type="floor", move_cost=1), ".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True), "#": 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) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0) target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] 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() result = engine.run()
assert result.winner is None assert result.winner == "players"
assert result.transcript == ( assert result.transcript == (
"initiative: archer d20=10+0=10", "initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9", "initiative: target d20=9+0=9",
"round 1 archer: move (0,1)->(1,2)", "round 1 archer: move (0,1)->(1,2)->(0,3)",
"round 1 target: wait", "round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 archer: short bow vs target d20=11+4=15 AC 13 -> HIT 3 damage (6->3)", "round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
"round 2 target: wait", "round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
"round 3 archer: short bow vs target d20=5+4=9 AC 13 -> MISS", "round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
"round 3 target: wait", "round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
"round 4 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 2 damage (3->1)", "round 3 archer: target down",
"round 4 target: wait", "battle over: players win in 3 rounds",
"battle over: draw after 4 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) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", speed=0) target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 21))] 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 assert engine.weapon_for(states[0], states[1]) is None
result = engine.run() 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: def test_ranged_soft_cover_from_creature_between() -> None: