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
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
+69 -49
View File
@@ -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),
)
+32 -26
View File
@@ -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: