feat(combat): use fly speed for movement

Movement budget is now max(land, fly) speed. Updates the harvie golden: Harvie (land 10 / fly 80) is a flyer and now flies at 80 ft, so he wins vs the goblin instead of being pinned crawling at 10 ft.
This commit is contained in:
2026-09-10 17:46:57 +02:00
parent ec0397351a
commit 79ac190728
4 changed files with 272 additions and 19 deletions
+19 -8
View File
@@ -412,11 +412,20 @@ Règles modélisées :
de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à
distance égale à son allonge — les cases à 5 ft sont une « dead zone » où distance égale à son allonge — les cases à 5 ft sont une « dead zone » où
elle ne frappe pas ; une arme sans allonge menace les cases adjacentes. elle ne frappe pas ; une arme sans allonge menace les cases adjacentes.
Mouvement : un déplacement parcourt jusqu'à la Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de 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 à 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 l'ennemi le plus proche — les combattants contournent les murs au lieu
d'osciller contre eux. d'osciller contre eux.
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
dotée d'une vitesse de vol (harvie : vol 80 vs sol 30, ierlieth : vol 40)
se déplace donc à sa vitesse de vol. Simplification documentée : voler
PAR-DESSUS les obstacles ou le terrain difficile n'est PAS modélisé — la
praticabilité et les coûts de terrain de la grille s'appliquent à tout le
budget (pas de vol 3D ni de maniabilité) ; le snapshot expose ce budget
sous la clé additive `speed_effective_ft`.
Destination explicite : `Action(kind="move", destination=(r, c))` et Destination explicite : `Action(kind="move", destination=(r, c))` et
`Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers `Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers
la case `(r, c)` désignée (monter au couvert, quitter un défilé, se la case `(r, c)` désignée (monter au couvert, quitter un défilé, se
@@ -686,7 +695,9 @@ Règles modélisées :
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants}, metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
summon_duration (int ou None), immediate_used, deed_up_close_and_deadly summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
(flags actuels), active_buffs {source, rounds_left, mods: tuple de (flags actuels), active_buffs {source, rounds_left, mods: tuple de
{target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft, ac {total, {target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft,
speed_effective_ft (V4 : budget de déplacement effectif = max(land, fly),
en pieds), ac {total,
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus, (référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm — crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm —
@@ -759,8 +770,8 @@ Règles modélisées :
`channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus `channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de `spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
grille `reachable_ft` budget=speed (délibérément rejetée — grille `reachable_ft` budget=speed (délibérément rejetée —
`movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft` `movement_cost_ft` par paire + `terrain_semantics` + `speed_effective_ft`
suffisent). Le couvert par paire est exposé via la clé `cover_bonus` suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
(calcul identique à `resolve_attack`, couvert mou des créatures inclus). (calcul identique à `resolve_attack`, couvert mou des créatures inclus).
Les API de décision exposées (items L1-L8) Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
+24 -9
View File
@@ -34,6 +34,8 @@ Phase 0 documented deviations from PF1e (conventions):
- Movement: a move action travels up to the combatant's speed in cells along - 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 the true shortest path (Dijkstra cost field from the target, 5-10-5
diagonals), stopping adjacent to the nearest enemy; ties keep delta order. diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
The speed budget is the best of land and fly speed (V4); flying over
obstacles or difficult terrain is NOT modeled (grid costs still apply).
- 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
@@ -158,7 +160,7 @@ _FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10 _AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC) _AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x land speed _RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "mind_affecting",
@@ -312,6 +314,18 @@ def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int:
return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft) return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft)
def _speed_ft(state: CombatantState) -> int:
"""Movement budget in feet: the best of the combatant's land and fly speed.
Simplification (V4): a creature that has a fly speed is assumed to use it;
flying OVER obstacles or difficult terrain is NOT modeled — the grid's
passability and terrain costs still apply to the whole budget. No
fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist
in the model (fly only).
"""
return max(state.combatant.speed_land_ft, state.combatant.speed_fly_ft or 0)
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]: def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" """Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
r0, c0 = start r0, c0 = start
@@ -1081,6 +1095,7 @@ class CombatEngine:
), ),
"speed_land_ft": state.combatant.speed_land_ft, "speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft, "speed_fly_ft": state.combatant.speed_fly_ft,
"speed_effective_ft": _speed_ft(state),
"ac": { "ac": {
"total": state.combatant.ac.total, "total": state.combatant.ac.total,
"touch": state.combatant.ac.touch, "touch": state.combatant.ac.touch,
@@ -2336,15 +2351,15 @@ class CombatEngine:
(API publique, lecture pour politique LLM.) Uses the same cost model as (API publique, lecture pour politique LLM.) Uses the same cost model as
``_move_path``: a full Dijkstra field from the target's anchor (other ``_move_path``: a full Dijkstra field from the target's anchor (other
active states' cells blocked), then the cheapest field value over active states' cells blocked), then the cheapest field value over
``state``'s footprint, compared against the combatant's land speed. ``state``'s footprint, compared against the combatant's effective
Read-only: never mutates state nor consumes RNG. (land-or-fly) speed. Read-only: never mutates state nor consumes RNG.
""" """
blocked = self._blocked(state, target) blocked = self._blocked(state, target)
field = self._grid.reachable(target.pos, None, blocked) field = self._grid.reachable(target.pos, None, blocked)
cost_cells = self._footprint_cost(state.occupied_cells(), field) cost_cells = self._footprint_cost(state.occupied_cells(), field)
if cost_cells is None: if cost_cells is None:
return None return None
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT speed_cells = _speed_ft(state) // _SQUARE_FT
if cost_cells > speed_cells: if cost_cells > speed_cells:
return None return None
return cost_cells * _SQUARE_FT return cost_cells * _SQUARE_FT
@@ -2495,7 +2510,7 @@ class CombatEngine:
self._grid, state.occupied_cells(), target.occupied_cells() self._grid, state.occupied_cells(), target.occupied_cells()
): ):
return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect") return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect")
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT speed_cells = _speed_ft(state) // _SQUARE_FT
max_cells = 2 * speed_cells max_cells = 2 * speed_cells
blocked = self._blocked(state, target) blocked = self._blocked(state, target)
melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"] melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"]
@@ -2603,7 +2618,7 @@ class CombatEngine:
""" """
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0 min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT speed_cells = _speed_ft(attacker) // _SQUARE_FT
max_cells = 2 * speed_cells max_cells = 2 * speed_cells
blocked = self._blocked(attacker, target) blocked = self._blocked(attacker, target)
end = self._charge_end( end = self._charge_end(
@@ -4804,7 +4819,7 @@ class CombatEngine:
``speed_factor`` widens the budget (run: 4x speed, U5e). ``speed_factor`` widens the budget (run: 4x speed, U5e).
""" """
speed_cells = int( speed_cells = int(
state.combatant.speed_land_ft _speed_ft(state)
// _SQUARE_FT // _SQUARE_FT
* self._get_speed_mult(state) * self._get_speed_mult(state)
* speed_factor * speed_factor
@@ -4850,7 +4865,7 @@ class CombatEngine:
""" """
speed_mult = self._get_speed_mult(state) speed_mult = self._get_speed_mult(state)
speed_cells = int( speed_cells = int(
state.combatant.speed_land_ft _speed_ft(state)
// _SQUARE_FT // _SQUARE_FT
* speed_mult * speed_mult
* speed_factor * speed_factor
@@ -4910,7 +4925,7 @@ class CombatEngine:
step that maximizes distance (cost) from the threat each cell. step that maximizes distance (cost) from the threat each cell.
""" """
speed_mult = self._get_speed_mult(state) speed_mult = self._get_speed_mult(state)
max_cells = int(2 * (state.combatant.speed_land_ft // _SQUARE_FT) * speed_mult) max_cells = int(2 * (_speed_ft(state) // _SQUARE_FT) * speed_mult)
if max_cells <= 0: if max_cells <= 0:
return [] return []
blocked = self._blocked(state) blocked = self._blocked(state)
+222
View File
@@ -0,0 +1,222 @@
"""V4: movement uses the best of land and fly speed.
The engine budgets every movement action (move, 5-foot step toward a target,
charge at 2x, withdraw at 2x, run at 4x) from ``_speed_ft(state)`` — the
maximum of the creature's ``speed_land_ft`` and ``speed_fly_ft`` (a missing
fly speed counts as 0, so a land-only creature is unchanged). Simplification:
a creature that has a fly speed is assumed to use it, but flying OVER
obstacles or difficult terrain is NOT modeled — the grid's passability and
terrain costs still apply to the whole budget. The observation snapshot adds
an additive ``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are
kept as-is.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import make_state
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
def _combatant(
id_: str, *, land: int, fly: int | None = None, initiative_mod: int = 0
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=initiative_mod,
speed_land_ft=land,
speed_fly_ft=fly,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[
AttackSpec(
id=f"{id_}-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[
DamageComponent(formula=parse_dice("1d6"), types=["slashing"])
],
)
],
)
def _corridor(cols: int) -> Grid:
"""A single-row open floor of ``cols`` cells (all move_cost 1)."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="corridor", terrain=("." * cols,), legend=legend)
return Grid.from_spec(spec)
def _run_move(
*, land: int, fly: int | None, destination: tuple[int, int], cols: int,
) -> CombatantState:
"""Hero acts first, takes a single ``move`` to ``destination``; foe idles
far down the corridor (never threatens). Returns the hero state."""
hero = make_state(
_combatant("hero", land=land, fly=fly, initiative_mod=2),
"players",
(0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (0, cols - 1))
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=destination),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), _corridor(cols), [hero, foe],
policy=policy, round_cap=1,
)
eng.run()
return eng._states[0]
# ── (a) a land-only creature moves at its land speed (unchanged) ──────────────
def test_land_only_creature_uses_land_speed() -> None:
# speed 30 ft = 6 cells; (0, 6) is exactly reachable, (0, 7) is not.
hero = _run_move(land=30, fly=None, destination=(0, 6), cols=12)
assert hero.pos == (0, 6)
hero = _run_move(land=30, fly=None, destination=(0, 7), cols=12)
assert hero.pos == (0, 0)
# ── (b) a fly 80 / land 30 creature moves up to 80 ft (16 cells) ──────────────
def test_flyer_moves_farther_than_land_only() -> None:
# fly 80 ft = 16 cells: (0, 16) is within the move budget.
flyer = _run_move(land=30, fly=80, destination=(0, 16), cols=20)
assert flyer.pos == (0, 16)
# A land-only creature cannot reach the same cell this turn.
crawler = _run_move(land=30, fly=None, destination=(0, 16), cols=20)
assert crawler.pos == (0, 0)
assert _corridor(20).distance(flyer.pos, (0, 0)) > _corridor(20).distance(
crawler.pos, (0, 0)
)
# ── (c) charge uses 2x the effective speed; run uses 4x ──────────────────────
def test_charge_budget_is_double_the_fly_speed() -> None:
# fly 80 -> charge up to 2x16 = 32 cells; the foe at (0, 20) is chargeable.
flyer = make_state(
_combatant("hero", land=30, fly=80), "players", (0, 0)
)
eng = CombatEngine(
ScriptedRng([]), _corridor(24),
[flyer, make_state(_combatant("foe", land=30), "monsters", (0, 20))],
)
result = eng.can_charge(flyer, eng._states[1])
assert result is not None
assert result.weapon is not None
assert result.reason is None
# A land-only creature (max 2x6 = 12 cells) cannot reach (0, 20).
crawler = make_state(_combatant("hero", land=30), "players", (0, 0))
foe2 = make_state(_combatant("foe", land=30), "monsters", (0, 20))
eng2 = CombatEngine(ScriptedRng([]), _corridor(24), [crawler, foe2])
result2 = eng2.can_charge(crawler, foe2)
assert result2 is not None
assert result2.reason == "no_charge_path"
def test_run_budget_is_four_times_the_fly_speed() -> None:
# fly 80 -> run 4x16 = 64 cells; (0, 40) is reachable by running.
hero = make_state(
_combatant("hero", land=30, fly=80, initiative_mod=2), "players", (0, 0)
)
foe = make_state(_combatant("foe", land=30), "monsters", (0, 63))
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(0, 40)),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), _corridor(64), [hero, foe],
policy=policy, round_cap=1,
)
eng.run()
assert eng._states[0].pos == (0, 40)
# A land-only creature (run 4x6 = 24 cells) cannot reach (0, 40).
crawler = make_state(
_combatant("hero", land=30, fly=None, initiative_mod=2), "players", (0, 0)
)
foe2 = make_state(_combatant("foe", land=30), "monsters", (0, 63))
eng2 = CombatEngine(
ScriptedRng([10, 5]), _corridor(64), [crawler, foe2],
policy=policy, round_cap=1,
)
eng2.run()
assert eng2._states[0].pos == (0, 0)
# ── (d) the snapshot exposes speed_effective_ft ───────────────────────────────
def test_snapshot_exposes_speed_effective_ft() -> None:
flyer = make_state(_combatant("flyer", land=30, fly=80), "players", (0, 0))
land_only = make_state(_combatant("walker", land=30), "monsters", (0, 3))
eng = CombatEngine(ScriptedRng([]), _corridor(8), [flyer, land_only])
obs = eng._build_observation(flyer).to_dict()
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["flyer"]["speed_effective_ft"] == 80
assert by_id["flyer"]["speed_land_ft"] == 30
assert by_id["flyer"]["speed_fly_ft"] == 80
assert by_id["walker"]["speed_effective_ft"] == 30
assert by_id["walker"]["speed_fly_ft"] is None
# ── (e) a fly speed lower than land falls back to land (max) ──────────────────
def test_fly_lower_than_land_uses_land() -> None:
hero = make_state(_combatant("hero", land=30, fly=10), "players", (0, 0))
foe = make_state(_combatant("foe", land=30), "monsters", (0, 7))
eng = CombatEngine(ScriptedRng([]), _corridor(8), [hero, foe])
obs = eng._build_observation(hero).to_dict()
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["hero"]["speed_effective_ft"] == 30
# Movement still uses the 30 ft land budget (6 cells), not the 10 ft fly.
mover = _run_move(land=30, fly=10, destination=(0, 6), cols=12)
assert mover.pos == (0, 6)
mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12)
assert mover.pos == (0, 0)
+7 -2
View File
@@ -157,9 +157,14 @@ class TestGoldenCombats:
self._assert_deterministic_goblin("oni") self._assert_deterministic_goblin("oni")
def test_harvie_vs_goblin(self): def test_harvie_vs_goblin(self):
"""Harvie (Animal Companion) vs Goblin - the goblin wins.""" """Harvie (Animal Companion, flying) vs Goblin - Harvie wins.
Updated (V4): Harvie has speed_land_ft 10 / speed_fly_ft 80; movement now
uses the best of land/fly speed, so Harvie flies at 80 ft and wins (the
previous expectation pinned him crawling at 10 ft).
"""
result, transcript = run_golden("harvie") result, transcript = run_golden("harvie")
assert result.winner == "monsters" assert result.winner == "players"
assert result.rounds >= 1 assert result.rounds >= 1
self._assert_deterministic_goblin("harvie") self._assert_deterministic_goblin("harvie")