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
+47 -10
View File
@@ -657,8 +657,9 @@ Règles modélisées :
inchangée. `default_policy` ne choisit pas d'arme (disponible via une inchangée. `default_policy` ne choisit pas d'arme (disponible via une
politique personnalisée). politique personnalisée).
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») : - Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un
**disque** : la créature
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10 comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque
@@ -669,6 +670,19 @@ Règles modélisées :
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
allonge menace les cases adjacentes (disque de 5 ft). allonge menace les cases adjacentes (disque de 5 ft).
**S4c — allonge nulle des créatures Tiny et plus petites** (CRB « Big and
Little Creatures in Combat » : « Tiny, Diminutive, and Fine Creatures [...]
They must enter an opponent's square to attack in melee ») : l'allonge
naturelle de Tiny/Diminutive/Fine vaut **0 ft** — une telle créature ne
**menace aucune case** (aucune AoO) et ne peut pas frapper une case
adjacente ; son attaque de mêlée n'est utilisable qu'à **distance 0**, la
case qu'elle partage avec sa cible (l'abstraction « entrer dans la case de
l'adversaire »). L'allonge effective reste `max(allonge naturelle,
reach_ft de l'arme)` : une attaque naturelle dont le JSON déclare
explicitement `reach_ft` (ierlieth : morsure `reach_ft: 5`) conserve cette
allonge — le `reach_ft` d'une attaque naturelle EST son allonge naturelle,
une créature Tiny peut donc être encodée avec une allonge non nulle si sa
fiche l'exige.
Mouvement : un déplacement parcourt jusqu'à la Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
@@ -711,6 +725,21 @@ Règles modélisées :
manœuvres et invocation sont généralisés aux cellules occupées (distance = manœuvres et invocation sont généralisés aux cellules occupées (distance =
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1 minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
reste bit-identique. reste bit-identique.
**S4c — co-occupation des créatures Tiny et plus petites** (CRB « 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 ») : une créature Tiny/Diminutive/Fine peut **traverser et
finir son déplacement** dans une case occupée par une créature plus grande,
et une créature plus grande peut traverser et finir dans une case occupée
par une créature Tiny ou plus petite — la case n'est pas « bloquée » pour
ce couple de tailles (`_can_cooccupy` : tailles différentes ET la plus
petite est Tiny ou moins). Deux créatures de même taille (ou Small et plus
grande) ne partagent jamais une case : le blocage reste total pour elles.
La co-occupation s'applique au mouvement (`_blocked`/`_blocked_hostile`
sont conscients du déplaceur), à la destination explicite (`_cells_free`)
et à la charge ; deux créatures partageant une case sont à **distance 0**
l'une de l'autre (ligne d'effet directe, attaque de mêlée possible — c'est
la voie d'attaque des créatures à allonge nulle, voir « Mêlée »).
- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et - Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et
`attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées `attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1, du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
@@ -1228,9 +1257,11 @@ Règles modélisées :
L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller
use their Dexterity modifier in place of their Strength modifier to use their Dexterity modifier in place of their Strength modifier to
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
le modificateur de Dextérité à la place de celui de Force ; lacune le modificateur de Dextérité à la place de celui de Force. Leur allonge
documentée : l'allonge nulle et la co-occupation des créatures Tiny et naturelle nulle (aucune case menacée, attaque dans leur propre case) et la
plus petites ne sont pas modélisées (abstraction à la grille de 5 ft). co-occupation Tiny-or-smaller avec une créature strictement plus grande
sont modélisées (conformance S4c) ; une attaque naturelle dotée d'un
`reach_ft` explicite (ex. morsure d'ierlieth) conserve cette allonge.
Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute
les bonus de CA des huit types autorisés (« A creature can also add any les bonus de CA des huit types autorisés (« A creature can also add any
circumstance, deflection, dodge, insight, luck, morale, profane, and circumstance, deflection, dodge, insight, luck, morale, profane, and
@@ -1391,7 +1422,10 @@ Règles modélisées :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, aware (SRD « Surprise » : agit au round de surprise), name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
dr {amount, bypass} ou None (C1), creature_type (C3), dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
@@ -1514,10 +1548,13 @@ plutôt que bricolées) :
spell automatically fails ») : non planifiables dans un tour, ils sont spell automatically fails ») : non planifiables dans un tour, ils sont
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
données d'objectif pour ces sorts rituels. données d'objectif pour ces sorts rituels.
- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur - **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller
allonge nulle et la co-occupation de leurs cases (plusieurs créatures est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque
minuscules sur une même case) ne sont pas modélisées ; seule la substitution possible dans sa propre case, et partage de case avec une créature
Dextérité/Force du CMB (conformance L1) tient compte de leur taille. strictement plus grande (déplacement à travers et fin de mouvement dessus) ;
en revanche deux créatures de même taille (Small et plus) ne partagent
jamais une case, et la co-occupation de plusieurs Tiny sur une même case
n'est pas modélisée.
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
+59 -10
View File
@@ -306,8 +306,15 @@ def _size_rank(size: str) -> int:
return _SIZE_ORDER.index("medium") return _SIZE_ORDER.index("medium")
# PF1e natural reach for tall creatures (simplification: no long/quadruped # 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] = { _SIZE_NATURAL_REACH_FT: dict[str, int] = {
"Fine": 0,
"Diminutive": 0,
"Tiny": 0,
"Small": 5, "Small": 5,
"Medium": 5, "Medium": 5,
"Large": 10, "Large": 10,
@@ -316,6 +323,28 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = {
"Colossal": 30, "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). # Number of grid cells per side of a creature's footprint (square footprint).
# Unknown sizes fall back to 1 (the historical single-cell behavior). # Unknown sizes fall back to 1 (the historical single-cell behavior).
SIZE_CELLS: dict[str, int] = { SIZE_CELLS: dict[str, int] = {
@@ -1378,6 +1407,7 @@ class CombatEngine:
"name": state.combatant.name, "name": state.combatant.name,
"level": state.combatant.level, "level": state.combatant.level,
"size": state.combatant.size, "size": state.combatant.size,
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
"aware": state.combatant.aware, "aware": state.combatant.aware,
"creature_type": state.combatant.creature_type, "creature_type": state.combatant.creature_type,
"bab": state.combatant.bab, "bab": state.combatant.bab,
@@ -2279,25 +2309,44 @@ class CombatEngine:
) )
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]: def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
"""Union of occupied cells of all active states except those in ``exclude``.""" """Occupied cells that block the mover (the first excluded state).
return frozenset(
cell Union of the active creatures' cells except those in ``exclude``. PF1e
for s in self._states co-occupancy (SRD "Big and Little Creatures in Combat") lets a
if s.active and all(s is not e for e in exclude) Tiny-or-smaller creature share a square with a strictly larger one, so
for cell in s.occupied_cells() 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]: def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
"""Occupied cells of active creatures hostile to ``mover``. """Occupied cells of active creatures hostile to ``mover``.
Same-side allies are excluded: PF1e (SRD Movement, Position and Same-side allies are excluded: PF1e (SRD Movement, Position and
Distance) — "You can move through a square occupied by a friendly 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( return frozenset(
cell cell
for s in self._states 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() for cell in s.occupied_cells()
) )
+116
View File
@@ -403,3 +403,119 @@ def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
"round 1 dest: wait", "round 1 dest: wait",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",
) )
# ── S4c: Tiny/Diminutive/Fine natural reach 0 and co-occupancy ───────────────
def test_tiny_reach_zero_threatens_nothing() -> None:
"""S4c: a Tiny creature's natural reach is 0 — it threatens no squares
(no AoO) and cannot strike adjacent squares."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
state = make_state(tiny, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset()
assert (3, 2) not in engine.threatened_squares(state)
def test_tiny_reach_zero_attacks_own_cell_only() -> None:
"""S4c: a 0-reach Tiny can attack a creature in its OWN cell (distance 0)
but not an adjacent one (distance 1)."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
target = make_combatant("target", hp=20, ac=12)
tiny_state = make_state(tiny, "players", (3, 3))
# Same cell: distance 0 -> the 0-reach attack is usable.
foe_same = make_state(target, "monsters", (3, 3))
engine_same = make_engine([tiny_state, foe_same])
assert engine_same.weapon_for(tiny_state, foe_same) is not None
# Adjacent cell: distance 1 (5 ft) -> out of the 0-reach zone.
foe_adj = make_state(target, "monsters", (3, 4))
engine_adj = make_engine([tiny_state, foe_adj])
assert engine_adj.weapon_for(tiny_state, foe_adj) is None
def _tiny_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "tiny":
return (Action(kind="attack", target_id="target"),)
return (Action(kind="wait"),)
def test_tiny_reach_zero_hits_creature_in_own_cell() -> None:
"""S4c: a 0-reach Tiny strikes a creature sharing its cell (the 'entered
the square' abstraction) — the attack resolves at distance 0."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
target = make_combatant("target", hp=20, ac=12)
tiny_state = make_state(tiny, "players", (3, 3))
target_state = make_state(target, "monsters", (3, 3))
engine = CombatEngine(
ScriptedRng([20, 1, 15, 3]),
make_grid(), [tiny_state, target_state],
round_cap=1, policy=_tiny_attack_policy,
)
result = engine.run()
assert any(
"short sword vs target" in line and "-> HIT" in line
for line in result.transcript
), result.transcript
assert target_state.hp == 17 # 20 - 1d6(3)
def _tiny_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "tiny":
return (Action(kind="move", destination=(3, 3)),)
return (Action(kind="wait"),)
def test_tiny_ends_move_in_medium_cell() -> None:
"""S4c: a Tiny creature may END its move in a cell occupied by a larger
creature (co-occupancy)."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
medium = make_combatant("medium", hp=20, ac=15, speed=0)
tiny_state = make_state(tiny, "players", (3, 2))
medium_state = make_state(medium, "monsters", (3, 3))
engine = CombatEngine(
ScriptedRng([20, 1, 5]),
make_grid(), [tiny_state, medium_state],
round_cap=1, policy=_tiny_move_policy,
)
result = engine.run()
assert any("move -> (3,3)" in line for line in result.transcript), result.transcript
assert tiny_state.pos == (3, 3)
def _medium_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "a":
return (Action(kind="move", destination=(3, 3)),)
return (Action(kind="wait"),)
def test_two_medium_cannot_share_cell() -> None:
"""S4c: two Medium creatures still cannot share a cell — the move onto
the occupied cell is rejected."""
a = make_combatant("a", speed=30)
b = make_combatant("b", hp=20, ac=15, speed=0)
a_state = make_state(a, "players", (3, 2))
b_state = make_state(b, "monsters", (3, 3))
engine = CombatEngine(
ScriptedRng([20, 1]),
make_grid(), [a_state, b_state],
round_cap=1, policy=_medium_move_policy,
)
result = engine.run()
assert any(
"move rejected (unreachable_destination)" in line for line in result.transcript
), result.transcript
assert a_state.pos == (3, 2)
def test_observation_exposes_natural_reach_ft() -> None:
"""S4c: the combatant snapshot exposes the size's natural reach."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
medium = make_combatant("medium")
tiny_state = make_state(tiny, "players", (3, 3))
medium_state = make_state(medium, "monsters", (3, 5))
engine = make_engine([tiny_state, medium_state])
obs = engine._build_observation(tiny_state).to_dict()
combatants = {c["id"]: c for c in obs["combatants"]}
assert combatants["tiny"]["natural_reach_ft"] == 0
assert combatants["medium"]["natural_reach_ft"] == 5
+99
View File
@@ -0,0 +1,99 @@
"""Tests for Tiny-or-smaller reach zero and square sharing (PF1e S4c).
SRD "Big and Little Creatures in Combat": Tiny, Diminutive and Fine creatures
"take up less than 1 square of space", have "a natural reach of 0 feet", do not
threaten the squares around them, and "must enter an opponent's square to
attack" — so a Tiny-or-smaller creature and a strictly larger one may share a
square (move through and end on it), while two creatures of the same (Small+)
size never share.
"""
from __future__ import annotations
from pf1e_simulator.combat import _effective_reach_ft
from tests.test_creature_sizes import make_combatant, make_engine, make_state
class TestTinyReachZero:
"""A Tiny creature whose melee attack has reach_ft 0 reaches only its cell."""
def test_threatens_no_squares(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert eng.threatened_squares(s_tiny) == frozenset()
def test_attacks_a_creature_in_its_own_cell(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 3)) # sharing the square
eng = make_engine([s_tiny, s_foe])
assert eng.weapon_for(s_tiny, s_foe) is not None
def test_cannot_reach_an_adjacent_creature(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert eng.weapon_for(s_tiny, s_foe) is None
def test_authored_reach_is_kept(self) -> None:
# A natural attack authored with reach_ft 5 (e.g. ierlieth's bite)
# overrides the size default: the creature reaches and threatens 5 ft.
tiny = make_combatant("tiny", size="Tiny", reach_ft=5)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert _effective_reach_ft(s_tiny, tiny.attacks[0]) == 5
assert (3, 4) in eng.threatened_squares(s_tiny)
assert eng.weapon_for(s_tiny, s_foe) is not None
class TestCoOccupancy:
"""A Tiny-or-smaller creature shares a square with a strictly larger one."""
def test_tiny_may_end_on_a_larger_creature_cell(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert eng._destination_reachable(s_tiny, (3, 4)) is True
def test_larger_may_end_on_a_tiny_cell(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 4))
s_foe = make_state(foe, "b", (3, 3))
eng = make_engine([s_tiny, s_foe])
assert eng._destination_reachable(s_foe, (3, 4)) is True
def test_two_medium_never_share(self) -> None:
m1 = make_combatant("m1", size="Medium")
m2 = make_combatant("m2", size="Medium")
s1 = make_state(m1, "a", (3, 3))
s2 = make_state(m2, "b", (3, 4))
eng = make_engine([s1, s2])
assert eng._destination_reachable(s1, (3, 4)) is False
assert eng._cells_free(s2.occupied_cells(), s1) is False
def test_tiny_may_cross_a_larger_hostile_cell(self) -> None:
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
foe = make_combatant("foe", size="Medium")
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert eng._cells_walkable(s_foe.occupied_cells(), s_tiny) is True
def test_medium_cannot_cross_a_medium_hostile_cell(self) -> None:
m1 = make_combatant("m1", size="Medium")
m2 = make_combatant("m2", size="Medium")
s1 = make_state(m1, "a", (3, 3))
s2 = make_state(m2, "b", (3, 4))
eng = make_engine([s1, s2])
assert eng._cells_walkable(s2.occupied_cells(), s1) is False