test(rules): lock in that size is baked into attack/AC values
This commit is contained in:
@@ -436,6 +436,16 @@ Règles modélisées :
|
|||||||
manœuvres et invocation sont généralisés aux cellules occupées (distance =
|
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
|
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
|
||||||
reste bit-identique.
|
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
|
- É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
|
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
|
cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user