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
|
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
|
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
||||||
armes.
|
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
|
- 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`
|
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||||
(`_expire_summons` décrémente puis retire).
|
(`_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,
|
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
|
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 et bloque toute attaque / manœuvre ; `staggered` restreint à une action
|
||||||
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
|
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é à
|
`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.
|
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,
|
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
|
||||||
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
|
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,
|
spells (résumés enrichis du registre : name, level, school, range_ft,
|
||||||
save, dc, summary, half_on_save, save_negates (E4)),
|
save, dc, summary, half_on_save, save_negates (E4)),
|
||||||
spell_resistance (int), resistances {type: montant}, immunities
|
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,
|
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
||||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||||
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
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),
|
threatened (grille bool des cases menacées par les ennemis),
|
||||||
terrain_semantics (un dict par type de terrain : type, move_cost,
|
terrain_semantics (un dict par type de terrain : type, move_cost,
|
||||||
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
|
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
|
||||||
{mods, flags, speed_mult}) — définitions complètes des 21 conditions de
|
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 23
|
||||||
CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et
|
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
|
||||||
modificateurs).
|
les modificateurs et les chances de rater (concealment)).
|
||||||
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
|
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
|
||||||
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
|
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
|
||||||
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
|
`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
|
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.
|
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
|
||||||
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec
|
- `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,
|
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
|
||||||
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
|
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
|
||||||
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
|
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
|
||||||
|
|||||||
+185
-28
@@ -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
|
_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
|
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
|
||||||
_POINT_BLANK_RANGE_FT = 30
|
_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({
|
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||||
"mind_affecting",
|
"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],
|
"mods": [_stat_modifier_dict(m) for m in cond.modifiers],
|
||||||
"flags": flags,
|
"flags": flags,
|
||||||
"speed_mult": cond.speed_mult,
|
"speed_mult": cond.speed_mult,
|
||||||
|
"miss_chance": cond.miss_chance,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -395,6 +401,7 @@ class AttackResult:
|
|||||||
flank_bonus: int = 0
|
flank_bonus: int = 0
|
||||||
high_ground_bonus: int = 0
|
high_ground_bonus: int = 0
|
||||||
base_bonus: int = 0
|
base_bonus: int = 0
|
||||||
|
melee_penalty: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -845,6 +852,12 @@ class CombatEngine:
|
|||||||
"spell_resistance": state.combatant.spell_resistance,
|
"spell_resistance": state.combatant.spell_resistance,
|
||||||
"resistances": dict(state.combatant.resistances),
|
"resistances": dict(state.combatant.resistances),
|
||||||
"immunities": tuple(state.combatant.immunities),
|
"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(
|
def _pair_snapshot(
|
||||||
@@ -1311,6 +1324,11 @@ class CombatEngine:
|
|||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
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(
|
atk_mods = self._stat_modifiers(
|
||||||
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
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))
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||||
attack_mod = resolve_modifiers(atk_mods)
|
attack_mod = resolve_modifiers(atk_mods)
|
||||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
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)
|
# Check if target denies DEX to AC (flat-footed)
|
||||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||||
full_ac_base = (
|
full_ac_base = (
|
||||||
@@ -1358,38 +1379,173 @@ class CombatEngine:
|
|||||||
):
|
):
|
||||||
ac += _COVER_AC_BONUS
|
ac += _COVER_AC_BONUS
|
||||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||||
crit = False
|
|
||||||
damage = 0
|
damage = 0
|
||||||
|
concealment_miss = False
|
||||||
|
crit = False
|
||||||
if hit:
|
if hit:
|
||||||
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
crit, concealment_miss, damage = self._resolve_hit_damage(
|
||||||
confirm = self._rng.d20()
|
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
|
||||||
confirm_total = (
|
penalty=penalty, flank=flank, high_ground=high_ground,
|
||||||
confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
|
||||||
)
|
dist_ft=dist_ft, denies_dex=denies_dex,
|
||||||
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
|
if not concealment_miss:
|
||||||
damage += resolve_modifiers(dmg_mods)
|
target.hp -= damage
|
||||||
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
damage = self._consume_up_close_and_deadly(
|
||||||
if crit:
|
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||||
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
|
|
||||||
)
|
|
||||||
target.hp -= damage
|
|
||||||
damage = self._consume_up_close_and_deadly(
|
|
||||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
|
||||||
)
|
|
||||||
return AttackResult(
|
return AttackResult(
|
||||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
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(
|
def _consume_up_close_and_deadly(
|
||||||
@@ -3355,10 +3511,11 @@ class CombatEngine:
|
|||||||
high_ground_str = (
|
high_ground_str = (
|
||||||
f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else ""
|
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 = (
|
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
|
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 = (
|
line = (
|
||||||
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Condition:
|
|||||||
cant_aoo: bool = False
|
cant_aoo: bool = False
|
||||||
must_flee: bool = False
|
must_flee: bool = False
|
||||||
speed_mult: float = 1.0
|
speed_mult: float = 1.0
|
||||||
|
miss_chance: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ── Fear conditions ──────────────────────────────────────────────────────────
|
# ── Fear conditions ──────────────────────────────────────────────────────────
|
||||||
@@ -253,6 +254,19 @@ STAGGERED = Condition(
|
|||||||
name="staggered",
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||||
CONDITIONS_BY_NAME: dict[str, Condition] = {
|
CONDITIONS_BY_NAME: dict[str, Condition] = {
|
||||||
c.name: c
|
c.name: c
|
||||||
@@ -260,6 +274,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
|
|||||||
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
|
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
|
||||||
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
||||||
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
|
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)."""
|
"""Return roll_die(20)."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def d100(self) -> int:
|
||||||
|
"""Return roll_die(100)."""
|
||||||
|
...
|
||||||
|
|
||||||
def choice_index(self, n: int) -> int:
|
def choice_index(self, n: int) -> int:
|
||||||
"""Return a uniform integer in 0..n-1 for deterministic tie-breaks."""
|
"""Return a uniform integer in 0..n-1 for deterministic tie-breaks."""
|
||||||
...
|
...
|
||||||
@@ -49,6 +53,9 @@ class SeededRng:
|
|||||||
def d20(self) -> int:
|
def d20(self) -> int:
|
||||||
return self.roll_die(20)
|
return self.roll_die(20)
|
||||||
|
|
||||||
|
def d100(self) -> int:
|
||||||
|
return self.roll_die(100)
|
||||||
|
|
||||||
def choice_index(self, n: int) -> int:
|
def choice_index(self, n: int) -> int:
|
||||||
return self._rng.randrange(n)
|
return self._rng.randrange(n)
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ class SeededRng:
|
|||||||
class ScriptedRng:
|
class ScriptedRng:
|
||||||
"""Rng that pops predetermined values from a queue.
|
"""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.
|
Raises RngExhaustedError when the queue is empty.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -71,6 +78,9 @@ class ScriptedRng:
|
|||||||
def d20(self) -> int:
|
def d20(self) -> int:
|
||||||
return self.roll_die(20)
|
return self.roll_die(20)
|
||||||
|
|
||||||
|
def d100(self) -> int:
|
||||||
|
return self.roll_die(100)
|
||||||
|
|
||||||
def choice_index(self, _n: int) -> int:
|
def choice_index(self, _n: int) -> int:
|
||||||
if not self._queue:
|
if not self._queue:
|
||||||
raise RngExhaustedError(_EXHAUSTED_MSG)
|
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: aly d20=9+0=9",
|
||||||
"initiative: tgt d20=8+0=8",
|
"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 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 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",
|
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
|
||||||
"battle over: draw after 1 rounds",
|
"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
|
"""Given: an Observation built over any combat
|
||||||
When: the top-level dict is inspected
|
When: the top-level dict is inspected
|
||||||
Then: condition_definitions is a sorted tuple of (name, {mods, flags,
|
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")
|
hero = make_combatant("hero")
|
||||||
foe = make_combatant("foe")
|
foe = make_combatant("foe")
|
||||||
hero_state = make_state(hero, "players", (0, 0))
|
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.
|
# Sorted by name for determinism.
|
||||||
assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME)
|
assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME)
|
||||||
for _name, body in defs:
|
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["mods"], list)
|
||||||
assert isinstance(body["flags"], list)
|
assert isinstance(body["flags"], list)
|
||||||
assert isinstance(body["speed_mult"], float)
|
assert isinstance(body["speed_mult"], float)
|
||||||
|
assert isinstance(body["miss_chance"], int)
|
||||||
|
|
||||||
|
|
||||||
def test_observation_enriched_condition_definitions_shaken() -> None:
|
def test_observation_enriched_condition_definitions_shaken() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user