fix(rules): give Tiny-or-smaller creatures zero reach and square sharing

This commit is contained in:
2026-09-13 15:35:23 +02:00
parent 7fb7ec55dd
commit b579cb4934
4 changed files with 322 additions and 21 deletions
+59 -10
View File
@@ -306,8 +306,15 @@ def _size_rank(size: str) -> int:
return _SIZE_ORDER.index("medium")
# PF1e natural reach for tall creatures (simplification: no long/quadruped
# distinction). Unknown sizes fall back to 5 ft (the historical behavior).
# distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD
# "Big and Little Creatures in Combat"): "Creatures that take up less than 1
# square of space typically have a natural reach of 0 feet, meaning they can't
# reach into adjacent squares." Unknown sizes fall back to 5 ft (the historical
# behavior).
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
"Fine": 0,
"Diminutive": 0,
"Tiny": 0,
"Small": 5,
"Medium": 5,
"Large": 10,
@@ -316,6 +323,28 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = {
"Colossal": 30,
}
# PF1e co-occupancy (SRD "Big and Little Creatures in Combat"): "Very small
# creatures take up less than 1 square of space. This means that more than one
# such creature can fit into a single square" and "They must enter an
# opponent's square to attack in melee" — a Tiny-or-smaller creature shares a
# square with a strictly larger one (and the larger one may move through and
# end in the smaller's square). Small and larger creatures occupy a full
# square and never share. The rank ceiling for "takes up less than 1 square".
_TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny")
def _can_cooccupy(size_a: str, size_b: str) -> bool:
"""True when two sizes may share a single grid square (PF1e co-occupancy).
Only a Tiny-or-smaller creature ("less than 1 square") and a STRICTLY
larger creature share; two same-size creatures never do.
"""
rank_a = _size_rank(size_a)
rank_b = _size_rank(size_b)
if rank_a == rank_b:
return False
return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK
# 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] = {
@@ -1378,6 +1407,7 @@ class CombatEngine:
"name": state.combatant.name,
"level": state.combatant.level,
"size": state.combatant.size,
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
"aware": state.combatant.aware,
"creature_type": state.combatant.creature_type,
"bab": state.combatant.bab,
@@ -2279,25 +2309,44 @@ class CombatEngine:
)
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()
)
"""Occupied cells that block the mover (the first excluded state).
Union of the active creatures' cells except those in ``exclude``. PF1e
co-occupancy (SRD "Big and Little Creatures in Combat") lets a
Tiny-or-smaller creature share a square with a strictly larger one, so
a co-occupiable creature's cells do NOT block the mover (it may move
through and end on them); two same-size creatures (or two Small-or-
larger) never share, so their cells still block.
"""
mover = exclude[0] if exclude else None
blocked: set[Pos] = set()
for s in self._states:
if not s.active or any(s is e for e in exclude):
continue
if mover is not None and _can_cooccupy(
mover.combatant.size, s.combatant.size
):
continue
blocked |= set(s.occupied_cells())
return frozenset(blocked)
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
"""Occupied cells of active creatures hostile to ``mover``.
Same-side allies are excluded: PF1e (SRD Movement, Position and
Distance) — "You can move through a square occupied by a friendly
character, unless you are charging."
character, unless you are charging." Co-occupiable creatures (a
Tiny-or-smaller mover and a strictly larger occupant, or the reverse)
are excluded too — the mover may pass through and end on their squares
(SRD "Big and Little Creatures in Combat").
"""
return frozenset(
cell
for s in self._states
if s.active and s is not mover and s.side != mover.side
if s.active
and s is not mover
and s.side != mover.side
and not _can_cooccupy(mover.combatant.size, s.combatant.size)
for cell in s.occupied_cells()
)