diff --git a/README.md b/README.md index 282aa4a..ac9cb5e 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,16 @@ Règles modélisées : manœuvres et invocation sont généralisés aux cellules occupées (distance = minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1 reste bit-identique. +- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et + `attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées + du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1, + Tiny +2…) — vérifié sur toutes les fiches (`data/monsters/*.json`, + `data/pcs/*.json`, verrouillé par `tests/test_size_is_baked_in.py`). Le + moteur n'applique donc JAMAIS de bonus de taille aux jets d'attaque ni à la + CA (ce serait un double comptage) ; la taille n'intervient dans la + résolution que pour les manœuvres de combat (CMB/CMD, table + `_SIZE_MODIFIERS` Small −1 … Colossal +8 — colonne distincte de la table + attaques/CA), l'allonge naturelle, le footprint et l'occupation. - Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par niveau (la descente est gratuite) ; une créature située plus haut que sa cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de diff --git a/tests/test_size_is_baked_in.py b/tests/test_size_is_baked_in.py new file mode 100644 index 0000000..78ba489 --- /dev/null +++ b/tests/test_size_is_baked_in.py @@ -0,0 +1,188 @@ +"""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)