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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user