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
+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)