feat(combat): support multi-cell occupancy for large and larger creatures

This commit is contained in:
2026-09-05 16:01:28 +02:00
parent 7f57df16a6
commit 30f8905a3e
5 changed files with 915 additions and 189 deletions
+290 -164
View File
@@ -76,7 +76,7 @@ Phase 0 documented deviations from PF1e (conventions):
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
@@ -107,7 +107,10 @@ if TYPE_CHECKING:
from pf1e_simulator.rng import Rng
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.los import has_cover, has_line_of_effect
from pf1e_simulator.los import (
has_cover_cells,
has_line_of_effect_cells,
)
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
@@ -149,6 +152,17 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = {
"Colossal": 30,
}
# Number of grid cells per side of a creature's footprint (square footprint).
# Unknown sizes fall back to 1 (the historical single-cell behavior).
SIZE_CELLS: dict[str, int] = {
"Small": 1,
"Medium": 1,
"Large": 2,
"Huge": 3,
"Gargantuan": 4,
"Colossal": 6,
}
# Row-major scan order of the 8 adjacent cells, used by reposition.
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
@@ -247,6 +261,18 @@ class CombatantState:
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
]
def occupied_cells(self) -> frozenset[Pos]:
"""The set of grid cells this creature occupies (its footprint).
``pos`` is the anchor (top-left corner); the footprint is an n x n
square where n = SIZE_CELLS[size]. A 1x1 creature occupies {pos}.
"""
n = SIZE_CELLS.get(self.combatant.size, 1)
r, c = self.pos
return frozenset(
(r + dr, c + dc) for dr in range(n) for dc in range(n)
)
def __post_init__(self) -> None:
if not self.spell_slots_left:
self.spell_slots_left = dict(self.combatant.spell_slots)
@@ -547,6 +573,46 @@ class CombatEngine:
return True
return False
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
"""Union of occupied cells of all active states except those in ``exclude``."""
return frozenset(
cell
for s in self._states
if s.active and all(s is not e for e in exclude)
for cell in s.occupied_cells()
)
def creature_distance(self, a: CombatantState, b: CombatantState) -> int:
"""Minimum grid distance between any occupied cell of ``a`` and ``b``."""
cells_b = b.occupied_cells()
return min(
self._grid.distance(cell_a, cell_b)
for cell_a in a.occupied_cells()
for cell_b in cells_b
)
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
"""True if every cell is in bounds, passable, and unoccupied by others."""
blocked = self._blocked(mover)
return all(
self._grid.passable(cell) and cell not in blocked for cell in cells
)
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
"""The cells ``state`` would occupy if its anchor were at ``anchor``."""
n = SIZE_CELLS.get(state.combatant.size, 1)
r, c = anchor
return frozenset(
(r + dr, c + dc) for dr in range(n) for dc in range(n)
)
def _footprint_cost(
self, footprint: frozenset[Pos], cost_field: dict[Pos, int]
) -> int | None:
"""Minimum cost over footprint cells present in the cost field."""
costs = [cost_field[cell] for cell in footprint if cell in cost_field]
return min(costs) if costs else None
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
"""Closest active enemy by grid distance; ties keep list order."""
enemies = [s for s in self._states if s.side != state.side and s.active]
@@ -554,14 +620,16 @@ class CombatEngine:
return None
return min(
enemies,
key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)),
key=lambda s: (self.creature_distance(state, s), self._states.index(s)),
)
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
"""First weapon usable against the target at this range and with clear LoE."""
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
if not has_line_of_effect_cells(
self._grid, attacker.occupied_cells(), target.occupied_cells()
):
return None
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
for weapon in attacker.usable_attacks():
if weapon.kind in ("melee", "touch"):
effective_reach_ft = _effective_reach_ft(attacker, weapon)
@@ -602,16 +670,22 @@ class CombatEngine:
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int:
"""+2 if an active ally threatens the target from the opposite side (CRB flanking)."""
ar, ac = attacker.pos
tr, tc = target.pos
opposite = (2 * tr - ar, 2 * tc - ac)
"""+2 if an active ally threatens the target from the opposite side (CRB flanking).
The opposite side is the mirror of every attacker cell across every
target cell; an ally occupying any mirrored cell flanks.
"""
opposite = {
(2 * tr - ar, 2 * tc - ac)
for ar, ac in attacker.occupied_cells()
for tr, tc in target.occupied_cells()
}
for ally in self._states:
if ally is attacker or ally is target:
continue
if not ally.active or ally.side != attacker.side:
continue
if ally.pos != opposite:
if not (ally.occupied_cells() & opposite):
continue
if any(w.kind == "melee" for w in ally.combatant.attacks):
return _FLANK_BONUS
@@ -713,7 +787,7 @@ class CombatEngine:
or self._has_condition_named(enemy, "shaken")
):
continue
dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT
dist_ft = self.creature_distance(state, enemy) * _SQUARE_FT
if dist_ft > self._FEAR_AURA_RANGE_FT:
continue
save = self.resolve_save(
@@ -765,7 +839,7 @@ class CombatEngine:
) -> AttackResult:
"""Roll one attack (with crit confirm and damage) and apply it."""
roll = self._rng.d20()
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
@@ -802,12 +876,15 @@ class CombatEngine:
base_ac = full_ac_base
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
cell
for s in self._states
if s.active and s is not attacker and s is not target
for cell in s.occupied_cells()
)
if has_cover(
if has_cover_cells(
self._grid,
attacker.pos,
target.pos,
attacker.occupied_cells(),
target.occupied_cells(),
ranged=self._is_ranged_attack(weapon),
occupied=occupied,
):
@@ -1003,25 +1080,44 @@ class CombatEngine:
return frozenset(threatened)
def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]:
"""Squares one melee weapon threatens: disk for non-reach, ring for reach."""
r, c = state.pos
"""Squares one melee weapon threatens: disk for non-reach, ring for reach.
The union over every occupied cell of the creature's footprint; the
creature's own cells are never threatened.
"""
threatened: set[Pos] = set()
if weapon.reach_ft > _SQUARE_FT:
ring = weapon.reach_ft // _SQUARE_FT
for dr in range(-ring, ring + 1):
for dc in range(-ring, ring + 1):
if self._grid.distance(state.pos, (r + dr, c + dc)) == ring:
threatened.add((r + dr, c + dc))
for r, c in state.occupied_cells():
threatened |= self._ring_cells((r, c), ring)
else:
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
for dr in range(-reach_cells, reach_cells + 1):
for dc in range(-reach_cells, reach_cells + 1):
if (dr, dc) == (0, 0):
continue
if self._grid.distance(state.pos, (r + dr, c + dc)) <= reach_cells:
threatened.add((r + dr, c + dc))
for r, c in state.occupied_cells():
threatened |= self._disk_cells((r, c), reach_cells)
threatened -= state.occupied_cells()
return threatened
def _ring_cells(self, center: Pos, ring: int) -> set[Pos]:
"""Cells at exactly ``ring`` squares from ``center`` (5-10-5 distance)."""
r, c = center
return {
(r + dr, c + dc)
for dr in range(-ring, ring + 1)
for dc in range(-ring, ring + 1)
if self._grid.distance(center, (r + dr, c + dc)) == ring
}
def _disk_cells(self, center: Pos, radius: int) -> set[Pos]:
"""Cells within ``radius`` squares of ``center`` (5-10-5 distance)."""
r, c = center
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0)
and self._grid.distance(center, (r + dr, c + dc)) <= radius
}
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
"""Active enemies whose threatened squares include pos (in self._states order)."""
return [
@@ -1049,6 +1145,21 @@ class CombatEngine:
attacker, target, weapon, label="AoO"
)
def _check_move_aoo(self, mover: CombatantState) -> bool:
"""Resolve AoOs provoked by leaving any threatened cell of the mover's footprint.
Each enemy threatening a cell the mover occupies strikes once (capped
by the per-round AoO allowance). Returns True if the mover dropped.
"""
for cell in mover.occupied_cells():
for enemy in self._enemies_threatening(mover, cell):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, mover)
if not mover.active:
return True
return False
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
@@ -1058,12 +1169,13 @@ class CombatEngine:
"""
if not self._is_ranged_attack(weapon):
return False
for enemy in self._enemies_threatening(attacker, attacker.pos):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
return True
for cell in attacker.occupied_cells():
for enemy in self._enemies_threatening(attacker, cell):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
return True
return False
def _check_casting_aoo(self, caster: CombatantState, spell_level: int) -> bool:
@@ -1071,26 +1183,27 @@ class CombatEngine:
Returns True if the caster dropped or failed concentration (spell fizzles).
"""
for enemy in self._enemies_threatening(caster, caster.pos):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
hp_before = caster.hp
self._resolve_aoo(enemy, caster)
if not caster.active:
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"casting interrupted by AoO from {enemy.combatant.id}"
)
return True
# Concentration check if damaged
dmg = hp_before - caster.hp
if dmg > 0 and not self._check_concentration(caster, spell_level, dmg):
dc = self._concentration_dc(caster, spell_level, dmg)
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"concentration check failed (DC {dc}), spell fizzles"
)
return True
for cell in caster.occupied_cells():
for enemy in self._enemies_threatening(caster, cell):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
hp_before = caster.hp
self._resolve_aoo(enemy, caster)
if not caster.active:
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"casting interrupted by AoO from {enemy.combatant.id}"
)
return True
# Concentration check if damaged
dmg = hp_before - caster.hp
if dmg > 0 and not self._check_concentration(caster, spell_level, dmg):
dc = self._concentration_dc(caster, spell_level, dmg)
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"concentration check failed (DC {dc}), spell fizzles"
)
return True
return False
def _concentration_dc(
@@ -1151,13 +1264,13 @@ class CombatEngine:
- Must end adjacent to the target (within melee reach).
- Only a single melee attack is allowed.
"""
if not has_line_of_effect(self._grid, state.pos, target.pos):
if not has_line_of_effect_cells(
self._grid, state.occupied_cells(), target.occupied_cells()
):
return None
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
max_cells = 2 * speed_cells
blocked = frozenset(
s.pos for s in self._states if s is not state and s is not target and s.active
)
blocked = self._blocked(state, target)
for weapon in state.usable_attacks():
if weapon.kind != "melee":
continue
@@ -1180,21 +1293,31 @@ class CombatEngine:
*,
min_reach_cells: int = 0,
) -> Pos | None:
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
"""Find the closest anchor from which ``state`` can melee ``target`` via a charge.
Scans squares within reach of the target, picks the one whose Bresenham
Scans anchors within reach of the target, picks the one whose Bresenham
line from ``state.pos`` is the shortest valid charge path (>=2 cells,
<=max_cells, no blocking terrain or creatures, all passable).
<=max_cells, no blocking terrain or creatures, footprint fits at the
end, footprint-to-footprint distance within the weapon's reach band).
Ties break toward lower distance from state.
"""
target_cells = target.occupied_cells()
candidates: list[tuple[int, Pos]] = []
for end in self._charge_candidates(
target, reach_cells, blocked, state.pos, min_reach_cells
):
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
footprint = self._footprint_at(end, state)
if not self._cells_free(footprint, state):
continue
dist = min(
self._grid.distance(cell_a, cell_b)
for cell_a in footprint
for cell_b in target_cells
)
if dist > reach_cells or dist < min_reach_cells:
continue
path = _bresenham_line(state.pos, end)
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
continue
if not self._charge_path_clear(path, blocked):
if not self._charge_path_clear(path, state):
continue
candidates.append((len(path), end))
if not candidates:
@@ -1208,36 +1331,32 @@ class CombatEngine:
reach_cells: int,
blocked: frozenset[Pos],
start: Pos,
min_reach_cells: int = 0,
) -> list[Pos]:
"""Squares within reach of target, passable, unoccupied, not the start."""
tr, tc = target.pos
result: list[Pos] = []
for dr in range(-reach_cells, reach_cells + 1):
for dc in range(-reach_cells, reach_cells + 1):
if abs(dr) > reach_cells or abs(dc) > reach_cells:
continue
if dr == 0 and dc == 0:
continue
end = (tr + dr, tc + dc)
if end == start:
continue
if not self._grid.in_bounds(end) or not self._grid.passable(end):
continue
if end in blocked:
continue
dist = self._grid.distance(end, target.pos)
if dist > reach_cells or dist < min_reach_cells:
continue
result.append(end)
return result
"""Anchors within reach of some target cell, passable, unoccupied, not the start."""
result: set[Pos] = set()
for tr, tc in target.occupied_cells():
for dr in range(-reach_cells, reach_cells + 1):
for dc in range(-reach_cells, reach_cells + 1):
if abs(dr) > reach_cells or abs(dc) > reach_cells:
continue
if dr == 0 and dc == 0:
continue
end = (tr + dr, tc + dc)
if end == start:
continue
if not self._grid.in_bounds(end) or not self._grid.passable(end):
continue
if end in blocked:
continue
result.add(end)
return sorted(result)
def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool:
"""True if every square on the charge path is passable and clear."""
def _charge_path_clear(
self, path: list[Pos], state: CombatantState
) -> bool:
"""True if the footprint fits at every anchor on the charge path."""
for pos in path:
if not self._grid.in_bounds(pos) or not self._grid.passable(pos):
return False
if pos in blocked:
if not self._cells_free(self._footprint_at(pos, state), state):
return False
terrain = self._grid.terrain_at(pos)
if terrain.move_cost is not None and terrain.move_cost > 1:
@@ -1257,9 +1376,7 @@ class CombatEngine:
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
max_cells = 2 * speed_cells
blocked = frozenset(
s.pos for s in self._states if s is not attacker and s is not target and s.active
)
blocked = self._blocked(attacker, target)
end = self._charge_end(
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
)
@@ -1269,12 +1386,8 @@ class CombatEngine:
start = attacker.pos
actual_path: list[Pos] = []
for step in path:
for enemy in self._enemies_threatening(attacker, attacker.pos):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
break
if self._check_move_aoo(attacker):
break
if not attacker.active:
break
attacker.pos = step
@@ -1550,7 +1663,7 @@ class CombatEngine:
)
)
beyond = self._beyond_cell(state.pos, target.pos)
if self._cell_free(beyond, state):
if self._cells_free(self._footprint_at(beyond, state), state):
state.pos = beyond
outcome = "SUCCESS (prone, moved through)"
else:
@@ -1611,7 +1724,7 @@ class CombatEngine:
continue
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
continue
if self._cell_free(pos, target):
if self._cells_free(self._footprint_at(pos, target), target):
new_pos = pos
break
if new_pos is not None:
@@ -1635,7 +1748,7 @@ class CombatEngine:
tr, tc = target.pos
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
beyond = self._beyond_cell(state.pos, target.pos)
if adjacent and self._cell_free(beyond, target):
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
target_old = target.pos
target.pos = beyond
state.pos = target_old
@@ -1867,7 +1980,7 @@ class CombatEngine:
# Up Close and Deadly: +1d6 damage within 30ft
if target is None or not target.active:
return
dist_ft = self._grid.distance(state.pos, target.pos) * _SQUARE_FT
dist_ft = self.creature_distance(state, target) * _SQUARE_FT
if dist_ft > _UP_CLOSE_RANGE_FT:
self._log(
f"round {self._current_round} {state.combatant.id}: deed Up Close "
@@ -1926,13 +2039,12 @@ class CombatEngine:
target = state
# Apply to all allies within 30ft (6 cells)
radius_cells = 30 // _SQUARE_FT
center = state.pos
affected: list[CombatantState] = []
for state_other in self._states:
if not state_other.active:
continue
if state_other.side == state.side:
dist = self._grid.distance(center, state_other.pos)
dist = self.creature_distance(state, state_other)
if dist <= radius_cells:
affected.append(state_other)
@@ -1997,7 +2109,7 @@ class CombatEngine:
return
if target is None or not target.active:
return
dist_ft = self._grid.distance(caster.pos, target.pos) * _SQUARE_FT
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
max_range = spell_range_ft(spell, caster.combatant.level)
if dist_ft > max_range:
self._log(
@@ -2005,7 +2117,9 @@ class CombatEngine:
f"cast {spell_name} -> OUT OF RANGE"
)
return
if not has_line_of_effect(self._grid, caster.pos, target.pos):
if not has_line_of_effect_cells(
self._grid, caster.occupied_cells(), target.occupied_cells()
):
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> NO LINE OF EFFECT"
@@ -2265,7 +2379,9 @@ class CombatEngine:
for state in self._states:
if not state.active:
continue
dist = self._grid.distance(center, state.pos)
dist = min(
self._grid.distance(center, cell) for cell in state.occupied_cells()
)
if dist <= radius_cells:
affected.append(state)
return affected
@@ -2351,8 +2467,7 @@ class CombatEngine:
if dr == 0 and dc == 0:
continue
pos = (center[0] + dr, center[1] + dc)
if (self._grid.passable(pos) and
not any(s.pos == pos for s in self._states if s.active)):
if self._cells_free(self._footprint_at(pos, state), state):
spawn_pos = pos
break
if spawn_pos:
@@ -2513,12 +2628,8 @@ class CombatEngine:
start = state.pos
actual_path: list[Pos] = []
for step in path:
for enemy in self._enemies_threatening(state, state.pos):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if self._check_move_aoo(state):
break
if not state.active:
break
state.pos = step
@@ -2529,42 +2640,61 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed."""
"""Full movement path toward the target, up to the combatant's speed.
Greedy walk of the anchor over the Dijkstra cost field from the target,
requiring the whole footprint to fit at each step.
"""
speed_mult = self._get_speed_mult(state)
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
if speed_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
blocked = self._blocked(state)
to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target:
if self._footprint_cost(state.occupied_cells(), to_target) is None:
return []
path: list[Pos] = []
current = state.pos
budget = speed_cells
while budget > 0:
current_cost = to_target[current]
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked:
continue
nxt_cost = to_target.get(nxt)
if nxt_cost is None or nxt_cost >= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = step + nxt_cost
if best is None or score < best[0]:
best = (score, step, nxt)
best = self._best_move_step(state, current, to_target, budget)
if best is None:
break
current = best[2]
_, step_cost, nxt = best
current = nxt
path.append(current)
budget -= best[1]
budget -= step_cost
return path
def _best_move_step(
self,
state: CombatantState,
current: Pos,
to_target: dict[Pos, int],
budget: int,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent anchor that best descends the cost field toward the target."""
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
if current_cost is None:
return None
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state):
continue
nxt_cost = self._footprint_cost(footprint, to_target)
if nxt_cost is None or nxt_cost >= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = step + nxt_cost
if best is None or score < best[0]:
best = (score, step, nxt)
return best
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
"""Movement path away from ``threat``, up to double speed.
@@ -2575,15 +2705,15 @@ class CombatEngine:
max_cells = int(2 * (state.combatant.speed_land_ft // _SQUARE_FT) * speed_mult)
if max_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
blocked = self._blocked(state)
from_threat = self._grid.reachable(threat.pos, None, blocked)
if state.pos not in from_threat:
if self._footprint_cost(state.occupied_cells(), from_threat) is None:
return []
path: list[Pos] = []
current = state.pos
budget = max_cells
while budget > 0:
best = self._best_flee_step(current, from_threat, blocked, budget)
best = self._best_flee_step(state, current, from_threat, budget)
if best is None:
break
_, step_cost, nxt = best
@@ -2594,22 +2724,25 @@ class CombatEngine:
def _best_flee_step(
self,
state: CombatantState,
current: Pos,
from_threat: dict[Pos, int],
blocked: frozenset[Pos],
budget: int,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent square that maximizes distance from the threat."""
current_cost = from_threat[current]
"""Pick the adjacent anchor that maximizes distance from the threat."""
current_cost = self._footprint_cost(self._footprint_at(current, state), from_threat)
if current_cost is None:
return None
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked or not self._grid.passable(nxt):
footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state):
continue
if not self._grid.diagonal_allowed(current, nxt):
continue
nxt_cost = from_threat.get(nxt)
nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
@@ -2634,13 +2767,8 @@ class CombatEngine:
start = state.pos
actual_path: list[Pos] = []
for i, step in enumerate(path):
if i > 0:
for enemy in self._enemies_threatening(state, state.pos):
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if i > 0 and self._check_move_aoo(state):
break
if not state.active:
break
state.pos = step
@@ -2673,11 +2801,7 @@ class CombatEngine:
if dc != 0:
dc = 1 if dc > 0 else -1
new_pos = (tr + dr, tc + dc)
# Check if destination is valid (in bounds, not blocked)
if not (0 <= new_pos[0] < self._grid.height and 0 <= new_pos[1] < self._grid.width):
return False
# Check if occupied
if any(s.pos == new_pos for s in self._states if s.active and s is not target):
if not self._cells_free(self._footprint_at(new_pos, target), target):
return False
target.pos = new_pos
return True
@@ -2717,7 +2841,7 @@ def _find_offensive_spell(
engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState
) -> str | None:
"""Find an offensive spell that can reach the target."""
dist_ft = engine.grid.distance(caster.pos, target.pos) * _SQUARE_FT
dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT
for name in spells_known:
spell = engine.spell_registry.get(name)
if not spell:
@@ -2727,7 +2851,9 @@ def _find_offensive_spell(
if not has_damage:
continue
max_range = spell_range_ft(spell, caster.combatant.level)
if dist_ft <= max_range and has_line_of_effect(engine.grid, caster.pos, target.pos):
if dist_ft <= max_range and has_line_of_effect_cells(
engine.grid, caster.occupied_cells(), target.occupied_cells()
):
return name
return None
@@ -2900,7 +3026,7 @@ def _emit_channel_energy(
if s.side != state.side
and s.active
and s.combatant.creature_type == "undead"
and engine.grid.distance(state.pos, s.pos) <= radius_cells
and engine.creature_distance(state, s) <= radius_cells
]
return "harm" if len(grouped_undead_enemies) >= _GROUPED_ENEMY_COUNT else None
@@ -2913,7 +3039,7 @@ def _emit_hex(
return False
if target is None or not target.active:
return False
return engine.grid.distance(state.pos, target.pos) * _SQUARE_FT <= _HEX_RANGE_FT
return engine.creature_distance(state, target) * _SQUARE_FT <= _HEX_RANGE_FT
def _spell_combat_actions(
@@ -2941,7 +3067,7 @@ def _swift_bonus_actions(
# when the target is within Up Close and Deadly range (30 ft).
if (
state.resources_left.get("grit", 0) > 0
and engine.grid.distance(state.pos, target.pos) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT
and engine.creature_distance(state, target) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT
):
bonus.append(
Action(
+49 -13
View File
@@ -67,18 +67,17 @@ def _segment_hits_cell(p1: Pt, p2: Pt, row: int, col: int) -> bool:
class _LineGrid:
"""Bound view for corner-line checks between two squares of one grid."""
"""Bound view for corner-line checks between two sets of squares of one grid."""
def __init__(
self,
grid: Grid,
a: Pos,
b: Pos,
exclude: frozenset[Pos],
blocking: Callable[[TerrainType], bool],
occupied: frozenset[Pos] = frozenset(),
) -> None:
self._grid = grid
self._exclude = frozenset({a, b})
self._exclude = exclude
self._blocking = blocking
self._occupied = occupied
@@ -105,13 +104,23 @@ def _grants_cover(terrain: TerrainType) -> bool:
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
"""LoE iff some corner of a reaches some corner of b unblocked."""
if a == b:
return has_line_of_effect_cells(grid, frozenset({a}), frozenset({b}))
def has_line_of_effect_cells(
grid: Grid, a_cells: frozenset[Pos], b_cells: frozenset[Pos]
) -> bool:
"""LoE iff some corner of some cell of ``a_cells`` reaches some corner of
some cell of ``b_cells`` unblocked."""
if a_cells & b_cells:
return True
lines = _LineGrid(grid, a, b, lambda terrain: terrain.blocks_los)
lines = _LineGrid(grid, a_cells | b_cells, lambda terrain: terrain.blocks_los)
return any(
not lines.blocked(corner_a, corner_b)
for corner_a in _corners(a)
for corner_b in _corners(b)
for cell_a in a_cells
for corner_a in _corners(cell_a)
for cell_b in b_cells
for corner_b in _corners(cell_b)
)
@@ -130,16 +139,43 @@ def has_cover(
ranged attacks, squares occupied by other creatures also block the
lines (soft cover, CRB); melee ignores creatures.
"""
if attacker == target:
return has_cover_cells(
grid,
frozenset({attacker}),
frozenset({target}),
ranged=ranged,
occupied=occupied,
)
def has_cover_cells(
grid: Grid,
attacker_cells: frozenset[Pos],
target_cells: frozenset[Pos],
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner.
The attacker picks their single best cell (and best corner within it);
the target's corners are the union over all its cells. For 1x1 creatures
this is exactly the single-cell corner rule.
"""
if attacker_cells & target_cells:
return False
lines = _LineGrid(
grid,
attacker,
target,
attacker_cells | target_cells,
_grants_cover,
occupied if ranged else frozenset(),
)
return not any(
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
for corner_a in _corners(attacker)
all(
not lines.blocked(corner_a, corner_t)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
)
+46 -10
View File
@@ -19,9 +19,14 @@ if TYPE_CHECKING:
from pf1e_simulator.models import Combatant
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
from pf1e_simulator.combat import (
SIZE_CELLS,
CombatantState,
CombatantStats,
CombatEngine,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import zone_cells
from pf1e_simulator.map import Pos, zone_cells
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
@@ -85,26 +90,52 @@ class EncounterReport:
return win_rate_band(self.win_rate(side), self.runs)
def _first_fit_anchor(
cells: list[Pos], placed: set[Pos], n: int, grid: Grid
) -> Pos | None:
"""First zone cell whose n x n footprint is in bounds, passable, and unplaced."""
for anchor in cells:
r, c = anchor
footprint = {(r + dr, c + dc) for dr in range(n) for dc in range(n)}
if any(not grid.in_bounds(p) or not grid.passable(p) for p in footprint):
continue
if footprint & placed:
continue
return anchor
return None
def build_states(spec: EncounterSpec) -> list[CombatantState]:
"""Fresh states for one battle; duplicate combatant ids get -N suffixes.
Combatants of a side fill the zone's cells in (row, col) order. A side with
more combatants than zone cells, or a zone with no cells, is a spec error.
Creatures larger than 1x1 occupy an n x n footprint anchored at their cell.
"""
states: list[CombatantState] = []
seen: dict[str, int] = {}
grid = Grid.from_spec(spec.map)
for side in spec.sides:
cells = zone_cells(spec.map, side.zone)
if not cells:
msg = f"side {side.name!r}: zone {side.zone!r} has no cells"
raise ValueError(msg)
if len(cells) < len(side.combatants):
msg = (
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
f"for {len(side.combatants)} combatants"
)
raise ValueError(msg)
for combatant, pos in zip(side.combatants, cells, strict=False):
placed: set[Pos] = set()
for combatant in side.combatants:
n = SIZE_CELLS.get(combatant.size, 1)
anchor = _first_fit_anchor(cells, placed, n, grid)
if anchor is None:
if n == 1:
msg = (
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
f"for {len(side.combatants)} combatants"
)
else:
msg = (
f"side {side.name!r}: zone {side.zone!r} has no room for "
f"{combatant.id!r} ({combatant.size}, {n}x{n})"
)
raise ValueError(msg)
count = seen.get(combatant.id, 0) + 1
seen[combatant.id] = count
cid = combatant.id if count == 1 else f"{combatant.id}-{count}"
@@ -113,11 +144,16 @@ def build_states(spec: EncounterSpec) -> list[CombatantState]:
if cid == combatant.id
else combatant.model_copy(update={"id": cid})
)
placed |= {
(anchor[0] + dr, anchor[1] + dc)
for dr in range(n)
for dc in range(n)
}
states.append(
CombatantState(
combatant=effective,
side=side.name,
pos=pos,
pos=anchor,
hp=effective.hp_max,
resources_left=dict(effective.resources),
)