fix(rules): give Tiny-or-smaller creatures zero reach and square sharing
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user