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:
@@ -412,11 +412,20 @@ Règles modélisées :
|
||||
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ù
|
||||
elle ne frappe pas ; une arme sans allonge menace les cases adjacentes.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
`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
|
||||
@@ -686,7 +695,9 @@ Règles modélisées :
|
||||
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
|
||||
summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
|
||||
(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
|
||||
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
|
||||
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
|
||||
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
|
||||
grille `reachable_ft` budget=speed (délibérément rejetée —
|
||||
`movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft`
|
||||
suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
|
||||
`movement_cost_ft` par paire + `terrain_semantics` + `speed_effective_ft`
|
||||
suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
|
||||
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
||||
Les API de décision exposées (items L1-L8)
|
||||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||||
|
||||
@@ -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
|
||||
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
_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)
|
||||
_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({
|
||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||
"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)
|
||||
|
||||
|
||||
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]:
|
||||
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
||||
r0, c0 = start
|
||||
@@ -1081,6 +1095,7 @@ class CombatEngine:
|
||||
),
|
||||
"speed_land_ft": state.combatant.speed_land_ft,
|
||||
"speed_fly_ft": state.combatant.speed_fly_ft,
|
||||
"speed_effective_ft": _speed_ft(state),
|
||||
"ac": {
|
||||
"total": state.combatant.ac.total,
|
||||
"touch": state.combatant.ac.touch,
|
||||
@@ -2336,15 +2351,15 @@ class CombatEngine:
|
||||
(API publique, lecture pour politique LLM.) Uses the same cost model as
|
||||
``_move_path``: a full Dijkstra field from the target's anchor (other
|
||||
active states' cells blocked), then the cheapest field value over
|
||||
``state``'s footprint, compared against the combatant's land speed.
|
||||
Read-only: never mutates state nor consumes RNG.
|
||||
``state``'s footprint, compared against the combatant's effective
|
||||
(land-or-fly) speed. Read-only: never mutates state nor consumes RNG.
|
||||
"""
|
||||
blocked = self._blocked(state, target)
|
||||
field = self._grid.reachable(target.pos, None, blocked)
|
||||
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
||||
if cost_cells is None:
|
||||
return None
|
||||
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
||||
speed_cells = _speed_ft(state) // _SQUARE_FT
|
||||
if cost_cells > speed_cells:
|
||||
return None
|
||||
return cost_cells * _SQUARE_FT
|
||||
@@ -2495,7 +2510,7 @@ class CombatEngine:
|
||||
self._grid, state.occupied_cells(), target.occupied_cells()
|
||||
):
|
||||
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
|
||||
blocked = self._blocked(state, target)
|
||||
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
|
||||
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
|
||||
blocked = self._blocked(attacker, target)
|
||||
end = self._charge_end(
|
||||
@@ -4804,7 +4819,7 @@ class CombatEngine:
|
||||
``speed_factor`` widens the budget (run: 4x speed, U5e).
|
||||
"""
|
||||
speed_cells = int(
|
||||
state.combatant.speed_land_ft
|
||||
_speed_ft(state)
|
||||
// _SQUARE_FT
|
||||
* self._get_speed_mult(state)
|
||||
* speed_factor
|
||||
@@ -4850,7 +4865,7 @@ class CombatEngine:
|
||||
"""
|
||||
speed_mult = self._get_speed_mult(state)
|
||||
speed_cells = int(
|
||||
state.combatant.speed_land_ft
|
||||
_speed_ft(state)
|
||||
// _SQUARE_FT
|
||||
* speed_mult
|
||||
* speed_factor
|
||||
@@ -4910,7 +4925,7 @@ class CombatEngine:
|
||||
step that maximizes distance (cost) from the threat each cell.
|
||||
"""
|
||||
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:
|
||||
return []
|
||||
blocked = self._blocked(state)
|
||||
|
||||
@@ -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)
|
||||
@@ -157,9 +157,14 @@ class TestGoldenCombats:
|
||||
self._assert_deterministic_goblin("oni")
|
||||
|
||||
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")
|
||||
assert result.winner == "monsters"
|
||||
assert result.winner == "players"
|
||||
assert result.rounds >= 1
|
||||
self._assert_deterministic_goblin("harvie")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user