"""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)