feat(combat): support multi-cell occupancy for large and larger creatures
This commit is contained in:
+290
-164
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user