4d89305881
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural reach is a disk (a Large creature still threatens and strikes adjacent squares), while a reach weapon doubles the wielder's natural reach but 'can't strike at their natural reach or less' (a ring whose dead zone is the natural reach). The engine keyed the ring off reach_ft > 5, so reach-10 natural attacks (giant belostomatid bite/claw, mounted hooves) wrongly got a 5-ft dead zone. AttackSpec gains reach_weapon (parsed from the combatant JSON); the disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied consistently to weapon selection, melee usability, threatened squares and charge end placement. Esha's Lance is the only reach weapon in the shipped data; Large natural attackers keep the default (disk). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
544 lines
21 KiB
Python
544 lines
21 KiB
Python
"""Tests for B5.2: multi-cell occupancy for Large+ creatures.
|
|
|
|
Covers:
|
|
- occupied_cells(): Medium -> {pos}, Large -> 4 cells, Huge -> 9 cells
|
|
- build_states: Large in 2x2+ zone; zone too small -> error
|
|
- creature_distance: min distance across occupied cells
|
|
- _threatened_squares: union of per-cell threatened areas for Large
|
|
- Movement: Large moves as footprint; stops at melee range
|
|
- Flanking: mirror cell from any occupied cell grants +2
|
|
- Charge: Large charge ends where footprint fits
|
|
- AoO: leaving threatened footprint cell triggers AoO
|
|
- LoS/cover: best cell of attacker vs target
|
|
- Equivalence: Medium 1x1 unchanged
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from pf1e_simulator.combat import (
|
|
SIZE_CELLS,
|
|
Action,
|
|
CombatantState,
|
|
CombatEngine,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.los import has_cover, has_line_of_effect
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from pf1e_simulator.runner import EncounterSpec, Side, build_states
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_combatant(
|
|
cid: str,
|
|
*,
|
|
size: str = "Medium",
|
|
reach_ft: int = 5,
|
|
reach_weapon: bool = False,
|
|
hp: int = 20,
|
|
ac: int = 15,
|
|
attack_bonus: int = 5,
|
|
speed: int = 30,
|
|
weapon_name: str = "short sword",
|
|
) -> Combatant:
|
|
"""Build a single-melee-weapon combatant."""
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name=weapon_name,
|
|
kind="melee",
|
|
attack_bonus=attack_bonus,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
|
reach_ft=reach_ft,
|
|
reach_weapon=reach_weapon,
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size=size,
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=12,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=hp,
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=attack_bonus,
|
|
initiative_mod=0,
|
|
speed_land_ft=speed,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[attack],
|
|
)
|
|
|
|
|
|
def make_state(
|
|
combatant: Combatant, side: str, pos: tuple[int, int]
|
|
) -> CombatantState:
|
|
return CombatantState(
|
|
combatant=combatant, side=side, pos=pos, hp=combatant.hp_max,
|
|
)
|
|
|
|
|
|
def make_grid(h: int = 10, w: int = 10) -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(
|
|
name="test", terrain=tuple(["." * w] * h), legend=legend,
|
|
)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def make_wall_grid() -> Grid:
|
|
"""10x10 grid with a wall at column 5 (rows 0-9)."""
|
|
terrain = []
|
|
for _ in range(10):
|
|
row = "." * 5 + "#" + "." * 4
|
|
terrain.append(row)
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
spec = MapSpec(name="test", terrain=tuple(terrain), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def make_engine(
|
|
states: list[CombatantState], *, grid: Grid | None = None,
|
|
) -> CombatEngine:
|
|
return CombatEngine(
|
|
ScriptedRng([]), grid or make_grid(), states,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. occupied_cells
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestOccupiedCells:
|
|
def test_medium_returns_singleton(self) -> None:
|
|
c = make_combatant("m", size="Medium")
|
|
s = make_state(c, "a", (3, 4))
|
|
assert s.occupied_cells() == frozenset({(3, 4)})
|
|
|
|
def test_small_returns_singleton(self) -> None:
|
|
c = make_combatant("s", size="Small")
|
|
s = make_state(c, "a", (3, 4))
|
|
assert s.occupied_cells() == frozenset({(3, 4)})
|
|
|
|
def test_large_returns_2x2(self) -> None:
|
|
c = make_combatant("l", size="Large")
|
|
s = make_state(c, "a", (3, 4))
|
|
assert s.occupied_cells() == frozenset({
|
|
(3, 4), (3, 5), (4, 4), (4, 5),
|
|
})
|
|
|
|
def test_huge_returns_3x3(self) -> None:
|
|
c = make_combatant("h", size="Huge")
|
|
s = make_state(c, "a", (0, 0))
|
|
cells = s.occupied_cells()
|
|
assert len(cells) == 9
|
|
for dr in range(3):
|
|
for dc in range(3):
|
|
assert (dr, dc) in cells
|
|
|
|
def test_unknown_size_returns_singleton(self) -> None:
|
|
c = make_combatant("u", size="Weird")
|
|
s = make_state(c, "a", (2, 2))
|
|
assert s.occupied_cells() == frozenset({(2, 2)})
|
|
|
|
def test_size_cells_table(self) -> None:
|
|
assert SIZE_CELLS["Small"] == 1
|
|
assert SIZE_CELLS["Medium"] == 1
|
|
assert SIZE_CELLS["Large"] == 2
|
|
assert SIZE_CELLS["Huge"] == 3
|
|
assert SIZE_CELLS["Gargantuan"] == 4
|
|
assert SIZE_CELLS["Colossal"] == 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. build_states placement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildStatesLarge:
|
|
def test_large_in_zone_2x2(self) -> None:
|
|
"""A Large creature placed in a zone cell occupies 2x2 from that anchor."""
|
|
spec = EncounterSpec(
|
|
map=MapSpec(
|
|
name="t",
|
|
terrain=tuple(["...."] * 4),
|
|
legend={".": TerrainType(type="floor", move_cost=1)},
|
|
zones=tuple(["AAAA"] * 4),
|
|
deployment={"p": "A"},
|
|
),
|
|
sides=(Side(name="p", combatants=(make_combatant("g", size="Large"),), zone="A"),),
|
|
)
|
|
states = build_states(spec)
|
|
assert len(states) == 1
|
|
assert states[0].occupied_cells() == frozenset({
|
|
(0, 0), (0, 1), (1, 0), (1, 1),
|
|
})
|
|
|
|
def test_zone_too_small_for_large_raises(self) -> None:
|
|
"""A zone with only 1 cell can't hold a Large creature's 2x2 footprint."""
|
|
spec = EncounterSpec(
|
|
map=MapSpec(
|
|
name="t",
|
|
terrain=(".",),
|
|
legend={".": TerrainType(type="floor", move_cost=1)},
|
|
zones=("A",),
|
|
deployment={"p": "A"},
|
|
),
|
|
sides=(Side(name="p", combatants=(make_combatant("g", size="Large"),), zone="A"),),
|
|
)
|
|
with pytest.raises(ValueError, match="zone"):
|
|
build_states(spec)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. creature_distance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCreatureDistance:
|
|
def test_medium_to_medium(self) -> None:
|
|
a = make_state(make_combatant("a"), "p", (0, 0))
|
|
b = make_state(make_combatant("b"), "e", (3, 4))
|
|
engine = make_engine([a, b])
|
|
assert engine.creature_distance(a, b) == 5 # 5-10-5 diagonal
|
|
|
|
def test_large_to_medium_adjacent(self) -> None:
|
|
"""Large at (0,0) occupies (0,0)(0,1)(1,0)(1,1).
|
|
Medium at (1,2): distance from any Large cell = min 1.
|
|
"""
|
|
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
|
|
b = make_state(make_combatant("b"), "e", (1, 2))
|
|
engine = make_engine([a, b])
|
|
assert engine.creature_distance(a, b) == 1
|
|
|
|
def test_large_to_medium_diagonal_touches_at_dist_1(self) -> None:
|
|
"""Large at (0,0) has cell (1,1).
|
|
Medium at (2,2): dist from (1,1) to (2,2) = 1 diagonal.
|
|
"""
|
|
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
|
|
b = make_state(make_combatant("b"), "e", (2, 2))
|
|
engine = make_engine([a, b])
|
|
assert engine.creature_distance(a, b) == 1
|
|
|
|
def test_large_to_large(self) -> None:
|
|
"""Two Large creatures at (0,0) and (5,5)."""
|
|
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
|
|
b = make_state(make_combatant("b", size="Large"), "e", (5, 5))
|
|
engine = make_engine([a, b])
|
|
# min dist from (1,1) to (5,5): d_row=4, d_col=4, max+min//2=4+2=6
|
|
# min dist from (0,0) to (5,5): same 6
|
|
# min dist from (0,1) to (5,5): 4+2=6
|
|
# min dist from (1,0) to (5,5): 4+2=6
|
|
assert engine.creature_distance(a, b) == 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Threatened squares for Large
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestThreatenedSquaresLarge:
|
|
def test_large_sword_threatens_union_of_2x2_cells(self) -> None:
|
|
"""A Large creature at (2,2) with a 5ft sword has natural reach 10ft (2 cells).
|
|
Each of the 4 occupied cells radiates a disk of radius 2.
|
|
"""
|
|
giant = make_combatant("g", size="Large")
|
|
state = make_state(giant, "p", (2, 2))
|
|
engine = make_engine([state])
|
|
threatened = engine.threatened_squares(state)
|
|
# Must contain cells adjacent to any of the 4 occupied cells
|
|
# e.g. (0,2) is at distance 2 from (2,2), should be in
|
|
assert (0, 2) in threatened
|
|
# (1,2) is adjacent to (2,2) -> at distance 1 from (2,2), inside disk(2)
|
|
assert (1, 2) in threatened
|
|
# (5,5) should be too far
|
|
assert (5, 5) not in threatened
|
|
|
|
def test_large_reach_weapon_threatens_union_of_rings(self) -> None:
|
|
"""A Large at (2,2) wielding a reach weapon threatens the ring beyond its
|
|
10 ft natural reach, up to 20 ft (5-10-5 cells 3..4).
|
|
|
|
R1 conformance: CRB "Big and Little Creatures in Combat" — "Large or
|
|
larger creatures using reach weapons can strike up to double their
|
|
natural reach but can't strike at their natural reach or less." The
|
|
previous expectation (ring at exactly 2 cells, i.e. 10 ft) encoded the
|
|
old bug where a reach weapon's ring was keyed off ``reach_ft`` instead
|
|
of the wielder's natural reach, so it put the whole natural disk in the
|
|
dead zone.
|
|
"""
|
|
spear = AttackSpec(
|
|
id="spear", name="longspear", kind="melee",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
|
reach_ft=10,
|
|
reach_weapon=True,
|
|
)
|
|
combatant = Combatant(
|
|
id="g", name="g", level=1, size="Large",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=12,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=20,
|
|
ac=ACProfile(total=15, touch=15, flat_footed=15),
|
|
bab=5, initiative_mod=0, speed_land_ft=30,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[spear],
|
|
)
|
|
state = make_state(combatant, "p", (2, 2))
|
|
engine = make_engine([state])
|
|
threatened = engine.threatened_squares(state)
|
|
# Dead zone: within the 10 ft natural reach of every occupied cell.
|
|
# (1, 3) is adjacent to (2, 3); (1, 2) is at most 2 cells from each.
|
|
assert (1, 3) not in threatened
|
|
assert (1, 2) not in threatened
|
|
# Ring: 15 ft (3 cells) and 20 ft (4 cells) out are threatened.
|
|
# (5, 2) is 3 cells from the occupied cell (2, 2).
|
|
assert (5, 2) in threatened
|
|
# (6, 2) is 4 cells from (2, 2).
|
|
assert (6, 2) in threatened
|
|
# Beyond double natural reach: (8, 8) is 5+ cells from every
|
|
# occupied cell ((3, 3) -> d_row=5, d_col=5 -> 5+2=7).
|
|
assert (8, 8) not in threatened
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Movement: Large moves as footprint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _move_policy(
|
|
engine: CombatEngine, state: CombatantState,
|
|
) -> tuple[Action, ...]:
|
|
return (Action(kind="move", target_id="d"),)
|
|
|
|
|
|
class TestLargeMovement:
|
|
def test_large_can_move_and_all_cells_update(self) -> None:
|
|
"""A Large creature moves; all 4 cells of the footprint shift."""
|
|
giant = make_combatant("g", size="Large", speed=30)
|
|
g_state = make_state(giant, "p", (0, 0))
|
|
dest = make_combatant("d", hp=100)
|
|
d_state = make_state(dest, "e", (5, 5))
|
|
states = [g_state, d_state]
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 1]), make_grid(), states,
|
|
round_cap=1, policy=_move_policy,
|
|
)
|
|
engine.run()
|
|
# Giant moved from (0,0) toward (5,5)
|
|
old_cells = frozenset({(0, 0), (0, 1), (1, 0), (1, 1)})
|
|
assert g_state.pos != (0, 0) or g_state.occupied_cells() != old_cells
|
|
|
|
def test_large_cannot_pass_through_wall(self) -> None:
|
|
"""A Large creature at (0,0) cannot move right because wall at col 5
|
|
blocks some cells of its footprint."""
|
|
giant = make_combatant("g", size="Large", speed=30)
|
|
g_state = make_state(giant, "p", (0, 3))
|
|
dest = make_combatant("d", hp=100)
|
|
d_state = make_state(dest, "e", (0, 9))
|
|
grid = make_wall_grid()
|
|
states = [g_state, d_state]
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 1]), grid, states,
|
|
round_cap=1, policy=_move_policy,
|
|
)
|
|
engine.run()
|
|
# Cannot move right past column 5 (wall blocks cells (0,5), (1,5))
|
|
# Should stay near start or move in a direction that doesn't cross wall
|
|
cells = g_state.occupied_cells()
|
|
for cell in cells:
|
|
assert grid.passable(cell)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Flanking: mirror from any occupied cell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFlankingLarge:
|
|
def test_ally_on_mirror_of_occupied_cell_grants_flank(self) -> None:
|
|
"""Ally on the mirror position of one of the Large's occupied cells grants +2."""
|
|
giant = make_combatant("g", size="Large")
|
|
attacker = make_state(giant, "p", (3, 3))
|
|
target = make_combatant("t")
|
|
t_state = make_state(target, "e", (3, 5))
|
|
# Giant occupies (3,3),(3,4),(4,3),(4,4)
|
|
# Mirror of (4,4) through target (3,5): 2*3-4=2, 2*5-4=6 -> (2,6)
|
|
ally = make_combatant("a")
|
|
a_state = make_state(ally, "p", (2, 6))
|
|
engine = make_engine([attacker, t_state, a_state])
|
|
assert engine._flanking_bonus(attacker, t_state) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Charge: Large charge ends where footprint fits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _charge_policy(
|
|
engine: CombatEngine, state: CombatantState,
|
|
) -> tuple[Action, ...]:
|
|
return (Action(kind="charge", target_id="d"),)
|
|
|
|
|
|
class TestLargeCharge:
|
|
def test_large_can_charge_and_footprint_fits(self) -> None:
|
|
"""A Large creature charges and its 2x2 footprint is free at the endpoint."""
|
|
giant = make_combatant("g", size="Large", speed=30)
|
|
g_state = make_state(giant, "p", (0, 0))
|
|
dest = make_combatant("d", hp=100, speed=0)
|
|
d_state = make_state(dest, "e", (5, 5))
|
|
states = [g_state, d_state]
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 10, 20, 10, 10]), make_grid(), states,
|
|
round_cap=1, policy=_charge_policy,
|
|
)
|
|
engine.run()
|
|
# After charging, the footprint should be somewhere reachable
|
|
assert g_state.active
|
|
for cell in g_state.occupied_cells():
|
|
assert make_grid().passable(cell)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. AoO: leaving threatened footprint cell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLargeAoO:
|
|
def test_aoo_when_large_leaves_threatened_cell(self) -> None:
|
|
"""When a Large creature moves away, enemies threatening its footprint
|
|
get AoOs."""
|
|
giant = make_combatant("g", size="Large", speed=30, hp=30, ac=12)
|
|
g_state = make_state(giant, "p", (3, 3))
|
|
# enemy with a longspear (reach weapon) at (3,6) — Medium natural reach
|
|
# 5 ft, so it threatens the ring at dist 2 from (3,6)
|
|
# (3,4) is at dist 2 from (3,6), so it's in the threatened zone
|
|
enemy = make_combatant("e", reach_ft=10, reach_weapon=True, hp=100, speed=0)
|
|
e_state = make_state(enemy, "m", (3, 6))
|
|
dest = make_combatant("d", hp=100, speed=0)
|
|
d_state = make_state(dest, "e", (0, 0))
|
|
states = [g_state, e_state, d_state]
|
|
|
|
def policy(eng: CombatEngine, st: CombatantState) -> tuple[Action, ...]:
|
|
if st is g_state:
|
|
return (Action(kind="move", target_id="d"),)
|
|
return ()
|
|
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 10, 10, 15, 5] * 10), make_grid(), states,
|
|
round_cap=1, policy=policy,
|
|
)
|
|
result = engine.run()
|
|
# The transcript should show an AoO
|
|
aoo_lines = [line for line in result.transcript if "AoO" in line]
|
|
# At least one AoO should be generated (enemy threatens (3,4))
|
|
assert len(aoo_lines) >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. LoS/cover from best cell of Large
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLoSLarge:
|
|
def test_los_from_best_cell(self) -> None:
|
|
"""A Large creature behind a wall can see the target if any cell has LoE."""
|
|
grid = make_wall_grid()
|
|
# Large at (3,3) occupies (3,3)(3,4)(4,3)(4,4)
|
|
# Wall at column 5
|
|
# (3,4) and (4,4) are right next to the wall but still on left side
|
|
# Target at (3,6) is on the right side of wall
|
|
# From (3,4) corner to (3,6) corner: must cross wall at col 5
|
|
# No cell of the Large has LoE through the wall to the other side
|
|
# This verifies that large doesn't magically see through walls
|
|
|
|
giant = make_combatant("g", size="Large")
|
|
g_state = make_state(giant, "p", (3, 0))
|
|
target = make_combatant("t")
|
|
t_state = make_state(target, "e", (3, 7))
|
|
|
|
# No LoE because wall at col 5 blocks all paths
|
|
assert not has_line_of_effect(grid, g_state.pos, t_state.pos)
|
|
|
|
# But from (3,0) to (3,4) which is before the wall: LoE exists
|
|
assert has_line_of_effect(grid, (3, 0), (3, 4))
|
|
|
|
def test_cover_from_best_cell_large(self) -> None:
|
|
"""A Large creature's cover check uses the attacker's cells."""
|
|
grid = make_grid()
|
|
# Large at (3,3), target at (3,5), with a blocking creature at (3,4)
|
|
occupied = frozenset({(3, 4)})
|
|
# From any cell of the Large, the creature at (3,4) blocks
|
|
# The best attacker corner might still find a path around
|
|
# This is a basic smoke test that cover still works with occupied sets
|
|
result = has_cover(
|
|
grid, (3, 3), (3, 5), ranged=True, occupied=occupied,
|
|
)
|
|
# With a creature directly between, should get cover
|
|
assert result is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Equivalence: Medium is 1x1 unchanged
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMediumEquivalence:
|
|
def test_medium_occupied_cells_is_pos(self) -> None:
|
|
c = make_combatant("m", size="Medium")
|
|
s = make_state(c, "a", (5, 5))
|
|
assert s.occupied_cells() == frozenset({(5, 5)})
|
|
|
|
def test_medium_distance_matches_grid(self) -> None:
|
|
a = make_state(make_combatant("a"), "p", (0, 0))
|
|
b = make_state(make_combatant("b"), "e", (3, 4))
|
|
engine = make_engine([a, b])
|
|
assert engine.creature_distance(a, b) == engine._grid.distance(a.pos, b.pos)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. Summon spawn: Large summoned creature finds free footprint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSummonLarge:
|
|
def test_summon_large_needs_free_footprint(self) -> None:
|
|
"""A Large summoned creature needs a 2x2 free area near the caster."""
|
|
# This is a structural test - we verify the spawn logic exists
|
|
# by checking the summon function handles footprint
|
|
giant = make_combatant("g", size="Large")
|
|
g_state = make_state(giant, "p", (5, 5))
|
|
make_engine([g_state])
|
|
# Verify SIZE_CELLS is accessible
|
|
assert SIZE_CELLS.get("Large") == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. Bull rush: Large target pushed with footprint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBullRushLarge:
|
|
def test_bull_rush_pushes_footprint(self) -> None:
|
|
"""Bull rush on a Large target moves the anchor and all cells follow."""
|
|
attacker = make_combatant("a", speed=0)
|
|
a_state = make_state(attacker, "p", (3, 1))
|
|
target = make_combatant("t", size="Large", speed=0)
|
|
t_state = make_state(target, "e", (3, 3))
|
|
engine = make_engine([a_state, t_state])
|
|
# Push direction: away from attacker (3,1) -> (3,3) means push right
|
|
result = engine._bull_rush_push(a_state, t_state)
|
|
if result:
|
|
# Target moved 1 cell right
|
|
assert t_state.pos == (3, 4)
|
|
assert t_state.occupied_cells() == frozenset({
|
|
(3, 4), (3, 5), (4, 4), (4, 5),
|
|
})
|