feat(rules): add ongoing spell damage
Acid Arrow (SRD spells/acid-arrow.md) deals 2d4 acid, then 2d4 again at the start of each of the target's turns for 1 round + 1 per three caster levels (max 6). DamageEffect gains a RecurringDamageSpec (JSON 'recurring'); a landed hit attaches a RecurringDamage source to CombatantState.recurring_damage, ticked in _turn_gate (after bleed), reduced by energy resistance/immunity but never DR (spell damage), cleared at expiry, and exposed in the observation snapshot. Extracted _resolve_one_projectile to keep _apply_damage_effect under the complexity ceiling. Updated acid_arrow.json, added tests/test_recurring_damage.py, and the README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -961,6 +961,22 @@ Règles modélisées :
|
|||||||
Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes
|
Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes
|
||||||
sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11)
|
sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11)
|
||||||
tire désormais ses cinq missiles.
|
tire désormais ses cinq missiles.
|
||||||
|
- Dégâts récurrents de sort (S2c) : un `DamageEffect` peut déclarer
|
||||||
|
`"recurring": {"formula": "2d4", "types": ["acid"], "base_rounds": 1,
|
||||||
|
"per_levels": 3, "max": 6}` — le nombre de rounds de récurrence vaut
|
||||||
|
`base_rounds + niveau // per_levels`, plafonné à `max` (SRD Acid Arrow :
|
||||||
|
« For every three caster levels you possess, the acid [...] lasts for another
|
||||||
|
round [...] dealing another 2d4 points of damage in each round » → 1 round au
|
||||||
|
niveau 1, 2 au niveau 3, 6 au niveau 18+). À l'attaque initiale réussie, une
|
||||||
|
source de dégâts récurrents (`RecurringDamage` sur le `CombatantState`, liste
|
||||||
|
`recurring_damage`) est posée sur la cible vivante ; au **début du tour** de
|
||||||
|
la cible (dans `_turn_gate`, après le tick de saignement), chaque source
|
||||||
|
jetle ses dés, est **réduite par la résistance/immunité énergétique** (acide)
|
||||||
|
mais **jamais par la RD** (c'est un dégât de sort), inflige ce dégât, puis
|
||||||
|
perd un round ; arrivée à 0 round elle est retirée. L'observation expose
|
||||||
|
chaque source sous `recurring_damage` (`{formula, types, rounds_left}`).
|
||||||
|
Simplification documentée : la neutralisation d'acide (immersion/eau) n'est
|
||||||
|
pas modélisée — le dégât court jusqu'à l'expiration de sa durée.
|
||||||
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
|
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
|
||||||
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
|
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
|
||||||
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
|
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
|
||||||
@@ -1340,7 +1356,10 @@ Règles modélisées :
|
|||||||
son tour), running (bool — course en cours jusqu'au prochain tour du
|
son tour), running (bool — course en cours jusqu'au prochain tour du
|
||||||
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
|
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
|
||||||
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
|
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
|
||||||
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`).
|
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
|
||||||
|
recurring_damage (S2c — liste des sources de dégâts récurrents de sort
|
||||||
|
actives, chacune `{formula, types, rounds_left}` ; tick au début du tour,
|
||||||
|
réduit par résistance énergétique, jamais par la RD).
|
||||||
- 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,
|
||||||
@@ -1415,13 +1434,6 @@ plutôt que bricolées) :
|
|||||||
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le
|
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le
|
||||||
moteur n'a pas encore de sauvegarde partielle) :
|
moteur n'a pas encore de sauvegarde partielle) :
|
||||||
|
|
||||||
- **Acid Arrow — sans dégâts continus** (SRD spells/acid-arrow.md : portée
|
|
||||||
**longue** — corrigée, conformance D7 — et « For every three caster levels
|
|
||||||
you possess, the acid, unless neutralized, lasts for another round [...],
|
|
||||||
dealing another 2d4 points of damage in each round ») : le moteur n'a pas de
|
|
||||||
subsystem de dégâts continus par sort (ni neutralisation d'acide) ; seuls les
|
|
||||||
2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont
|
|
||||||
hors périmètre.
|
|
||||||
- **Fear — sauvegarde totale** (SRD spells/fear.md : « each
|
- **Fear — sauvegarde totale** (SRD spells/fear.md : « each
|
||||||
living creature in the area [...] becomes panicked unless it succeeds on a
|
living creature in the area [...] becomes panicked unless it succeeds on a
|
||||||
Will save [...]. If the Will save succeeds, the creature is shaken for 1
|
Will save [...]. If the Will save succeeds, the creature is shaken for 1
|
||||||
|
|||||||
@@ -12,8 +12,15 @@
|
|||||||
"type": "damage",
|
"type": "damage",
|
||||||
"formula": "2d4",
|
"formula": "2d4",
|
||||||
"types": ["acid"],
|
"types": ["acid"],
|
||||||
"half_on_save": false
|
"half_on_save": false,
|
||||||
|
"recurring": {
|
||||||
|
"formula": "2d4",
|
||||||
|
"types": ["acid"],
|
||||||
|
"base_rounds": 1,
|
||||||
|
"per_levels": 3,
|
||||||
|
"max": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "SRD spells/acid-arrow.md: ranged touch attack dealing 2d4 acid damage, plus another 2d4 per round for 1 extra round per three caster levels (max 6 additional rounds). The engine has no damage-over-time subsystem, so only the initial 2d4 is modeled - the ongoing acid is documented as a limitation. No save."
|
"description": "SRD spells/acid-arrow.md: ranged touch attack dealing 2d4 acid damage, plus another 2d4 acid damage at the start of each of the target's turns for 1 round + 1 round per three caster levels (maximum 6 additional rounds). Recurring damage is reduced by energy resistance/immunity, not by DR. No save."
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-32
@@ -123,7 +123,7 @@ from typing import TYPE_CHECKING, Literal
|
|||||||
|
|
||||||
from pf1e_simulator.areas import cone_cells, line_cells
|
from pf1e_simulator.areas import cone_cells, line_cells
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
from pf1e_simulator.dice import dice_to_str
|
from pf1e_simulator.dice import DiceExpr, dice_to_str
|
||||||
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
||||||
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
||||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||||
@@ -134,6 +134,7 @@ from pf1e_simulator.spells import (
|
|||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
|
RecurringDamageSpec,
|
||||||
SaveType,
|
SaveType,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
@@ -560,6 +561,23 @@ def _is_pc_combatant(combatant: Combatant) -> bool:
|
|||||||
return combatant.source.startswith(_PC_SOURCE_PREFIX)
|
return combatant.source.startswith(_PC_SOURCE_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecurringDamage:
|
||||||
|
"""One active damage-over-time source on a combatant (S2c, Acid Arrow).
|
||||||
|
|
||||||
|
- ``formula``: dice rolled each recurring round (2d4)
|
||||||
|
- ``types``: energy types, reduced by resistance/immunity (never DR)
|
||||||
|
- ``rounds_left``: rounds this source still deals damage (decremented and
|
||||||
|
removed at the start of the owner's turn once it ticks)
|
||||||
|
- ``source``: human-readable origin for the transcript
|
||||||
|
"""
|
||||||
|
|
||||||
|
formula: DiceExpr
|
||||||
|
types: frozenset[str]
|
||||||
|
rounds_left: int
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CombatantState:
|
class CombatantState:
|
||||||
"""Mutable runtime state of one combatant on the grid."""
|
"""Mutable runtime state of one combatant on the grid."""
|
||||||
@@ -633,6 +651,11 @@ class CombatantState:
|
|||||||
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
||||||
# of each turn; any effect that restores hit points clears it.
|
# of each turn; any effect that restores hit points clears it.
|
||||||
bleed_damage: int = 0
|
bleed_damage: int = 0
|
||||||
|
# PF1e recurring spell damage (S2c, not in the frozen data model): each
|
||||||
|
# entry is one damage-over-time source (Acid Arrow's lingering acid) that
|
||||||
|
# deals its dice at the start of this combatant's turn for `rounds_left`
|
||||||
|
# rounds, reduced by energy resistance/immunity (never DR).
|
||||||
|
recurring_damage: list[RecurringDamage] = field(default_factory=list)
|
||||||
|
|
||||||
def usable_attacks(self) -> list[AttackSpec]:
|
def usable_attacks(self) -> list[AttackSpec]:
|
||||||
return [
|
return [
|
||||||
@@ -1303,6 +1326,14 @@ class CombatEngine:
|
|||||||
"aid_ac_bonus": state.aid_ac_bonus,
|
"aid_ac_bonus": state.aid_ac_bonus,
|
||||||
"running": state.running,
|
"running": state.running,
|
||||||
"bleed_damage": state.bleed_damage,
|
"bleed_damage": state.bleed_damage,
|
||||||
|
"recurring_damage": [
|
||||||
|
{
|
||||||
|
"formula": dice_to_str(entry.formula),
|
||||||
|
"types": tuple(sorted(entry.types)),
|
||||||
|
"rounds_left": entry.rounds_left,
|
||||||
|
}
|
||||||
|
for entry in state.recurring_damage
|
||||||
|
],
|
||||||
"aoo_remaining": max(
|
"aoo_remaining": max(
|
||||||
0,
|
0,
|
||||||
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
|
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
|
||||||
@@ -1796,15 +1827,17 @@ class CombatEngine:
|
|||||||
state.bleed_damage = 0
|
state.bleed_damage = 0
|
||||||
|
|
||||||
def _turn_gate(self, state: CombatantState) -> bool | None:
|
def _turn_gate(self, state: CombatantState) -> bool | None:
|
||||||
"""Start-of-turn effects: bleed tick, confused d% roll, cant_act flag.
|
"""Start-of-turn effects: bleed tick, recurring spell damage, confused
|
||||||
|
d% roll, cant_act flag.
|
||||||
|
|
||||||
Returns None when the turn proceeds to the policy; otherwise the turn
|
Returns None when the turn proceeds to the policy; otherwise the turn
|
||||||
is forfeited and the value is the battle-over flag.
|
is forfeited and the value is the battle-over flag.
|
||||||
"""
|
"""
|
||||||
self._apply_bleed_damage(state)
|
self._apply_bleed_damage(state)
|
||||||
|
self._apply_recurring_damage(state)
|
||||||
if not state.active:
|
if not state.active:
|
||||||
# Bleeding knocked the combatant out (dying or dead); the winner
|
# Bleeding / acid knocked the combatant out (dying or dead); the
|
||||||
# check covers the death case.
|
# winner check covers the death case.
|
||||||
return self._winner_side() is not None
|
return self._winner_side() is not None
|
||||||
confused_over = self._apply_confusion(state)
|
confused_over = self._apply_confusion(state)
|
||||||
if confused_over is not None:
|
if confused_over is not None:
|
||||||
@@ -1825,6 +1858,34 @@ class CombatEngine:
|
|||||||
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_recurring_damage(self, state: CombatantState) -> None:
|
||||||
|
"""PF1e recurring spell damage (S2c, Acid Arrow): at the start of the
|
||||||
|
owner's turn each active source rolls its dice, is reduced by energy
|
||||||
|
resistance/immunity (never DR — it is spell damage), deals that damage,
|
||||||
|
then loses one round; sources that reach 0 rounds are cleared."""
|
||||||
|
if not state.recurring_damage:
|
||||||
|
return
|
||||||
|
remaining: list[RecurringDamage] = []
|
||||||
|
for entry in state.recurring_damage:
|
||||||
|
raw = entry.formula.roll(self._rng)
|
||||||
|
dmg = self._apply_energy_resistance(raw, entry.types, state)
|
||||||
|
old_hp = state.hp
|
||||||
|
state.hp -= dmg
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
|
f"{entry.source} {dmg} (hp {old_hp}->{state.hp})"
|
||||||
|
)
|
||||||
|
if entry.rounds_left > 1:
|
||||||
|
remaining.append(
|
||||||
|
RecurringDamage(
|
||||||
|
formula=entry.formula,
|
||||||
|
types=entry.types,
|
||||||
|
rounds_left=entry.rounds_left - 1,
|
||||||
|
source=entry.source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.recurring_damage[:] = remaining
|
||||||
|
|
||||||
def _apply_confusion(self, state: CombatantState) -> bool | None:
|
def _apply_confusion(self, state: CombatantState) -> bool | None:
|
||||||
"""PF1e confused: roll d% at the start of the turn to act randomly (CRB).
|
"""PF1e confused: roll d% at the start of the turn to act randomly (CRB).
|
||||||
|
|
||||||
@@ -5804,6 +5865,7 @@ class CombatEngine:
|
|||||||
effect, target, caster_live, target_live,
|
effect, target, caster_live, target_live,
|
||||||
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
||||||
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
||||||
|
spell_name=spell_name,
|
||||||
)
|
)
|
||||||
if isinstance(effect, ConditionEffect):
|
if isinstance(effect, ConditionEffect):
|
||||||
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
||||||
@@ -5825,6 +5887,7 @@ class CombatEngine:
|
|||||||
crit: bool = False,
|
crit: bool = False,
|
||||||
ranged_touch: bool = False,
|
ranged_touch: bool = False,
|
||||||
attack_penalty: int = 0,
|
attack_penalty: int = 0,
|
||||||
|
spell_name: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
||||||
# against the caster's level at cast time; the scaled formula then
|
# against the caster's level at cast time; the scaled formula then
|
||||||
@@ -5842,34 +5905,13 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
total = 0
|
total = 0
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
proj_crit = crit
|
dmg = self._resolve_one_projectile(
|
||||||
if ranged_touch:
|
effect, formula, target, caster,
|
||||||
# Each ray rolls its own ranged touch attack (cover,
|
saved=saved, empower=empower, maximize=maximize,
|
||||||
# concealment and critical threat included).
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
||||||
hit, proj_crit = self._spell_ranged_touch_attack_hits(
|
)
|
||||||
caster, target,
|
if dmg is None:
|
||||||
attack_penalty=attack_penalty, deals_damage=True,
|
continue # this ray missed — no damage, no stat/reaction
|
||||||
)
|
|
||||||
if not hit:
|
|
||||||
continue
|
|
||||||
raw = formula.roll(self._rng)
|
|
||||||
if maximize:
|
|
||||||
raw = formula.max_value()
|
|
||||||
if proj_crit:
|
|
||||||
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
|
||||||
# deals damage threatens on a natural 20 and deals double
|
|
||||||
# damage on a successful critical hit.
|
|
||||||
raw *= 2
|
|
||||||
if saved and effect.half_on_save:
|
|
||||||
dmg = max(0, raw // 2)
|
|
||||||
elif saved and effect.save_negates:
|
|
||||||
dmg = 0
|
|
||||||
else:
|
|
||||||
dmg = raw
|
|
||||||
if empower:
|
|
||||||
dmg = int(dmg * 1.5)
|
|
||||||
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
|
||||||
target.hp -= dmg
|
|
||||||
caster_live.damage_dealt += dmg
|
caster_live.damage_dealt += dmg
|
||||||
target_live.damage_taken += dmg
|
target_live.damage_taken += dmg
|
||||||
self._check_reactions(
|
self._check_reactions(
|
||||||
@@ -5882,8 +5924,83 @@ class CombatEngine:
|
|||||||
# are lost (the engine's existing multi-attack convention).
|
# are lost (the engine's existing multi-attack convention).
|
||||||
if not target.active:
|
if not target.active:
|
||||||
break
|
break
|
||||||
|
# PF1e recurring damage (S2c, Acid Arrow): a landed hit leaves acid
|
||||||
|
# that keeps burning for rounds_for(caster_level) rounds. Only attached
|
||||||
|
# while the target is still standing (a dead target has no turns).
|
||||||
|
if effect.recurring is not None and target.active:
|
||||||
|
self._attach_recurring_damage(effect.recurring, target, caster, spell_name)
|
||||||
return f"{total} damage"
|
return f"{total} damage"
|
||||||
|
|
||||||
|
def _resolve_one_projectile(
|
||||||
|
self,
|
||||||
|
effect: DamageEffect,
|
||||||
|
formula: DiceExpr,
|
||||||
|
target: CombatantState,
|
||||||
|
caster: CombatantState,
|
||||||
|
*,
|
||||||
|
saved: bool,
|
||||||
|
empower: bool,
|
||||||
|
maximize: bool,
|
||||||
|
crit: bool,
|
||||||
|
ranged_touch: bool,
|
||||||
|
attack_penalty: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""Resolve one projectile's hit + damage; None when the ray misses.
|
||||||
|
|
||||||
|
Rolls the projectile's own ranged touch attack (when the spell is a
|
||||||
|
ray), then its own damage dice through maximize / crit / save /
|
||||||
|
empower / energy resistance, and applies the damage to the target.
|
||||||
|
"""
|
||||||
|
proj_crit = crit
|
||||||
|
if ranged_touch:
|
||||||
|
# Each ray rolls its own ranged touch attack (cover, concealment
|
||||||
|
# and critical threat included).
|
||||||
|
hit, proj_crit = self._spell_ranged_touch_attack_hits(
|
||||||
|
caster, target,
|
||||||
|
attack_penalty=attack_penalty, deals_damage=True,
|
||||||
|
)
|
||||||
|
if not hit:
|
||||||
|
return None
|
||||||
|
raw = formula.roll(self._rng)
|
||||||
|
if maximize:
|
||||||
|
raw = formula.max_value()
|
||||||
|
if proj_crit:
|
||||||
|
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
||||||
|
# deals damage threatens on a natural 20 and deals double
|
||||||
|
# damage on a successful critical hit.
|
||||||
|
raw *= 2
|
||||||
|
if saved and effect.half_on_save:
|
||||||
|
dmg = max(0, raw // 2)
|
||||||
|
elif saved and effect.save_negates:
|
||||||
|
dmg = 0
|
||||||
|
else:
|
||||||
|
dmg = raw
|
||||||
|
if empower:
|
||||||
|
dmg = int(dmg * 1.5)
|
||||||
|
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||||
|
target.hp -= dmg
|
||||||
|
return dmg
|
||||||
|
|
||||||
|
def _attach_recurring_damage(
|
||||||
|
self,
|
||||||
|
spec: RecurringDamageSpec,
|
||||||
|
target: CombatantState,
|
||||||
|
caster: CombatantState,
|
||||||
|
spell_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Add a recurring-damage source to a target that just took the hit."""
|
||||||
|
rounds = spec.rounds_for(caster.combatant.level)
|
||||||
|
if rounds <= 0:
|
||||||
|
return
|
||||||
|
target.recurring_damage.append(
|
||||||
|
RecurringDamage(
|
||||||
|
formula=spec.formula,
|
||||||
|
types=frozenset(spec.types),
|
||||||
|
rounds_left=rounds,
|
||||||
|
source=f"acid: {spell_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_condition_effect(
|
def _apply_condition_effect(
|
||||||
self,
|
self,
|
||||||
effect: ConditionEffect,
|
effect: ConditionEffect,
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ class ProjectileScaling:
|
|||||||
return min(self.max_projectiles, self.base + extra)
|
return min(self.max_projectiles, self.base + extra)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecurringDamageSpec:
|
||||||
|
"""Per-round recurring spell damage (PF1e Acid Arrow).
|
||||||
|
|
||||||
|
- formula: dice rolled in each recurring round (2d4)
|
||||||
|
- types: energy types (acid) — reduced by energy resistance/immunity,
|
||||||
|
never by DR (it is spell damage)
|
||||||
|
- base_rounds / per_levels / max_rounds: rounds of recurrence scale with
|
||||||
|
caster level as ``base_rounds + caster_level // per_levels`` capped at
|
||||||
|
``max_rounds`` (SRD spells/acid-arrow.md: "1 round + 1 round per three
|
||||||
|
levels [...] to a maximum of 6 additional rounds").
|
||||||
|
"""
|
||||||
|
formula: DiceExpr
|
||||||
|
types: tuple[str, ...]
|
||||||
|
base_rounds: int
|
||||||
|
per_levels: int
|
||||||
|
max_rounds: int
|
||||||
|
|
||||||
|
def rounds_for(self, caster_level: int) -> int:
|
||||||
|
"""Recurring rounds for a caster of ``caster_level``."""
|
||||||
|
return min(self.max_rounds, self.base_rounds + caster_level // self.per_levels)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DamageEffect:
|
class DamageEffect:
|
||||||
formula: DiceExpr
|
formula: DiceExpr
|
||||||
@@ -113,6 +136,10 @@ class DamageEffect:
|
|||||||
# fires `count_for(caster_level)` projectiles, each rolling its own damage
|
# fires `count_for(caster_level)` projectiles, each rolling its own damage
|
||||||
# and (for a ray) its own attack. Absent = a single projectile.
|
# and (for a ray) its own attack. Absent = a single projectile.
|
||||||
projectiles: ProjectileScaling | None = None
|
projectiles: ProjectileScaling | None = None
|
||||||
|
# PF1e recurring (damage-over-time) component (Acid Arrow): after the
|
||||||
|
# initial hit the target takes this damage again at the start of each of
|
||||||
|
# its turns for `rounds_for(caster_level)` rounds. Absent = no DoT.
|
||||||
|
recurring: RecurringDamageSpec | None = None
|
||||||
|
|
||||||
def effective_formula(self, caster_level: int) -> DiceExpr:
|
def effective_formula(self, caster_level: int) -> DiceExpr:
|
||||||
"""The damage formula resolved for a caster of ``caster_level``.
|
"""The damage formula resolved for a caster of ``caster_level``.
|
||||||
@@ -417,6 +444,30 @@ def _parse_projectiles(value: Json) -> ProjectileScaling:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recurring(value: Json) -> RecurringDamageSpec:
|
||||||
|
data = _as_dict(value)
|
||||||
|
formula = parse_dice(_as_str(data["formula"]))
|
||||||
|
base_rounds = _as_int(data.get("base_rounds", 1))
|
||||||
|
per_levels = _as_int(data["per_levels"])
|
||||||
|
max_rounds = _as_int(data["max"])
|
||||||
|
if base_rounds < 0:
|
||||||
|
msg = f"recurring base_rounds must be >= 0, got {base_rounds}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if per_levels < 1:
|
||||||
|
msg = f"recurring per_levels must be >= 1, got {per_levels}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if max_rounds < base_rounds:
|
||||||
|
msg = f"recurring max must be >= base_rounds, got {max_rounds} < {base_rounds}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return RecurringDamageSpec(
|
||||||
|
formula=formula,
|
||||||
|
types=tuple(_as_str(t) for t in _as_list(data["types"])),
|
||||||
|
base_rounds=base_rounds,
|
||||||
|
per_levels=per_levels,
|
||||||
|
max_rounds=max_rounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_effect(data: Json) -> SpellEffect:
|
def _parse_effect(data: Json) -> SpellEffect:
|
||||||
effect = _as_dict(data)
|
effect = _as_dict(data)
|
||||||
effect_type = _as_str(effect["type"])
|
effect_type = _as_str(effect["type"])
|
||||||
@@ -438,6 +489,8 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||||
proj_raw = effect.get("projectiles")
|
proj_raw = effect.get("projectiles")
|
||||||
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
|
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
|
||||||
|
rec_raw = effect.get("recurring")
|
||||||
|
recurring = None if rec_raw is None else _parse_recurring(rec_raw)
|
||||||
return DamageEffect(
|
return DamageEffect(
|
||||||
formula=formula,
|
formula=formula,
|
||||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
||||||
@@ -446,6 +499,7 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
scaling=scaling,
|
scaling=scaling,
|
||||||
scaling_flat=scaling_flat,
|
scaling_flat=scaling_flat,
|
||||||
projectiles=projectiles,
|
projectiles=projectiles,
|
||||||
|
recurring=recurring,
|
||||||
)
|
)
|
||||||
if effect_type == "condition":
|
if effect_type == "condition":
|
||||||
return ConditionEffect(
|
return ConditionEffect(
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Ongoing (recurring) spell damage (Package S2c).
|
||||||
|
|
||||||
|
PF1e (SRD spells/acid-arrow.md): "The arrow deals 2d4 points of acid damage
|
||||||
|
[...] For every three caster levels you possess, the acid, unless neutralized,
|
||||||
|
lasts for another round (to a maximum of 6 additional rounds at 18th level),
|
||||||
|
dealing another 2d4 points of damage in each round."
|
||||||
|
|
||||||
|
The recurring component ticks at the START of the affected creature's turn, is
|
||||||
|
reduced by energy resistance/immunity (acid) but NOT by DR (it is spell
|
||||||
|
damage), and is cleared when its rounds run out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
DamageReduction,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import DamageEffect, load_spell, load_spell_registry
|
||||||
|
|
||||||
|
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||||
|
|
||||||
|
|
||||||
|
def _caster(cid: str = "caster", *, level: int = 1) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="dagger",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=2,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=level,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=14, con_score=12,
|
||||||
|
int_score=18, wis_score=12, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=40,
|
||||||
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||||
|
bab=2,
|
||||||
|
initiative_mod=2,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=3, ref=4, will=4),
|
||||||
|
attacks=[attack],
|
||||||
|
casting_ability="int",
|
||||||
|
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _victim(
|
||||||
|
cid: str = "victim",
|
||||||
|
*,
|
||||||
|
hp_max: int = 200,
|
||||||
|
resistances: dict[str, int] | None = None,
|
||||||
|
dr: DamageReduction | None = None,
|
||||||
|
) -> Combatant:
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=hp_max,
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=2, will=2),
|
||||||
|
attacks=[],
|
||||||
|
resistances=resistances or {},
|
||||||
|
dr=dr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _grid() -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
caster: Combatant,
|
||||||
|
victim: Combatant,
|
||||||
|
queue: list[int],
|
||||||
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||||
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||||
|
v = CombatantState(combatant=victim, side="monsters", pos=(3, 0), hp=victim.hp_max)
|
||||||
|
reg = load_spell_registry(_SPELLS_DIR)
|
||||||
|
engine = CombatEngine(ScriptedRng(queue), _grid(), [c, v], spell_registry=reg)
|
||||||
|
return engine, c, v
|
||||||
|
|
||||||
|
|
||||||
|
def _acid_rounds(level: int) -> int:
|
||||||
|
spell = load_spell(_SPELLS_DIR / "acid_arrow.json")
|
||||||
|
effect = spell.effects[0]
|
||||||
|
assert isinstance(effect, DamageEffect)
|
||||||
|
assert effect.recurring is not None
|
||||||
|
return effect.recurring.rounds_for(level)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcidArrowRecurringRounds:
|
||||||
|
"""Given: Acid Arrow's recurring scaling (1 + one round per three levels,
|
||||||
|
max 6)
|
||||||
|
When: resolved for caster levels 1 / 3 / 18
|
||||||
|
Then: it is 1 / 2 / 6 rounds (SRD spells/acid-arrow.md)."""
|
||||||
|
|
||||||
|
def test_one_round_at_low_level(self) -> None:
|
||||||
|
assert _acid_rounds(1) == 1
|
||||||
|
|
||||||
|
def test_two_rounds_at_level_3(self) -> None:
|
||||||
|
assert _acid_rounds(3) == 2
|
||||||
|
|
||||||
|
def test_caps_at_six(self) -> None:
|
||||||
|
assert _acid_rounds(18) == 6
|
||||||
|
assert _acid_rounds(20) == 6
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcidArrowAttachesRecurring:
|
||||||
|
"""Given: a level-1 caster (1 recurring round) hits with Acid Arrow
|
||||||
|
When: the ranged touch attack lands
|
||||||
|
Then: the initial 2d4 is dealt AND one recurring source is attached."""
|
||||||
|
|
||||||
|
def test_initial_hit_attaches_source(self) -> None:
|
||||||
|
caster = _caster(level=1)
|
||||||
|
victim = _victim(hp_max=200)
|
||||||
|
# ranged touch attack d20=15 (hits AC 10), then 2d4 initial (3+3=6)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert v.hp == 200 - 6
|
||||||
|
assert len(v.recurring_damage) == 1
|
||||||
|
assert v.recurring_damage[0].rounds_left == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringTicksEachRound:
|
||||||
|
"""Given: a level-3 caster (2 recurring rounds) hits with Acid Arrow
|
||||||
|
When: the victim's turn starts twice
|
||||||
|
Then: the acid deals 2d4 on each of the two turns, then clears."""
|
||||||
|
|
||||||
|
def test_ticks_then_expires(self) -> None:
|
||||||
|
caster = _caster(level=3)
|
||||||
|
victim = _victim(hp_max=200)
|
||||||
|
# attack (15) + initial 2d4 (3+3) + tick1 2d4 (2+2) + tick2 2d4 (4+4)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3, 2, 2, 4, 4])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert v.hp == 200 - 6
|
||||||
|
assert len(v.recurring_damage) == 1
|
||||||
|
assert v.recurring_damage[0].rounds_left == 2
|
||||||
|
# first turn start: one tick, one round left
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert v.hp == 200 - 6 - 4
|
||||||
|
assert len(v.recurring_damage) == 1
|
||||||
|
# second turn start: final tick, then cleared
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert v.hp == 200 - 6 - 4 - 8
|
||||||
|
assert v.recurring_damage == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringReducedByEnergyResistance:
|
||||||
|
"""Given: a target with acid resistance 5 is burning from Acid Arrow
|
||||||
|
When: the recurring acid ticks
|
||||||
|
Then: each tick's 2d4 is reduced by the resistance (floored at 0)."""
|
||||||
|
|
||||||
|
def test_resistance_reduces_each_tick(self) -> None:
|
||||||
|
caster = _caster(level=1)
|
||||||
|
victim = _victim(hp_max=200, resistances={"acid": 5})
|
||||||
|
# attack (15) + initial 2d4 (4+4=8 -> -5 = 3) + tick 2d4 (3+3=6 -> -5 = 1)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 4, 4, 3, 3])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert v.hp == 200 - 3 # initial reduced 8 -> 3
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert v.hp == 200 - 3 - 1 # tick reduced 6 -> 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringNotReducedByDR:
|
||||||
|
"""Given: a target with damage reduction is burning from Acid Arrow
|
||||||
|
When: the recurring acid ticks
|
||||||
|
Then: DR does NOT reduce the spell's recurring damage (CRB: DR does not
|
||||||
|
negate energy damage from spells)."""
|
||||||
|
|
||||||
|
def test_dr_does_not_touch_recurring(self) -> None:
|
||||||
|
caster = _caster(level=1)
|
||||||
|
victim = _victim(hp_max=200, dr=DamageReduction(amount=10, bypass=("slashing",)))
|
||||||
|
# attack (15) + initial 2d4 (3+3=6) + tick 2d4 (5+5=10)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3, 5, 5])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert v.hp == 200 - 6
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
# DR 10 does not reduce the acid: full 10 dealt
|
||||||
|
assert v.hp == 200 - 6 - 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringImmunityZeroesTick:
|
||||||
|
"""Given: an acid-immune target is burning from Acid Arrow
|
||||||
|
When: the recurring acid ticks
|
||||||
|
Then: immunity zeroes the tick damage."""
|
||||||
|
|
||||||
|
def test_immunity_zeroes_tick(self) -> None:
|
||||||
|
caster = _caster(level=1)
|
||||||
|
victim = _victim(hp_max=200)
|
||||||
|
victim = victim.model_copy(update={"immunities": ("acid",)})
|
||||||
|
# attack (15) + initial 2d4 (3+3, immune -> 0) + tick (4+4, immune -> 0)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3, 4, 4])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert v.hp == 200
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert v.hp == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringSnapshot:
|
||||||
|
"""Given: a target burning from Acid Arrow
|
||||||
|
When: the observation snapshot is built
|
||||||
|
Then: recurring_damage lists each active source's formula, types and
|
||||||
|
rounds_left."""
|
||||||
|
|
||||||
|
def test_snapshot_exposes_recurring(self) -> None:
|
||||||
|
caster = _caster(level=3)
|
||||||
|
victim = _victim(hp_max=200)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
obs = engine._build_observation(v).to_dict()
|
||||||
|
entry = next(
|
||||||
|
cb for cb in obs["combatants"] if cb["id"] == "victim"
|
||||||
|
)
|
||||||
|
assert entry["recurring_damage"] == [
|
||||||
|
{"formula": "2d4", "types": ("acid",), "rounds_left": 2}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user