feat(combat): support multi-cell occupancy for large and larger creatures
This commit is contained in:
@@ -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