2cc2ba1907
The greedy move/run/flee/charge descent helpers hardcoded diagonal parity 0, letting a speed-30 walker cover 6 diagonal cells (cell-count budget). Under 5-10-5 (SRD Movement, Tactical) the same walker covers only 4 cells (1+2+1+2=6 squares). Threading the running diagonal count: - _best_move_step / _best_flee_step: accept diagonals_used param - _descend_path / _run_line_path / _flee_path: track and pass parity - _charge_end: use _path_cost_5105() instead of len(path) for max check - _path_cost_5105: new helper; computes total 5-10-5 cost of any path Updated golden paths in test_aoo_limit_one_per_round (5-cell path now takes an orthogonal detour to avoid expensive 2nd diagonal), test_run_* (speed-30 diagonal move stops at 4 cells), and staggered_withdraw (single-speed diagonal withdraw stops at 4 cells).
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Tests for mechanical condition effects (prone, staggered, nauseated, paralyzed)
|
|
and StatModifier.condition filtering.
|
|
|
|
Part (a): StatModifier with condition= is only included when the condition holds.
|
|
Part (b): Condition behavioral effects enforced via action economy / AC modifiers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
|
from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.effects import ActiveCondition, StatModifier
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
|
|
|
|
def _make_hero(cid: str = "hero", bab: int = 5) -> Combatant:
|
|
melee = AttackSpec(
|
|
id=f"{cid}-sword",
|
|
name="sword",
|
|
kind="melee",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
|
|
damage_bonus=2,
|
|
)
|
|
ranged = AttackSpec(
|
|
id=f"{cid}-bow",
|
|
name="bow",
|
|
kind="ranged",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
|
|
damage_bonus=2,
|
|
range_increment_ft=60,
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=14, dex_score=12, con_score=12,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=20,
|
|
ac=ACProfile(total=18, touch=12, flat_footed=16),
|
|
bab=bab,
|
|
initiative_mod=2,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=5, ref=4, will=3),
|
|
attacks=[melee, ranged],
|
|
)
|
|
|
|
|
|
def _make_foe(cid: str = "foe") -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="club",
|
|
kind="melee",
|
|
attack_bonus=3,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=12, dex_score=10, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=15,
|
|
ac=ACProfile(total=15, touch=10, flat_footed=13),
|
|
bab=3,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=3, ref=2, will=1),
|
|
attacks=[attack],
|
|
)
|
|
|
|
|
|
def _make_engine(
|
|
hero: Combatant, foe: Combatant, queue: list[int]
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
|
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
|
|
f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
engine = CombatEngine(ScriptedRng(queue), grid, [h, f])
|
|
return engine, h, f
|
|
|
|
|
|
def _make_engine_3(
|
|
c1: Combatant, c2: Combatant, c3: Combatant, queue: list[int]
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
|
|
s1 = CombatantState(combatant=c1, side="players", pos=(0, 0), hp=c1.hp_max)
|
|
s2 = CombatantState(combatant=c2, side="monsters", pos=(0, 1), hp=c2.hp_max)
|
|
s3 = CombatantState(combatant=c3, side="monsters", pos=(0, 2), hp=c3.hp_max)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
engine = CombatEngine(ScriptedRng(queue), grid, [s1, s2, s3])
|
|
return engine, s1, s2, s3
|
|
|
|
|
|
# ── Test 1: prone → -4 AC vs melee attacks ───────────────────────────────────
|
|
|
|
|
|
class TestProneACMelee:
|
|
"""Given: a prone target
|
|
When: a melee attacker resolves an attack
|
|
Then: the target's AC is reduced by 4 (prone penalty)."""
|
|
|
|
def test_prone_target_minus4_ac_vs_melee(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 4])
|
|
f.active_conditions.append(
|
|
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
|
|
)
|
|
result = engine.resolve_attack(h, f, hero.attacks[0])
|
|
# foe total AC 15, prone penalty -4 → effective AC 11
|
|
assert result.ac == 11
|
|
|
|
|
|
# ── Test 2: prone ranged attacker → -4 on ranged attack ───────────────────────
|
|
|
|
|
|
class TestProneRangedAttack:
|
|
"""Given: an attacker who is prone
|
|
When: they make a ranged attack
|
|
Then: their attack total is reduced by 4."""
|
|
|
|
def test_prone_attacker_minus4_ranged_attack(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [15, 4])
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
|
|
)
|
|
ranged_weapon = hero.attacks[1] # bow
|
|
result = engine.resolve_attack(h, f, ranged_weapon)
|
|
# base_bonus=5, roll=15, no flank (ranged), no range penalty (same cell), prone -4
|
|
# total = 15 + 5 - 4 = 16
|
|
assert result.total == 16
|
|
|
|
|
|
# ── Test 3: staggered → reduced action economy ────────────────────────────────
|
|
|
|
|
|
class TestStaggeredActionEconomy:
|
|
"""Given: a staggered combatant
|
|
When: _take_turn is called
|
|
Then: they can only do one move OR one standard action (no full attack)."""
|
|
|
|
def test_staggered_full_attack_becomes_single_attack(self) -> None:
|
|
hero = _make_hero(bab=6)
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 10, 10, 4])
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
|
|
)
|
|
engine._roll_initiative()
|
|
engine._take_turn(h)
|
|
# Staggered: no full-round. A leading full_attack (adjacent, BAB 6 →
|
|
# 2 swings) becomes a single standard-action attack → exactly 1 swing.
|
|
swings = [e for e in engine._transcript if " vs foe " in e and "sword" in e]
|
|
assert len(swings) == 1
|
|
|
|
def test_staggered_can_still_act(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
|
|
)
|
|
engine._roll_initiative()
|
|
result = engine._take_turn(h)
|
|
assert result is False
|
|
|
|
def test_staggered_restricted_withdraw_moves_single_speed(self) -> None:
|
|
# SRD "Full-Round Actions — Restricted Withdraw": "If you are limited
|
|
# to taking only a standard action each round you can withdraw as a
|
|
# standard action. In this case, you may move up to your speed" — 30
|
|
# ft = 6 cells, not the 2x full-round budget.
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
grid = Grid.from_spec(
|
|
MapSpec(name="big", terrain=tuple(["." * 20] * 20), legend=legend)
|
|
)
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
h = CombatantState(combatant=hero, side="players", pos=(10, 10), hp=hero.hp_max)
|
|
f = CombatantState(combatant=foe, side="monsters", pos=(10, 9), hp=foe.hp_max)
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
|
|
)
|
|
engine = CombatEngine(
|
|
ScriptedRng([10, 9]),
|
|
grid,
|
|
[h, f],
|
|
round_cap=1,
|
|
policy=lambda _e, s: (
|
|
(Action(kind="withdraw", target_id="foe"),)
|
|
if s.combatant.id == hero.id
|
|
else (Action(kind="wait"),)
|
|
),
|
|
)
|
|
engine.run()
|
|
withdraw_lines = [
|
|
line for line in engine._transcript if "withdraw" in line
|
|
]
|
|
assert len(withdraw_lines) == 1
|
|
# 4 diagonal steps from (10,10) — the single-speed budget of 6 squares
|
|
# under 5-10-5 (1+2+1+2=6); the old cell-count budget let it walk 6.
|
|
assert withdraw_lines[0].count("->") == 4
|
|
assert h.pos != (10, 10)
|
|
|
|
|
|
# ── Test 4: nauseated → single move action only ───────────────────────────────
|
|
|
|
|
|
class TestNauseatedActionEconomy:
|
|
"""Given: a nauseated combatant
|
|
When: _take_turn is called
|
|
Then: they can only take a single move action (no standard, no attack,
|
|
no spellcasting — CRB nauseated)."""
|
|
|
|
def test_nauseated_no_attack_and_no_full_round(self) -> None:
|
|
hero = _make_hero(bab=6)
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=NAUSEATED, rounds_left=5, source="test")
|
|
)
|
|
engine._roll_initiative()
|
|
engine._take_turn(h)
|
|
for entry in engine._transcript:
|
|
assert "sword vs foe" not in entry, (
|
|
f"nauseated combatant should not attack: {entry}"
|
|
)
|
|
|
|
|
|
# ── Test 5: paralyzed → no actions + effective DEX 0 (-4 AC) ─────────────────
|
|
|
|
|
|
class TestParalyzedNoActions:
|
|
"""Given: a paralyzed combatant
|
|
When: _take_turn is called
|
|
Then: they take no actions and are effectively helpless."""
|
|
|
|
def test_paralyzed_no_action(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
|
h.active_conditions.append(
|
|
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
|
|
)
|
|
engine._roll_initiative()
|
|
engine._take_turn(h)
|
|
# Paralyzed: cant_act=True → should only log "wait (cannot act)"
|
|
found_wait = False
|
|
for entry in engine._transcript:
|
|
if "cannot act" in entry:
|
|
found_wait = True
|
|
# Should not have executed any attack
|
|
assert "attack" not in entry.lower() or "cannot" in entry.lower(), (
|
|
f"paralyzed combatant should not attack: {entry}"
|
|
)
|
|
assert found_wait, "paralyzed combatant should log 'wait (cannot act)'"
|
|
|
|
def test_paralyzed_defender_helpless_ac(self) -> None:
|
|
"""Paralyzed is helpless: denies_dex_to_ac (flat-footed) + Dex 0 (-5)."""
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 4])
|
|
f.active_conditions.append(
|
|
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
|
|
)
|
|
result = engine.resolve_attack(h, f, hero.attacks[0])
|
|
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; helpless Dex 0 → -5;
|
|
# melee attacker also gains the +4 helpless bonus (roll 10 + 5 + 4 = 19).
|
|
assert result.ac == 8
|
|
assert result.total == 19
|
|
|
|
|
|
# ── Test 6: StatModifier condition filtering ──────────────────────────────────
|
|
|
|
|
|
class TestStatModifierConditionFiltering:
|
|
"""Given: a StatModifier with condition= set
|
|
When: the condition does not hold → modifier excluded
|
|
When: the condition holds → modifier included."""
|
|
|
|
def test_modifier_excluded_when_condition_not_held(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 4])
|
|
# Add a modifier with condition="mounted" that doesn't hold
|
|
h.effects.append(
|
|
StatModifier(target="attack", value=5, condition="mounted")
|
|
)
|
|
result = engine.resolve_attack(h, f, hero.attacks[0])
|
|
# The +5 should NOT apply because "mounted" condition doesn't hold
|
|
# base_bonus=5, roll=10, no modifiers from condition
|
|
assert result.total == 10 + 5 # 15
|
|
|
|
def test_modifier_included_when_condition_holds(self) -> None:
|
|
hero = _make_hero()
|
|
foe = _make_foe()
|
|
engine, h, f = _make_engine(hero, foe, [10, 4])
|
|
# Add a modifier with no condition (always active)
|
|
h.effects.append(
|
|
StatModifier(target="attack", value=3)
|
|
)
|
|
result = engine.resolve_attack(h, f, hero.attacks[0])
|
|
# base_bonus=5, roll=10, +3 from always-active modifier
|
|
assert result.total == 10 + 5 + 3 # 18
|