feat(combat): add concealment miss chance, sneak attack, and Precise Shot

This commit is contained in:
2026-09-10 10:22:50 +02:00
parent a1c43e83af
commit 97f4429c2b
7 changed files with 682 additions and 40 deletions
+35 -7
View File
@@ -410,16 +410,41 @@ Règles modélisées :
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
armes.
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et
`displaced` (50 %, concealment total) portent un champ `miss_chance` ; quand la
cible en porte une, après la touche et la confirmation de critique, le moteur
tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun
dégât, pas de critique, deed non consommé — journalisé « concealment »). Une
cible avec concealment ne peut pas être la victime d'une attaque sournoise.
- Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des
d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au
niveau 3…) si la cible est flanquée (attaque de mêlée uniquement) ou privée de
son bonus de Dextérité à la CA (condition `denies_dex_to_ac` ; mêlée, ou
attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et
ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais
multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une
cible avec concealment.
- Tirer dans une mêlée / Precise Shot : toute attaque à distance (kind `ranged`
ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au
jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un
ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses
cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à
≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité.
Les attaques de mêlée ne sont pas concernées.
- Couvert : seul le couvert standard (+4, terrain et couvert mou des créatures)
est modélisé ; le couvert partiel (+2) et le couvert amélioré (+8) ne sont PAS
modélisés.
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire).
- Conditions effectives : 18 conditions prédéfinies (`conditions.py`). Leurs drapeaux
- Conditions effectives : 23 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %) impose la
chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance.
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
@@ -496,7 +521,10 @@ Règles modélisées :
spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4)),
spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés).
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles :
(niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
une cible engagée dans une mêlée, signal pour le tir dans la mêlée et
l'attaque sournoise).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
@@ -504,9 +532,9 @@ Règles modélisées :
threatened (grille bool des cases menacées par les ennemis),
terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult}) — définitions complètes des 21 conditions de
CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et
modificateurs).
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 23
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
@@ -563,7 +591,7 @@ la lecture mais non appliquée dans la résolution.
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec
modificateurs + flags comportementaux) et 18 conditions prédéfinies
modificateurs + flags comportementaux + `miss_chance`) et 23 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
+185 -28
View File
@@ -138,6 +138,11 @@ _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
_POINT_BLANK_RANGE_FT = 30
_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee
_FIRING_MELEE_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting",
@@ -236,6 +241,7 @@ def _condition_definition_dict(cond: Condition) -> dict[str, object]:
"mods": [_stat_modifier_dict(m) for m in cond.modifiers],
"flags": flags,
"speed_mult": cond.speed_mult,
"miss_chance": cond.miss_chance,
}
@@ -395,6 +401,7 @@ class AttackResult:
flank_bonus: int = 0
high_ground_bonus: int = 0
base_bonus: int = 0
melee_penalty: int = 0
@dataclass(frozen=True)
@@ -845,6 +852,12 @@ class CombatEngine:
"spell_resistance": state.combatant.spell_resistance,
"resistances": dict(state.combatant.resistances),
"immunities": tuple(state.combatant.immunities),
"sneak_dice": (
(state.combatant.level + 1) // 2
if self._has_feat(state, _SNEAK_ATTACK_FEATURE)
else 0
),
"engaged_in_melee": self._engaged_in_melee(state),
}
def _pair_snapshot(
@@ -1311,6 +1324,11 @@ class CombatEngine:
else 0
)
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
fim_penalty = (
self._firing_into_melee_penalty(attacker, target)
if self._is_ranged_attack(weapon)
else 0
)
atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
@@ -1318,7 +1336,10 @@ class CombatEngine:
atk_mods.append(StatModifier(target="attack", value=-4))
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
total = roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
total = (
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
+ fim_penalty
)
# Check if target denies DEX to AC (flat-footed)
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
full_ac_base = (
@@ -1358,38 +1379,173 @@ class CombatEngine:
):
ac += _COVER_AC_BONUS
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False
damage = 0
concealment_miss = False
crit = False
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
confirm_total = (
confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
crit, concealment_miss, damage = self._resolve_hit_damage(
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
penalty=penalty, flank=flank, high_ground=high_ground,
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
dist_ft=dist_ft, denies_dex=denies_dex,
)
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
damage += pa_amt * 2 if weapon.kind == "melee" else 0
if crit:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
weapon_types = frozenset(
t for comp in weapon.damage for t in comp.types
if not concealment_miss:
target.hp -= damage
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
damage = self._apply_energy_resistance(
damage, weapon_types, target
)
target.hp -= damage
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus,
base_bonus=base_bonus, melee_penalty=fim_penalty,
)
def _resolve_hit_damage(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
roll: int,
ac: int,
base_bonus: int,
penalty: int,
flank: int,
high_ground: int,
attack_mod: int,
pa_amt: int,
fim_penalty: int,
dist_ft: int,
denies_dex: bool,
) -> tuple[bool, bool, int]:
"""Resolve a successful attack roll: crit confirm, concealment miss
chance, sneak attack dice, then damage (PF1e order).
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
added after the crit multiplier and before DR / energy resistance.
"""
crit = False
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
confirm_total = (
confirm + base_bonus + penalty + flank + high_ground
+ attack_mod - pa_amt + fim_penalty
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
concealment = self._concealment_chance(target)
if concealment > 0:
pct = self._rng.d100()
missed = pct <= concealment
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"concealment vs {target.combatant.id}: "
f"d100({pct}) {'<=' if missed else '>'} {concealment}% "
f"— attack {'misses' if missed else 'lands'}"
)
if missed:
return False, True, 0
sneak_total = 0
sneak_dice = self._sneak_attack_dice(
attacker, target, weapon, dist_ft=dist_ft,
denies_dex=denies_dex, concealment=concealment,
)
if sneak_dice > 0:
sneak_total = sum(self._rng.roll_die(6) for _ in range(sneak_dice))
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"sneak attack vs {target.combatant.id}: "
f"{sneak_dice}d6 = {sneak_total} precision damage"
)
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
damage = (
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
)
damage += resolve_modifiers(dmg_mods)
damage += pa_amt * 2 if weapon.kind == "melee" else 0
if crit:
damage *= weapon.crit_mult
damage += sneak_total
damage = self._apply_dr(damage, weapon, target)
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
damage = self._apply_energy_resistance(damage, weapon_types, target)
return crit, False, damage
def _concealment_chance(self, target: CombatantState) -> int:
"""Highest miss chance (%) among the target's active concealment conditions."""
chances = [ac.condition.miss_chance for ac in target.active_conditions]
chances += [cond.miss_chance for cond in target.conditions]
return max((c for c in chances if c > 0), default=0)
def _sneak_attack_dice(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
dist_ft: int,
denies_dex: bool,
concealment: int,
) -> int:
"""Sneak attack d6 count for this swing (0 when the sneak does not apply).
PF1e: precision damage when the target is flanked (melee only) or
denied its Dex bonus to AC (melee, or ranged within 30 ft); dice =
(level + 1) // 2; concealment denies the sneak attack entirely.
"""
if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE):
return 0
dice = (attacker.combatant.level + 1) // 2
is_melee = weapon.kind == "melee"
if is_melee and self._flanking_bonus(attacker, target) > 0:
return dice
if denies_dex and (
is_melee
or (self._is_ranged_attack(weapon) and dist_ft <= _POINT_BLANK_RANGE_FT)
):
return dice
return 0
def _firing_into_melee_penalty(
self, attacker: CombatantState, target: CombatantState
) -> int:
"""PF1e firing into melee: -4 on a ranged attack whose target is
engaged in melee, reduced to -2 when the target or every threatening
opponent stands 10+ ft from the attacker. Precise Shot removes it.
The target is engaged when an active enemy of the target (not the
attacker itself) threatens the target's occupied cells with a melee
weapon.
"""
if self._has_feat(attacker, _PRECISE_SHOT_FEATURE):
return 0
threats = [
s
for s in self._states
if s is not attacker
and s.active
and s.side != target.side
and self.threatened_squares(s) & target.occupied_cells()
]
if not threats:
return 0
if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT:
return _FIRING_MELEE_REDUCED_PENALTY
for threat in threats:
dist = self.creature_distance(attacker, threat) * _SQUARE_FT
if dist >= _FIRING_MELEE_SAFE_FT:
return _FIRING_MELEE_REDUCED_PENALTY
return _FIRING_MELEE_PENALTY
def _engaged_in_melee(self, state: CombatantState) -> bool:
"""True when an active enemy with a melee weapon threatens this
combatant's occupied cells (same threat machinery as AoO/flanking)."""
occupied = state.occupied_cells()
return any(
self.threatened_squares(s) & occupied
for s in self._states
if s is not state and s.active and s.side != state.side
)
def _consume_up_close_and_deadly(
@@ -3355,10 +3511,11 @@ class CombatEngine:
high_ground_str = (
f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else ""
)
melee_str = f"{result.melee_penalty}(melee)" if result.melee_penalty else ""
bonus_str = (
f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}"
f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}{melee_str}"
if result.penalty
else f"{result.base_bonus}{flank_str}{high_ground_str}"
else f"{result.base_bonus}{flank_str}{high_ground_str}{melee_str}"
)
line = (
f"round {self._current_round} {attacker.combatant.id}: {label} "
+15 -1
View File
@@ -37,6 +37,7 @@ class Condition:
cant_aoo: bool = False
must_flee: bool = False
speed_mult: float = 1.0
miss_chance: int = 0
# ── Fear conditions ──────────────────────────────────────────────────────────
@@ -253,6 +254,19 @@ STAGGERED = Condition(
name="staggered",
)
# ── Concealment conditions ───────────────────────────────────────────────────
# Blurred (blur spell): concealment — 20% miss chance on attacks (CRB)
BLURRED = Condition(
name="blurred",
miss_chance=20,
)
# Displaced (displacement spell): total concealment — 50% miss chance (CRB)
DISPLACED = Condition(
name="displaced",
miss_chance=50,
)
# ── Registry ─────────────────────────────────────────────────────────────────
CONDITIONS_BY_NAME: dict[str, Condition] = {
c.name: c
@@ -260,6 +274,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED,
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
]
}
+11 -1
View File
@@ -32,6 +32,10 @@ class Rng(Protocol):
"""Return roll_die(20)."""
...
def d100(self) -> int:
"""Return roll_die(100)."""
...
def choice_index(self, n: int) -> int:
"""Return a uniform integer in 0..n-1 for deterministic tie-breaks."""
...
@@ -49,6 +53,9 @@ class SeededRng:
def d20(self) -> int:
return self.roll_die(20)
def d100(self) -> int:
return self.roll_die(100)
def choice_index(self, n: int) -> int:
return self._rng.randrange(n)
@@ -56,7 +63,7 @@ class SeededRng:
class ScriptedRng:
"""Rng that pops predetermined values from a queue.
Each call to roll_die, d20, or choice_index consumes one value.
Each call to roll_die, d20, d100, or choice_index consumes one value.
Raises RngExhaustedError when the queue is empty.
"""
@@ -71,6 +78,9 @@ class ScriptedRng:
def d20(self) -> int:
return self.roll_die(20)
def d100(self) -> int:
return self.roll_die(100)
def choice_index(self, _n: int) -> int:
if not self._queue:
raise RngExhaustedError(_EXHAUSTED_MSG)
+432
View File
@@ -0,0 +1,432 @@
"""Concealment miss chance, sneak attack, and firing into melee (Package P2)."""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import _snapshot, make_grid, make_state
def _melee(name: str, attack_bonus: int = 5) -> AttackSpec:
return AttackSpec(
id=f"{name}-sword",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _bow(name: str, attack_bonus: int = 5) -> AttackSpec:
return AttackSpec(
id=f"{name}-bow",
name="short bow",
kind="ranged",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=60,
)
def _combatant(
id_: str,
*,
level: int = 1,
ac: int = 10,
attacks: tuple[AttackSpec, ...] = (),
features: tuple[str, ...] = (),
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=level,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks) or [_melee(id_)],
features=[AbilitySpec(name=f, category="class") for f in features],
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
sides: list[str],
rng: list[int],
) -> CombatEngine:
states = [
make_state(c, side, pos)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
return CombatEngine(ScriptedRng(rng), make_grid(), states)
def _add_condition(state: CombatantState, name: str, rounds: int = 5) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME[name],
rounds_left=rounds,
source="test",
)
)
# ── Rule A: concealment miss chance ──────────────────────────────────────────
def test_concealment_blur_misses_on_low_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 10],
)
a, t = eng._states
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 0
assert t.hp == 100
assert any("concealment" in line for line in eng._transcript)
def test_concealment_blur_lands_on_high_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 50, 4],
)
a, t = eng._states
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 4
assert t.hp == 96
def test_total_concealment_triggers_at_50() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 50],
)
a, t = eng._states
_add_condition(t, "displaced")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 0
assert t.hp == 100
def test_concealment_denies_sneak_attack() -> None:
sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine(
[sneak, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 90, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 3
assert t.hp == 97
assert not any("sneak" in line for line in eng._transcript)
def test_concealment_miss_does_not_consume_deed() -> None:
eng = _engine(
[
_combatant("atk", attacks=(_bow("atk", attack_bonus=15),)),
_combatant("tgt"),
],
[(0, 0), (0, 2)],
["players", "monsters"],
[15, 5],
)
a, t = eng._states
a.deed_up_close_and_deadly = True
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 0
assert t.hp == 100
assert a.deed_up_close_and_deadly is True
# ── Rule B: sneak attack ─────────────────────────────────────────────────────
def test_sneak_flanked_melee_dice_count() -> None:
rogue = _combatant("rogue", level=5, features=("Sneak Attack",))
eng = _engine(
[rogue, _combatant("tgt"), _combatant("ally")],
[(0, 0), (0, 1), (0, 2)],
["players", "monsters", "players"],
[15, 2, 3, 4, 5],
)
a, t, _ally = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 22
assert result.damage == 14
assert t.hp == 86
assert any("sneak attack" in line and "3d6" in line for line in eng._transcript)
def test_sneak_not_multiplied_on_crit() -> None:
rogue = _combatant("rogue", level=3, features=("Sneak Attack",))
weapon = AttackSpec(
id="rogue-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
crit_range=20,
crit_mult=2,
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[20, 20, 6, 6, 2],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, weapon)
assert result.crit is True
assert result.damage == 16
assert t.hp == 84
def test_sneak_ranged_within_30ft() -> None:
rogue = _combatant(
"rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),)
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 4)],
["players", "monsters"],
[15, 4, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 7
assert t.hp == 93
def test_sneak_ranged_beyond_30ft_denied() -> None:
rogue = _combatant(
"rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),)
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 7)],
["players", "monsters"],
[15, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 3
assert t.hp == 97
assert not any("sneak" in line for line in eng._transcript)
def test_sneak_denied_dex_melee() -> None:
rogue = _combatant("rogue", level=1, features=("Sneak Attack",))
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 2, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 5
assert t.hp == 95
def test_no_sneak_without_feature() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 3
assert not any("sneak" in line for line in eng._transcript)
# ── Rule C: firing into melee / Precise Shot ─────────────────────────────────
def _firing_setup(rng: list[int], *, precise: bool = False) -> CombatEngine:
archer = _combatant(
"archer",
ac=99,
attacks=(_bow("archer", attack_bonus=8),),
features=("Precise Shot",) if precise else (),
)
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
return _engine(
[archer, tgt, guard],
[(0, 0), (0, 1), (1, 1)],
["players", "monsters", "players"],
rng,
)
def test_firing_into_melee_minus4() -> None:
eng = _firing_setup([10])
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 14
assert result.melee_penalty == -4
assert result.hit is False
assert t.hp == 100
def test_firing_into_melee_minus2_when_target_10ft_away() -> None:
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
eng = _engine(
[archer, tgt, guard],
[(0, 0), (0, 2), (0, 3)],
["players", "monsters", "players"],
[9, 3],
)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 15
assert result.melee_penalty == -2
assert result.hit is True
assert result.damage == 3
def test_precise_shot_negates_penalty() -> None:
eng = _firing_setup([10, 3], precise=True)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 18
assert result.melee_penalty == 0
assert result.hit is True
assert result.damage == 3
def test_melee_attack_unaffected_by_firing_rule() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt"), _combatant("ally")],
[(0, 0), (0, 2), (0, 3)],
["players", "monsters", "players"],
[15, 3],
)
a, t, _al = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.melee_penalty == 0
assert result.total == 20
def test_firing_into_melee_shown_in_transcript() -> None:
eng = _firing_setup([10])
a, t, _g = eng._states
eng._single_attack(a, t, a.combatant.attacks[0])
line = [ln for ln in eng._transcript if "short bow" in ln][-1]
assert "8-4(melee)=14" in line
assert "MISS" in line
def test_firing_into_melee_applies_to_crit_confirm() -> None:
archer = _combatant(
"archer",
ac=99,
attacks=(
AttackSpec(
id="archer-bow",
name="short bow",
kind="ranged",
attack_bonus=8,
damage=[
DamageComponent(formula=parse_dice("1d6"), types=["piercing"])
],
crit_range=20,
range_increment_ft=60,
),
),
)
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
# d20=20 hits (nat 20); confirm d20=1 -> 1+8-4=5 < 15 -> no crit.
eng = _engine(
[archer, tgt, guard],
[(0, 0), (0, 1), (1, 1)],
["players", "monsters", "players"],
[20, 1, 3],
)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.crit is False
assert result.damage == 3
# ── Snapshot keys ────────────────────────────────────────────────────────────
def test_snapshot_sneak_dice_and_engaged() -> None:
rogue = _combatant("rogue", level=5, features=("Sneak Attack",))
brute = _combatant("brute")
distant = _combatant("distant", attacks=(_bow("distant"),))
eng = _engine(
[rogue, brute, distant],
[(0, 0), (0, 1), (0, 7)],
["players", "monsters", "players"],
[],
)
obs = _snapshot(eng, eng._states[0])
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["rogue"]["sneak_dice"] == 3
assert by_id["brute"]["sneak_dice"] == 0
assert by_id["rogue"]["engaged_in_melee"] is True
assert by_id["brute"]["engaged_in_melee"] is True
assert by_id["distant"]["engaged_in_melee"] is False
def test_condition_definitions_carry_miss_chance() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 3)],
["players", "monsters"],
[],
)
obs = _snapshot(eng, eng._states[0])
defs = dict(obs["condition_definitions"])
assert defs["blurred"]["miss_chance"] == 20
assert defs["displaced"]["miss_chance"] == 50
assert defs["shaken"]["miss_chance"] == 0
+1 -1
View File
@@ -794,7 +794,7 @@ def test_ranged_attack_ignores_flanking() -> None:
"initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8",
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
"round 1 arc: short bow vs tgt d20=12+2-2(melee)=12 AC 15 -> MISS",
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds",
+3 -2
View File
@@ -1232,7 +1232,7 @@ def test_observation_enriched_condition_definitions_present() -> None:
"""Given: an Observation built over any combat
When: the top-level dict is inspected
Then: condition_definitions is a sorted tuple of (name, {mods, flags,
speed_mult}) covering every entry of CONDITIONS_BY_NAME."""
speed_mult, miss_chance}) covering every entry of CONDITIONS_BY_NAME."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
@@ -1246,10 +1246,11 @@ def test_observation_enriched_condition_definitions_present() -> None:
# Sorted by name for determinism.
assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME)
for _name, body in defs:
assert set(body) == {"mods", "flags", "speed_mult"}
assert set(body) == {"mods", "flags", "speed_mult", "miss_chance"}
assert isinstance(body["mods"], list)
assert isinstance(body["flags"], list)
assert isinstance(body["speed_mult"], float)
assert isinstance(body["miss_chance"], int)
def test_observation_enriched_condition_definitions_shaken() -> None: