Compare commits

...

2 Commits

Author SHA1 Message Date
ctan 1a68dfa6db docs(rules): conformance journal rounds 3-4 (22 fix commits) and residual inventory
Round 3 (attack resolution, bestiary/PC stat blocks, action economy) and
round 4 (remaining spells, PC feats) found 2 High, ~11 Med and ~70 Low
findings. Records the 22 fix commits (e0bf484..0c3f5fd), both justified
golden updates (Weapon Focus, Divine Favor), and the residual inventory:
~13 deferred spell effects, 20 feat/racial mismatches, and the missing
subsystems (alignment, items, skills). Journal marked paused at round 4.
2026-09-17 23:28:32 +02:00
ctan 0c3f5fd907 fix(rules): condition durations scale with caster level (hold person, doom, touch of fatigue, vanish)
ConditionEffect gains duration_rounds_per_level and max_duration_rounds
(mirroring PenaltyEffect), applied as caster_level * per_level rounds capped
at max: hold person 1 rd/lvl, doom 1 min/lvl, touch of fatigue 1 rd/lvl,
vanish 1 rd/lvl (max 5). Extend Spell doubles both the per-level rate and
the cap. Tests cover scaling and the vanish cap.
2026-09-17 23:26:45 +02:00
9 changed files with 134 additions and 15 deletions
+1 -1
View File
@@ -1775,7 +1775,7 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 1491 tests uv run pytest -q # 1543 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+2 -1
View File
@@ -11,8 +11,9 @@
"type": "condition", "type": "condition",
"condition_name": "shaken", "condition_name": "shaken",
"duration_rounds": 10, "duration_rounds": 10,
"duration_rounds_per_level": 10,
"save_negates": true "save_negates": true
} }
], ],
"description": "SRD spells/doom.md: the subject is filled with horrible dread and becomes shaken (-2 on attacks and saves). Saving Throw Will negates - there is no partial success (the earlier '-1 on a successful save' wording was a D&D 3.5 rule that does not exist in PF1e). Duration 1 min/level approximated as 10 rounds." "description": "SRD spells/doom.md: the subject is filled with horrible dread and becomes shaken (-2 on attacks and saves). Saving Throw Will negates - there is no partial success (the earlier '-1 on a successful save' wording was a D&D 3.5 rule that does not exist in PF1e). Duration 1 min/level (10 rounds per level)."
} }
+3 -2
View File
@@ -10,9 +10,10 @@
{ {
"type": "condition", "type": "condition",
"condition_name": "paralyzed", "condition_name": "paralyzed",
"duration_rounds": 3, "duration_rounds": 1,
"duration_rounds_per_level": 1,
"save_negates": true "save_negates": true
} }
], ],
"description": "The subject becomes paralyzed and freezes in place. Will negates." "description": "The subject becomes paralyzed and freezes in place. Will negates. Duration 1 round/level."
} }
+3 -2
View File
@@ -10,9 +10,10 @@
{ {
"type": "condition", "type": "condition",
"condition_name": "fatigued", "condition_name": "fatigued",
"duration_rounds": 4, "duration_rounds": 1,
"duration_rounds_per_level": 1,
"save_negates": true "save_negates": true
} }
], ],
"description": "A cantrip: a melee touch attack channels negative energy, making the target fatigued for 1 round/level (modeled as 4 rounds at caster level 4; the effect ends with the duration rather than by long rest). No effect on an already fatigued creature or on creatures immune to fatigue. Fortitude negates." "description": "A cantrip: a melee touch attack channels negative energy, making the target fatigued for 1 round/level (the effect ends with the duration rather than by long rest). No effect on an already fatigued creature or on creatures immune to fatigue. Fortitude negates."
} }
+3 -1
View File
@@ -10,7 +10,9 @@
{ {
"type": "condition", "type": "condition",
"condition_name": "invisible", "condition_name": "invisible",
"duration_rounds": 5, "duration_rounds": 1,
"duration_rounds_per_level": 1,
"max_duration_rounds": 5,
"save_negates": false "save_negates": false
} }
], ],
+41 -3
View File
@@ -26,7 +26,7 @@ résiduel**.
| Contrôle | Résultat | | Contrôle | Résultat |
|---|---| |---|---|
| `uv run pytest -q` | 1521 tests verts | | `uv run pytest -q` | 1543 tests verts |
| `uv run ruff check src tests` (`select=ALL`) | conforme | | `uv run ruff check src tests` (`select=ALL`) | conforme |
| `uv run basedpyright src` (strict) | 0 erreur | | `uv run basedpyright src` (strict) | 0 erreur |
| Transcript doré depuis la passe 1 | inchangé | | Transcript doré depuis la passe 1 | inchangé |
@@ -57,7 +57,40 @@ résiduel**.
| `3a5948c` | CMD : plus de double comptage de Dextérité (Tiny, CMB pré-calculé) | | `3a5948c` | CMD : plus de double comptage de Dextérité (Tiny, CMB pré-calculé) |
| `e89ea42` | README/capacités : grapple (pin hors périmètre), `pinned`/`nauseated` sans attaque, Weapon Focus par groupe | | `e89ea42` | README/capacités : grapple (pin hors périmètre), `pinned`/`nauseated` sans attaque, Weapon Focus par groupe |
> Transcript doré : mis à jour une seule fois, par le correctif Weapon Focus (`4d45409`) — le +1 du don transforme le jet 15 en 16 contre CA 16, le gobelin meurt au round 4 (justification ligne à ligne dans le docstring du test). > Transcript doré : mis à jour **deux fois**, chaque fois par un correctif SRD
> justifié ligne à ligne dans le docstring du test — `4d45409` (Weapon Focus :
> le +1 transforme le jet 15 en 16 contre CA 16, le gobelin meurt au round 4)
> puis `bee224a` (Shield of Faith et Divine Favor portent enfin leurs vrais
> buffs : `_find_buff_spell` retient désormais Divine Favor, index 20, ce qui
> remonte la CA de tammara et réécrit son transcript).
### Correctifs des passes 3 et 4 (commits)
| Commit | Objet |
|---|---|
| `e0bf484` | Tir dans une mêlée : exemption mesurée **cible → allié le plus proche** (High) |
| `cc9fba3` | CA d'une cible inerte : −5 exact, plus de double comptage d'une Dextérité négative |
| `fa63829` | `trait` et `racial` non cumulables (APG « Traits », CRB) |
| `1c2663d` | Type non cumulable : meilleur bonus **et** pire pénalité conservés séparément |
| `ae037c9` | Dégâts minimum : 1 point sur une touche réduite sous 1 par la RD |
| `b3a7c37` | Types de bonus `alchemical`/`inherent`/`resistance`/`size` (UM Table 2-7) |
| `feaab5e` | `celestial_wolf` aligné sur le loup du SRD + template céleste (DR/RM) |
| `1c94d48` | `harvie` : Sagesse 14, Talons primaires +2 (1d4 ×2), Morsure secondaire |
| `b9477a2` | `ierlieth` : bonus de dégâts −1, Queue secondaire ajoutée |
| `2b5b39a` | Chevaux : sabots 1d4 marqués naturels, Morsure secondaire de Jeanne |
| `611952c` | `misty` : don « Plot Armor » ajouté en no-op tracé |
| `83bbcae` | Ray of Enfeeblement : type `PenaltyEffect` (pénalité de Force lancée, Fort moitié) au lieu de *fatigued* |
| `79d9ff0` | `drunkards_breath` : cône de 30 ft qui rend *nauseated* (niveau 2, conjuration, Fort annule) — l'ancien contenu était **inventé** (High) |
| `48c816b` | `delay_disease` : niveau 2 conjuration (soins) |
| `27f9629` | Données : `alarm` close, `air_bubble`/`endure_elements` touch, `delay_poison` conjuration, conventions 10 ft/0 ft documentées, `daze` 4 DV |
| `47f563e` | Shield of Faith : +2 CA déflexion (+1 / 6 niveaux, max +5) |
| `bee224a` | Divine Favor : +1 chance attaque/dégâts par 3 niveaux (max +3) — doré mis à jour |
| `6d96779` | Spear of Purity / Shard of Chaos : 1d8 / 2 niveaux (max 5d8), Volonté moitié |
| `a5bd3c5` | Défense totale : plus d'attaques d'opportunité |
| `e253b03` | Charge restreinte à 1× pour un accablé/invalide (et au round de surprise) |
| `980d53d` | Aide : expiration au début du tour de **l'aidant** |
| `a144423` | Défense totale et combat défensif non cumulables |
| `0c3f5fd` | Durées de condition scalées au niveau (hold person, doom, touch of fatigue, vanish) |
### Passe 1 — sorts de buff, mouvement/grille, morts-vivants, armes naturelles ### Passe 1 — sorts de buff, mouvement/grille, morts-vivants, armes naturelles
@@ -205,6 +238,9 @@ ne pas être confondues avec des bogues.
| **Réglages du liche non SRD** (pv 100 vs 111, Force, Intelligence, initiative, aura de terreur 30 ft/DD 22 vs 60 ft/DD 18) | Réglages d'équilibrage du scénario doré | Assumé : conservés pour ne pas casser la fiche dorée | | **Réglages du liche non SRD** (pv 100 vs 111, Force, Intelligence, initiative, aura de terreur 30 ft/DD 22 vs 60 ft/DD 18) | Réglages d'équilibrage du scénario doré | Assumé : conservés pour ne pas casser la fiche dorée |
| **Dés énergétiques non multipliés par un critique** (contact du liche) | Règle moteur globale (exceptions CRB « Critical Hits ») | Assumé : cohérent avec les autres attaques | | **Dés énergétiques non multipliés par un critique** (contact du liche) | Règle moteur globale (exceptions CRB « Critical Hits ») | Assumé : cohérent avec les autres attaques |
| **`selective_channeling` non modélisé** | Le canal d'énergie affecte toutes les créatures du type visé dans la zone | Assumé (documenté au README) | | **`selective_channeling` non modélisé** | Le canal d'énergie affecte toutes les créatures du type visé dans la zone | Assumé (documenté au README) |
| **Effets de sorts différés (passe 4)** | `mirror_image` (1d4+1 images, ~67-78 % d'échec au lieu du flou 20 %), `spiritual_weapon` (attaque flottante persistante), buffs utilitaires sans mécanique cible (`guidance`, `resistance`, `virtue`, `stabilize`, `owls_wisdom`, `saving_finale`), `acid_arrow` (cap 7 rounds), `ice_slick` (+1/niveau), `charm_person` (condition *charmed*), `bleed` (seuil ≤ −1 pv), `bane` (pénalité −1), `bears_endurance` (+2 pv/DV), `aid` (pv temporaires), `tears_to_wine` (portée 10 ft) | Écart connu, non corrigé dans cette passe (correctifs à effet de bord potentiel sur le choix de sort de `default_policy`) |
| **20 écarts de dons/raciales (passe 4)** | Effets faux : *Exalted of the Society*, *Fortified Drinker* (Med, ce dernier ne s'applique **jamais** — condition « alcohol » non supportée) ; *Born Damned*, *Half-Forgotten Secrets*, *Axe to Grind*, *Thoroughbred*, *Extremely Fashionable*, *Lonely*/*Bitter* (drawbacks inversés en bonus), *Larger Than Life*, *Friendly Face*, *Breeze-Kissed* ; effets manquants : *Ambitious*, *Focused Study*, *Scaled Skin* ; valeur fausse : *Maw or Claw* (1d4 vs 1d6) ; noms inventés : *Whispering Wind*, *Like the Wind*, *Fey-touched*, *Pingre* | La parité feuille ↔ JSON est parfaite dans les deux sens (aucun don manquant ou en trop, aucun no-op silencieux), mais le résolveur mappe mal ces noms ou accepte des noms non-SRD — non corrigé |
| **Morts-vivants / objets / alignement** | Le moteur n'a ni alignement (Shard of Chaos, Spear of Purity, Protection from Evil), ni piste d'objets (Steal, Sunder), ni sous-système de compétences (Perception opposée, Acrobaties) | Hors périmètre : mécaniques absentes du modèle |
--- ---
@@ -233,7 +269,9 @@ Constats explicitement contrôlés et **confirmés conformes** :
|---|---|---|---|---| |---|---|---|---|---|
| 1 | Sorts (B), Mouvement/grille (C), Morts-vivants (D), Armes naturelles (A) | `bg_9e9e22e6`, `bg_cda707bf`, `bg_ba80396a`, `bg_6a621d4c` | 1 High, 12 Med, ~20 Low | Corrigés + documentés (ci-dessus) | | 1 | Sorts (B), Mouvement/grille (C), Morts-vivants (D), Armes naturelles (A) | `bg_9e9e22e6`, `bg_cda707bf`, `bg_ba80396a`, `bg_6a621d4c` | 1 High, 12 Med, ~20 Low | Corrigés + documentés (ci-dessus) |
| 2 | Dons/capacités (E), Manœuvres (F), Conditions (G), Données de sorts (H) | `bg_5314f54c` (re-run), `bg_9fdc610e`, `bg_f5829c68`, `bg_b289533d` + extraction/vérification `bg_e1ccd2da`, `bg_4292da47`, `bg_14d5344a` | 4 High, ~18 Med, ~45 Low | Constats consignés (§ 1 passe 2) ; correctifs data/conditions/dons en cours | | 2 | Dons/capacités (E), Manœuvres (F), Conditions (G), Données de sorts (H) | `bg_5314f54c` (re-run), `bg_9fdc610e`, `bg_f5829c68`, `bg_b289533d` + extraction/vérification `bg_e1ccd2da`, `bg_4292da47`, `bg_14d5344a` | 4 High, ~18 Med, ~45 Low | Constats consignés (§ 1 passe 2) ; correctifs data/conditions/dons en cours |
| 3+ | Bestiaire/PJ (stat blocks), résolution d'attaque/défense, économie d'action, sorts restants (68/108 non vérifiés champ par champ) | — | à lancer | À faire | | 3 | Résolution d'attaque & défense, bestiaire/PJ (blocs de stats), économie d'action & tour, sorts restants | `bg_38d99eac`, `bg_8cdcb3e2`, `bg_73e3f878` | 1 High, ~10 Med, ~40 Low | 11 correctifs (`e0bf484`..`611952c`) + Ray of Enfeeblement (`83bbcae`) ; le reste documenté |
| 4 | Sorts restants (champ par champ), dons des 8 PJ vs feuilles Foundry | `bg_29c36b1a` | 1 High, 1 Med, ~30 Low (dont 20 dons) | 11 correctifs (`79d9ff0`..`0c3f5fd`) ; 20 écarts de dons + effets de sorts différés documentés |
| 5+ | ~60 sorts non encore vérifiés, sous-systèmes non couverts (invocation, métamagie/slots, concentration, observation, runner/métriques) | — | — | **En pause** (demande utilisateur) |
--- ---
+26 -5
View File
@@ -6518,7 +6518,10 @@ class CombatEngine:
spell_name=spell_name, 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,
caster_level=caster.combatant.level,
)
if isinstance(effect, HealEffect): if isinstance(effect, HealEffect):
return self._apply_heal_effect( return self._apply_heal_effect(
effect, target, caster_level=caster.combatant.level, effect, target, caster_level=caster.combatant.level,
@@ -6669,20 +6672,26 @@ class CombatEngine:
*, *,
saved: bool, saved: bool,
spell_name: str, spell_name: str,
caster_level: int = 0,
) -> str: ) -> str:
duration = effect.duration_rounds
if effect.duration_rounds_per_level > 0:
duration = max(1, caster_level * effect.duration_rounds_per_level)
if effect.max_duration_rounds > 0:
duration = min(duration, effect.max_duration_rounds)
if saved and effect.save_negates: if saved and effect.save_negates:
# PF1e partial save (SRD spells/fear.md): a successful save does # PF1e partial save (SRD spells/fear.md): a successful save does
# not always spare the target — it may impose a weaker condition # not always spare the target — it may impose a weaker condition
# (Fear: shaken for 1 round instead of panicked). # (Fear: shaken for 1 round instead of panicked).
if effect.save_partial is not None: if effect.save_partial is not None:
name, duration = effect.save_partial name, partial_duration = effect.save_partial
applied = self._impose_condition(target, name, duration, spell_name) applied = self._impose_condition(target, name, partial_duration, spell_name)
if not applied: if not applied:
return "" return ""
return f"{name} ({duration}r) (partial save)" return f"{name} ({partial_duration}r) (partial save)"
return f"{effect.condition_name} negated" return f"{effect.condition_name} negated"
return self._impose_condition( return self._impose_condition(
target, effect.condition_name, effect.duration_rounds, spell_name target, effect.condition_name, duration, spell_name
) )
def _impose_condition( def _impose_condition(
@@ -6998,6 +7007,16 @@ class CombatEngine:
return effect return effect
if isinstance(effect, ConditionEffect): if isinstance(effect, ConditionEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
per_level = (
effect.duration_rounds_per_level * 2
if has_extend
else effect.duration_rounds_per_level
)
max_rounds = (
effect.max_duration_rounds * 2
if has_extend
else effect.max_duration_rounds
)
partial = effect.save_partial partial = effect.save_partial
if partial is not None and has_extend: if partial is not None and has_extend:
partial = (partial[0], partial[1] * 2) partial = (partial[0], partial[1] * 2)
@@ -7006,6 +7025,8 @@ class CombatEngine:
duration_rounds=duration, duration_rounds=duration,
save_negates=effect.save_negates, save_negates=effect.save_negates,
save_partial=partial, save_partial=partial,
duration_rounds_per_level=per_level,
max_duration_rounds=max_rounds,
) )
if isinstance(effect, BuffEffect): if isinstance(effect, BuffEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
+21
View File
@@ -176,6 +176,15 @@ class DamageEffect:
@dataclass(frozen=True) @dataclass(frozen=True)
class ConditionEffect: class ConditionEffect:
"""Transient condition (PF1e: ray of enfeeblement, hold person, doom...).
``duration_rounds_per_level`` (mirroring ``PenaltyEffect``'s field of the
same name) makes the duration caster-level-scaled — SRD hold person "1
round/level", doom "1 min./level" — replacing the fixed
``duration_rounds`` when > 0; ``max_duration_rounds`` caps it (0 = no
cap, SRD vanish "maximum 5 rounds").
"""
condition_name: str condition_name: str
duration_rounds: int duration_rounds: int
save_negates: bool = True save_negates: bool = True
@@ -183,6 +192,8 @@ class ConditionEffect:
# is not spared outright but suffers a weaker condition instead. Encoded as # is not spared outright but suffers a weaker condition instead. Encoded as
# (condition_name, duration_rounds); None = a successful save fully negates. # (condition_name, duration_rounds); None = a successful save fully negates.
save_partial: tuple[str, int] | None = None save_partial: tuple[str, int] | None = None
duration_rounds_per_level: int = 0
max_duration_rounds: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -602,11 +613,21 @@ def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect: def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
partial_raw = effect.get("on_success") partial_raw = effect.get("on_success")
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw) save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
per_level = _as_int(effect.get("duration_rounds_per_level", 0))
if per_level < 0:
msg = f"duration_rounds_per_level must be >= 0, got {per_level}"
raise ValueError(msg)
max_rounds = _as_int(effect.get("max_duration_rounds", 0))
if max_rounds < 0:
msg = f"max_duration_rounds must be >= 0, got {max_rounds}"
raise ValueError(msg)
return ConditionEffect( return ConditionEffect(
condition_name=_as_str(effect["condition_name"]), condition_name=_as_str(effect["condition_name"]),
duration_rounds=_as_int(effect["duration_rounds"]), duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(effect.get("save_negates", True)), save_negates=bool(effect.get("save_negates", True)),
save_partial=save_partial, save_partial=save_partial,
duration_rounds_per_level=per_level,
max_duration_rounds=max_rounds,
) )
+34
View File
@@ -551,6 +551,40 @@ class TestHoldPerson:
assert any("negated" in line for line in engine._transcript) assert any("negated" in line for line in engine._transcript)
class TestConditionDurationScaling:
"""Given: a ConditionEffect with duration_rounds_per_level (SRD hold person
"1 round/level", doom "1 min./level", vanish "1 round/level (maximum 5)")
When: the spell is cast
Then: the condition lasts caster_level * per_level rounds, capped at max."""
def test_hold_person_scales_with_caster_level(self) -> None:
caster = _make_caster(level=5)
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Hold Person")
ac = next(ac for ac in t.active_conditions if ac.condition.name == "paralyzed")
assert ac.rounds_left == 5 # 1 round/level
def test_vanish_caps_at_maximum(self) -> None:
caster = _make_caster(level=8)
target = _make_target()
# Vanish is range touch: the target must be adjacent (5 ft).
engine, c, t = _make_engine(
caster, target, [2], target_pos=(1, 0), target_side="players"
)
engine._cast_spell(c, t, "Vanish")
ac = next(ac for ac in t.active_conditions if ac.condition.name == "invisible")
assert ac.rounds_left == 5 # 1 round/level, maximum 5
def test_doom_scales_to_minutes(self) -> None:
caster = _make_caster(level=5)
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Doom")
ac = next(ac for ac in t.active_conditions if ac.condition.name == "shaken")
assert ac.rounds_left == 50 # 1 min/level = 10 rounds/level
class TestFear: class TestFear:
"""Given: a caster casting Fear (SRD spells/fear.md: panicked unless the """Given: a caster casting Fear (SRD spells/fear.md: panicked unless the
Will save succeeds; a successful save makes the creature shaken 1 round) Will save succeeds; a successful save makes the creature shaken 1 round)