feat(combat): add concealment miss chance, sneak attack, and Precise Shot
This commit is contained in:
@@ -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
|
||||
|
||||
+182
-25
@@ -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,30 +1379,17 @@ 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
|
||||
)
|
||||
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
|
||||
)
|
||||
damage = self._apply_energy_resistance(
|
||||
damage, weapon_types, target
|
||||
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,
|
||||
)
|
||||
if not concealment_miss:
|
||||
target.hp -= damage
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
@@ -1389,7 +1397,155 @@ class CombatEngine:
|
||||
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} "
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user