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
+116
View File
@@ -403,3 +403,119 @@ def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
"round 1 dest: wait",
"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