From 79ac190728b036e6c45cd3f2026daa4fba432225 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 17:46:57 +0200 Subject: [PATCH] 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. --- README.md | 27 ++-- src/pf1e_simulator/combat.py | 33 +++-- tests/test_fly_speed.py | 222 +++++++++++++++++++++++++++++++++ tests/test_pc_combat_golden.py | 9 +- 4 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 tests/test_fly_speed.py diff --git a/README.md b/README.md index ac9cb5e..225dc7f 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1d7e32b..6064265 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_fly_speed.py b/tests/test_fly_speed.py new file mode 100644 index 0000000..9f48ba5 --- /dev/null +++ b/tests/test_fly_speed.py @@ -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) diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index b018496..bdbe76e 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -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")