439 lines
15 KiB
Python
439 lines
15 KiB
Python
"""P1 PF1e conformance fixes (Oracle-adjudicated audit).
|
|
|
|
Six genuine RAW deviations, one section per bug:
|
|
|
|
B1 Area saves: every creature in an area rolls the save the SPELL lists
|
|
(Will / Fortitude / Reflex); "save negates" areas save, and secondary
|
|
creatures are not hardcoded to Reflex.
|
|
B2 Concentration: d20 + caster level + the casting ability modifier
|
|
(Int/Wis/Cha), never Constitution; Combat Casting's +4 only applies
|
|
when casting defensively or while grappled.
|
|
B3 Prone: the prone attacker takes -4 on melee attacks (ranged treated as
|
|
crossbow-exempt at -4); the prone defender takes -4 AC vs melee and
|
|
gains +4 AC vs ranged.
|
|
B4 Spell slots: casting without an available slot is refused; cantrips
|
|
(level 0) cast freely.
|
|
B5 Helpless: paralyzed / asleep / unconscious (and helpless) are helpless —
|
|
melee attackers gain +4 and the target has effective Dexterity 0 (-5);
|
|
stunned is NOT helpless.
|
|
B6 Nauseated: only a single move action per turn — no standard actions, no
|
|
attacks, no spellcasting, no concentration.
|
|
|
|
B2 is covered in tests/test_concentration.py; B1's per-spell cases in
|
|
tests/test_spells.py (TestAreaSavesPerSpellSaveType). This file adds the
|
|
cross-cutting conformance locks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
|
|
from pf1e_simulator.combat import (
|
|
Action,
|
|
ActionResult,
|
|
CombatantState,
|
|
CombatEngine,
|
|
_nauseated_filter,
|
|
)
|
|
from pf1e_simulator.conditions import (
|
|
ASLEEP,
|
|
CONDITIONS_BY_NAME,
|
|
PARALYZED,
|
|
Condition,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.effects import ActiveCondition
|
|
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
|
|
from pf1e_simulator.spells import (
|
|
AreaEffect,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
SpellSpec,
|
|
)
|
|
|
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
|
|
def _melee(cid: str) -> AttackSpec:
|
|
return AttackSpec(
|
|
id=f"{cid}-sword",
|
|
name="short sword",
|
|
kind="melee",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
|
)
|
|
|
|
|
|
def _bow(cid: str) -> AttackSpec:
|
|
return AttackSpec(
|
|
id=f"{cid}-bow",
|
|
name="short bow",
|
|
kind="ranged",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
|
range_increment_ft=60,
|
|
)
|
|
|
|
|
|
def _combatant(
|
|
cid: str,
|
|
*,
|
|
level: int = 1,
|
|
hp: int = 100,
|
|
ac: int = 15,
|
|
dex: int = 10,
|
|
attacks: tuple[AttackSpec, ...] = (),
|
|
spells: tuple[str, ...] = (),
|
|
spell_slots: dict[int, int] | None = None,
|
|
) -> Combatant:
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=level,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=dex, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=hp,
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=1,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=list(attacks) or [_melee(cid)],
|
|
spells=list(spells),
|
|
spell_slots=spell_slots or {},
|
|
)
|
|
|
|
|
|
def _engine(
|
|
combatants: list[Combatant],
|
|
positions: list[tuple[int, int]],
|
|
rng: list[int],
|
|
spell_registry: dict[str, SpellSpec] | None = None,
|
|
policy: PolicyFn | None = None,
|
|
) -> CombatEngine:
|
|
sides = ["players"] + ["monsters"] * (len(combatants) - 1)
|
|
states = [
|
|
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
|
|
for c, side, pos in zip(combatants, sides, positions, strict=True)
|
|
]
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
return CombatEngine(
|
|
ScriptedRng(rng), grid, states,
|
|
spell_registry=spell_registry or {}, policy=policy,
|
|
)
|
|
|
|
|
|
def _add_condition(state: CombatantState, cond: Condition) -> None:
|
|
state.active_conditions.append(
|
|
ActiveCondition(condition=cond, rounds_left=5, source="test")
|
|
)
|
|
|
|
|
|
# ── B1: point-cast area — every creature rolls the spell's save type ─────────
|
|
|
|
|
|
_SLUMBER_CLOUD = SpellSpec(
|
|
name="Slumber Cloud",
|
|
level=1,
|
|
school="enchantment",
|
|
range="close",
|
|
save="will",
|
|
effects=(
|
|
AreaEffect(
|
|
radius_ft=20,
|
|
effects=(ConditionEffect("asleep", 10, save_negates=True),),
|
|
save_for_half=False,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def test_point_cast_area_rolls_the_spells_save_per_creature() -> None:
|
|
# No primary target: each creature in the burst rolls its own Will save
|
|
# (the old code short-circuited save_for_half=False to "no save at all").
|
|
eng = _engine(
|
|
[
|
|
_combatant("caster", spell_slots={1: 1}),
|
|
_combatant("sleeper"),
|
|
_combatant("waker"),
|
|
],
|
|
[(0, 7), (0, 2), (0, 3)],
|
|
[1, 20],
|
|
spell_registry={"Slumber Cloud": _SLUMBER_CLOUD},
|
|
)
|
|
caster, sleeper, waker = eng._states
|
|
result = eng._execute(
|
|
caster,
|
|
Action(kind="cast_spell", spell_name="Slumber Cloud", target_pos=(0, 2)),
|
|
)
|
|
assert result.ok
|
|
# Caster is outside the 4-cell burst; sleeper fails its Will (d20 1),
|
|
# waker saves on natural 20 — the negate semantics now reach areas.
|
|
assert not any(ac.condition.name == "asleep" for ac in caster.active_conditions)
|
|
assert any(ac.condition.name == "asleep" for ac in sleeper.active_conditions)
|
|
assert not any(ac.condition.name == "asleep" for ac in waker.active_conditions)
|
|
|
|
|
|
# ── B3: prone rules by weapon kind ───────────────────────────────────────────
|
|
|
|
|
|
def test_prone_attacker_minus4_on_melee_attack() -> None:
|
|
eng = _engine([_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4])
|
|
a, d = eng._states
|
|
_add_condition(a, CONDITIONS_BY_NAME["prone"])
|
|
result = eng.resolve_attack(a, d, a.combatant.attacks[0])
|
|
# roll 10 + bonus 5 - 4 (prone melee) = 11.
|
|
assert result.total == 11
|
|
|
|
|
|
def test_prone_defender_minus4_ac_vs_melee_plus4_vs_ranged() -> None:
|
|
melee_eng = _engine(
|
|
[_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4]
|
|
)
|
|
_add_condition(melee_eng._states[1], CONDITIONS_BY_NAME["prone"])
|
|
melee = melee_eng.resolve_attack(
|
|
melee_eng._states[0], melee_eng._states[1], _melee("atk")
|
|
)
|
|
assert melee.ac == 11 # 15 - 4 vs melee
|
|
|
|
ranged_eng = _engine(
|
|
[_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")],
|
|
[(0, 0), (0, 3)],
|
|
[10, 4],
|
|
)
|
|
_add_condition(ranged_eng._states[1], CONDITIONS_BY_NAME["prone"])
|
|
ranged = ranged_eng.resolve_attack(
|
|
ranged_eng._states[0], ranged_eng._states[1], _bow("atk")
|
|
)
|
|
assert ranged.ac == 19 # 15 + 4 vs ranged
|
|
|
|
|
|
def test_prone_ranged_attacker_minus4_crossbow_exemption_documented() -> None:
|
|
# The model cannot tell a crossbow from a bow: every ranged weapon is
|
|
# treated as crossbow-exempt — usable, but at -4 while prone.
|
|
eng = _engine(
|
|
[_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")],
|
|
[(0, 0), (0, 3)],
|
|
[15, 4],
|
|
)
|
|
_add_condition(eng._states[0], CONDITIONS_BY_NAME["prone"])
|
|
result = eng.resolve_attack(
|
|
eng._states[0], eng._states[1], _bow("atk")
|
|
)
|
|
assert result.total == 16 # 15 + 5 - 4
|
|
|
|
|
|
# ── B4: spell slots enforced ─────────────────────────────────────────────────
|
|
|
|
|
|
_ZAP = SpellSpec(
|
|
name="Zap",
|
|
level=1,
|
|
school="evocation",
|
|
range="close",
|
|
save="none",
|
|
effects=(DamageEffect(formula=parse_dice("1d4"), types=("electricity",)),),
|
|
)
|
|
|
|
_SPARK = SpellSpec(
|
|
name="Spark",
|
|
level=0,
|
|
school="evocation",
|
|
range="close",
|
|
save="none",
|
|
effects=(DamageEffect(formula=parse_dice("1d3"), types=("electricity",)),),
|
|
)
|
|
|
|
|
|
def test_cast_without_a_slot_is_refused() -> None:
|
|
eng = _engine(
|
|
[_combatant("caster", spell_slots={1: 1}), _combatant("tgt")],
|
|
[(0, 0), (0, 2)],
|
|
[3, 3],
|
|
spell_registry={"Zap": _ZAP},
|
|
)
|
|
caster, tgt = eng._states
|
|
eng._cast_spell(caster, tgt, "Zap")
|
|
assert tgt.hp == 97 # first cast spent the only slot (1d4 → 3)
|
|
eng._cast_spell(caster, tgt, "Zap")
|
|
assert tgt.hp == 97 # second cast refused before any roll
|
|
assert any("NO SPELL SLOT" in line for line in eng._transcript)
|
|
assert caster.spell_slots_left[1] == 0
|
|
|
|
|
|
def test_cantrips_cast_freely_without_slots() -> None:
|
|
eng = _engine(
|
|
[_combatant("caster"), _combatant("tgt")],
|
|
[(0, 0), (0, 2)],
|
|
[3, 3, 3],
|
|
spell_registry={"Spark": _SPARK},
|
|
)
|
|
caster, tgt = eng._states
|
|
for _ in range(3):
|
|
eng._cast_spell(caster, tgt, "Spark")
|
|
assert tgt.hp == 100 - 9 # three casts of 1d3 → 3 each
|
|
assert not any("NO SPELL SLOT" in line for line in eng._transcript)
|
|
|
|
|
|
# ── B5: helpless extended to paralyzed / asleep / unconscious ────────────────
|
|
|
|
|
|
def _helpless_cases() -> list[Condition]:
|
|
return [
|
|
CONDITIONS_BY_NAME["helpless"],
|
|
PARALYZED,
|
|
ASLEEP,
|
|
Condition(name="unconscious", denies_dex_to_ac=True, cant_act=True),
|
|
]
|
|
|
|
|
|
def test_melee_vs_helpless_states_gains_plus4_and_dex_zero() -> None:
|
|
for cond in _helpless_cases():
|
|
eng = _engine(
|
|
[_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4]
|
|
)
|
|
_add_condition(eng._states[1], cond)
|
|
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
|
|
# roll 10 + 5 + 4 (helpless melee) = 19; AC 16 (flat-footed, Dex
|
|
# denied) - 5 (effective Dex 0) = 11.
|
|
assert result.total == 19, cond.name
|
|
assert result.ac == 11, cond.name
|
|
|
|
|
|
def test_stunned_is_not_helpless() -> None:
|
|
eng = _engine(
|
|
[_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4]
|
|
)
|
|
_add_condition(eng._states[1], CONDITIONS_BY_NAME["stunned"])
|
|
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
|
|
# No +4 to hit (stunned is not helpless); AC = flat-footed 16 - 2 (the
|
|
# stunned condition's own AC penalty), no Dex-0 -5.
|
|
assert result.total == 15
|
|
assert result.ac == 14
|
|
|
|
|
|
def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None:
|
|
# A Dex 8 (-1) helpless target: the -1 penalty gives way to the Dex-0 -5
|
|
# (adjustment -5 - (-1) = -4 on top of the flat-footed base).
|
|
eng = _engine(
|
|
[_combatant("atk"), _combatant("def", ac=14, dex=8)],
|
|
[(0, 0), (0, 1)],
|
|
[10, 4],
|
|
)
|
|
_add_condition(eng._states[1], PARALYZED)
|
|
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
|
|
assert result.ac == 10 # 14 - 4
|
|
|
|
|
|
# ── B6: nauseated — single move action only, no casting ──────────────────────
|
|
|
|
|
|
def test_nauseated_cannot_take_a_standard_attack() -> None:
|
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
|
if s.combatant.id == "hero":
|
|
return (Action(kind="attack", target_id="foe"),)
|
|
return (Action(kind="wait"),)
|
|
|
|
eng = _engine(
|
|
[_combatant("hero"), _combatant("foe")], [(0, 0), (0, 1)], [10, 5],
|
|
policy=policy,
|
|
)
|
|
hero, _foe = eng._states
|
|
_add_condition(hero, CONDITIONS_BY_NAME["nauseated"])
|
|
eng._roll_initiative()
|
|
eng._take_turn(hero)
|
|
assert not any(
|
|
" vs foe" in line and "short sword" in line for line in eng._transcript
|
|
)
|
|
assert hero.last_action_results == [
|
|
ActionResult(kind="attack", ok=False, reason="downgraded:nauseated")
|
|
]
|
|
|
|
|
|
def test_nauseated_keeps_only_the_first_move_action() -> None:
|
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
|
if s.combatant.id == "hero":
|
|
return (
|
|
Action(kind="move", target_id="foe"),
|
|
Action(kind="attack", target_id="foe"),
|
|
)
|
|
return (Action(kind="wait"),)
|
|
|
|
eng = _engine(
|
|
[_combatant("hero"), _combatant("foe")], [(0, 0), (0, 3)], [10, 5],
|
|
policy=policy,
|
|
)
|
|
hero, _foe = eng._states
|
|
_add_condition(hero, CONDITIONS_BY_NAME["nauseated"])
|
|
eng._roll_initiative()
|
|
eng._take_turn(hero)
|
|
assert any("hero: move" in line for line in eng._transcript)
|
|
assert hero.last_action_results == [
|
|
ActionResult(kind="attack", ok=False, reason="downgraded:nauseated"),
|
|
ActionResult(kind="move", ok=True, reason="ok"),
|
|
]
|
|
|
|
|
|
def test_nauseated_cannot_cast_via_action_execution() -> None:
|
|
# Defense in depth: even a cast that bypasses the action filter (a ready
|
|
# payload, say) is refused while nauseated.
|
|
eng = _engine(
|
|
[_combatant("caster", spell_slots={1: 9}), _combatant("tgt")],
|
|
[(0, 0), (0, 2)],
|
|
[],
|
|
spell_registry={"Zap": _ZAP},
|
|
)
|
|
caster, tgt = eng._states
|
|
_add_condition(caster, CONDITIONS_BY_NAME["nauseated"])
|
|
eng._execute_cast_spell(caster, tgt, "Zap")
|
|
assert tgt.hp == 100
|
|
assert caster.spell_slots_left[1] == 9
|
|
assert any(
|
|
"cast_spell rejected (cannot attack)" in line for line in eng._transcript
|
|
)
|
|
|
|
|
|
def test_nauseated_filter_keeps_move_kinds_drops_everything_else() -> None:
|
|
for kind in ("move", "5foot_step", "stand_up", "drop_prone"):
|
|
kept = _nauseated_filter((Action(kind=kind, target_id="foe"),))
|
|
assert len(kept) == 1, kind
|
|
assert kept[0].kind == kind, kind
|
|
for kind in ("attack", "cast_spell", "full_attack", "charge", "maneuver", "hex"):
|
|
assert _nauseated_filter((Action(kind=kind, target_id="foe"),)) == (), kind
|
|
|
|
|
|
# ── B3/B5 engine-side locks (unit level) ────────────────────────────────────
|
|
|
|
|
|
def test_stat_modifiers_no_longer_flatten_prone_or_paralyzed_ac() -> None:
|
|
# The unconditional -4 AC appends for prone/paralyzed are gone from
|
|
# _stat_modifiers: the adjustments are weapon-kind dependent and live in
|
|
# resolve_attack. A prone + paralyzed defender's collected "ac" mods
|
|
# carry no -4.
|
|
eng = _engine([_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], [])
|
|
_add_condition(eng._states[1], CONDITIONS_BY_NAME["prone"])
|
|
_add_condition(eng._states[1], PARALYZED)
|
|
mods = eng._stat_modifiers(eng._states[1], "ac")
|
|
assert all(m.value != -4 for m in mods)
|
|
|
|
|
|
def test_asleep_condition_no_longer_carries_a_flat_ac_penalty() -> None:
|
|
# The ASLEEP -4 AC approximation is superseded by the engine's Dex-0
|
|
# adjustment for helpless targets.
|
|
assert ASLEEP.modifiers == ()
|