79ac190728
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.
223 lines
8.1 KiB
Python
223 lines
8.1 KiB
Python
"""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)
|