Files
pf1e-simulator/tests/test_size_is_baked_in.py
T

189 lines
7.6 KiB
Python

"""Regression tests: size is BAKED INTO creature attack/AC data, not applied by the engine.
The PF1e size modifier to attack rolls and AC (Small +1, Medium +0, Large -1,
Tiny +2, ...) is already included in the data-authored ``ac.total/touch/
flat_footed`` and ``attack_bonus`` values (verified against the source sheets
in ``fiches_personnages/`` and the Bestiary entries). ``resolve_attack`` must
therefore NEVER add a size modifier on top — doing so would double-count and
regress the pinned transcripts and goldens.
The engine's ``_SIZE_MODIFIERS`` table (Fine -8 ... Colossal +8) is the
COMBAT-MANEUVER size table (CMB/CMD), an entirely different column of the PF1e
size table, and stays exactly as-is.
Covers:
- (a) a Small attacker with explicit ``attack_bonus`` resolves at
``d20 + attack_bonus`` — no silent extra +1 size bonus.
- (b) a Small target with explicit ``ac.total`` is resolved against that value
— no silent extra +1 size AC.
- (c) the CMB/CMD path still applies the combat-maneuver size table via
``_size_mod`` / ``_maneuver_cmb`` / ``_maneuver_cmd``.
- (d) the shipped goblin data itself proves the bake-in (arithmetic pinned).
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
_SIZE_MODIFIERS,
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
_DATA = Path(__file__).resolve().parent.parent / "data"
def make_combatant(
cid: str,
*,
size: str = "Medium",
ac: int = 13,
attack_bonus: int = 2,
str_score: int = 10,
dex_score: int = 10,
bab: int = 1,
) -> Combatant:
"""Single-melee-weapon combatant with data-authored FINAL ac/attack_bonus.
Mirrors ``tests/test_combat.py::make_combatant`` but exposes ``size`` so
the tests can pin Small/Large/Tiny statlines whose numbers already bake
the attack/AC size modifier.
"""
attack = AttackSpec(
id=f"{cid}-w",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=str_score,
dex_score=dex_score,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=6,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
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_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return CombatEngine(ScriptedRng(queue), Grid.from_spec(spec), states)
def test_small_attacker_gets_no_extra_size_bonus_on_attack() -> None:
"""(a) Small attacker, data attack_bonus=2 (goblin-like: BAB 1 + Str 0 +
size +1 already baked). With roll 10, adjacent, no feats/conditions/cover:
total must be exactly 12 — NOT 13 (a +1 size bonus re-applied)."""
attacker = make_state(
make_combatant("gob", size="Small", attack_bonus=2), "players", (1, 1)
)
target = make_state(make_combatant("orc", ac=20), "monsters", (1, 2))
engine = make_engine([10], [attacker, target])
result = engine.resolve_attack(attacker, target, attacker.combatant.attacks[0])
assert result.roll == 10
assert result.base_bonus == 2
assert result.total == 12
assert result.hit is False # 12 < 20 — and would still miss even at 13
def test_small_target_ac_is_used_verbatim() -> None:
"""(b) Small target, data ac.total=16 (goblin: 10 + 1 size + 2 Dex + 3
armour already baked). resolve_attack must compare against exactly 16 —
NOT 17 (a +1 size AC re-applied)."""
attacker = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
target = make_state(make_combatant("gob", size="Small", ac=16), "monsters", (1, 2))
engine = make_engine([14, 1], [attacker, target])
result = engine.resolve_attack(attacker, target, attacker.combatant.attacks[0])
assert result.ac == 16
assert result.total == 16 # roll 14 + attack_bonus 2
assert result.hit is True # hits at exactly 16, not vs a 17-inflated AC
# Fresh states (the first hit damaged the target): a total of 15 must MISS
# 16 — pinning AC to exactly 16 (not 15, not 17).
attacker2 = make_state(make_combatant("a2", attack_bonus=2), "players", (1, 1))
target2 = make_state(make_combatant("gob2", size="Small", ac=16), "monsters", (1, 2))
engine2 = make_engine([13], [attacker2, target2])
miss = engine2.resolve_attack(attacker2, target2, attacker2.combatant.attacks[0])
assert miss.ac == 16
assert miss.total == 15
assert miss.hit is False
def test_combat_maneuver_size_table_still_applied() -> None:
"""(c) _SIZE_MODIFIERS is the CMB/CMD table (opposite sign to the
attack/AC table) and _size_mod / _maneuver_cmb / _maneuver_cmd still use
it: Small -1, Medium 0, Large +1, Tiny -2, Colossal +8, Fine -8."""
assert _SIZE_MODIFIERS["small"] == -1
assert _SIZE_MODIFIERS["medium"] == 0
assert _SIZE_MODIFIERS["large"] == 1
assert _SIZE_MODIFIERS["tiny"] == -2
assert _SIZE_MODIFIERS["colossal"] == 8
assert _SIZE_MODIFIERS["fine"] == -8
engine = make_engine([], [])
small = make_state(make_combatant("s", size="Small", bab=1, str_score=10), "p", (0, 0))
large = make_state(make_combatant("l", size="Large", bab=1, str_score=10), "m", (0, 1))
colossal = make_state(
make_combatant("c", size="Colossal", bab=1, str_score=10), "m", (0, 2)
)
assert engine._size_mod(small) == -1
assert engine._size_mod(large) == 1
assert engine._size_mod(colossal) == 8
# CMB = BAB + Str mod + size mod (no explicit cmb on the model):
# bab 1 + str 0 + size.
assert engine._maneuver_cmb(small) == 0 # 1 + 0 - 1
assert engine._maneuver_cmb(large) == 2 # 1 + 0 + 1
assert engine._maneuver_cmb(colossal) == 9 # 1 + 0 + 8
# CMD = 10 + CMB + Dex mod (dex 0 here): the size modifier propagates.
assert engine._maneuver_cmd(small) == 10 # 10 + 0 + 0
assert engine._maneuver_cmd(large) == 12 # 10 + 2 + 0
assert engine._maneuver_cmd(colossal) == 19 # 10 + 9 + 0
def test_goblin_json_size_is_baked_in() -> None:
"""(d) The shipped Small goblin proves the bake-in arithmetically:
short sword attack_bonus 2 = BAB 1 + Str 0 + size +1;
touch AC 13 = 10 + Dex 2 + size +1. If the engine added size on top,
these data values would be wrong by +1."""
goblin = load_combatant(_DATA / "monsters" / "goblin.json")
assert goblin.size == "Small"
assert goblin.bab == 1
assert goblin.abilities.str_mod == 0
assert goblin.abilities.dex_mod == 2
sword = next(a for a in goblin.attacks if a.name == "short sword")
assert sword.attack_bonus == goblin.bab + goblin.abilities.str_mod + 1 # +1 = Small
assert goblin.ac.touch == 10 + goblin.abilities.dex_mod + 1 # +1 = Small
assert goblin.ac.total == 16 # 10 + 1 size + 2 Dex + 3 armour (subtle/leather)