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