Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a144423522 | |||
| 980d53d80b | |||
| e253b03ffe | |||
| a5bd3c5a85 | |||
| 6d96779ded | |||
| bee224ab37 | |||
| 47f563e11a | |||
| 27f962920f | |||
| 48c816b303 | |||
| 79d9ff0c30 | |||
| 83bbcae952 | |||
| 611952cbf7 | |||
| 2b5b39a9f7 | |||
| b9477a2ba1 | |||
| 1c94d48803 | |||
| feaab5ee41 | |||
| b3a7c3787d | |||
| ae037c984f | |||
| 1c2663d21c | |||
| fa63829385 | |||
| cc9fba3b52 | |||
| e0bf484394 | |||
| e6f7b1988b | |||
| e89ea42168 | |||
| 3a5948c35c | |||
| 0cccea2fa1 | |||
| 06da519fc6 | |||
| 2b5920c85c | |||
| fdc9127483 | |||
| 350844fc68 | |||
| 2807f719ca | |||
| 4d45409941 | |||
| f31cc88124 | |||
| cf8a07311d | |||
| 68a03a34f6 | |||
| def8533197 | |||
| 6220edcfd7 | |||
| bfe0b767a9 | |||
| 317f91a07f | |||
| b03a61d8ab | |||
| 99e9203d8c | |||
| ea60b2e4b3 | |||
| cad35e2e13 | |||
| 3e19d2974e | |||
| 881f2ef9b2 | |||
| 9a16ca0388 | |||
| 437507ee2c | |||
| 7fc7a1d6ec | |||
| 2cc2ba1907 | |||
| e5691bdb6f | |||
| 59caf67909 | |||
| 5e3505dc8c | |||
| e43066e0ef | |||
| 312f9fd6b8 | |||
| 6c4bdc5d14 | |||
| 0c141414a9 | |||
| b84238792c | |||
| a2a80b8690 | |||
| b579cb4934 | |||
| 7fb7ec55dd | |||
| 6702efc652 | |||
| f5dc9da0c4 | |||
| 3cc0502228 | |||
| 7a8351e3b2 | |||
| 9768f5ae1d | |||
| c96b36d3f9 | |||
| a59ebee37a | |||
| 88ef9f39be | |||
| 642f449b9e | |||
| ee8c8f80ff | |||
| a6cc5aef36 | |||
| 4659fae9f6 | |||
| 734cfa4e55 | |||
| 5bf2850e2f | |||
| a989266747 | |||
| 25149c64ba | |||
| 317c68ecdb | |||
| a4487a85f9 | |||
| 7810d88488 | |||
| ec2088ab90 | |||
| 35c8d101b5 | |||
| d152d42af9 | |||
| 84bcf004c3 | |||
| 5b64fbee48 | |||
| b5b1bfd395 | |||
| 5c48579768 | |||
| b0fe553782 | |||
| 68699ad6c7 | |||
| f9c369380d | |||
| 910ed1dc7d | |||
| 8c7e7075ad | |||
| e3818fa6c9 | |||
| 8043dd393c | |||
| f9420c7b09 | |||
| 34be75c492 | |||
| a4f7f4e5de | |||
| a9b33e4055 | |||
| 068b49794e | |||
| 57a597babd | |||
| 5a848fd07c | |||
| e1c9e78b4e |
@@ -300,10 +300,17 @@ vers la fiche Foundry d'origine.
|
||||
|
||||
## Règles implémentées et limites de la Phase 0
|
||||
|
||||
> Le suivi détaillé des écarts avec le SRD — corrigés, hors périmètre et
|
||||
> résiduels assumés — est consigné dans
|
||||
> [`docs/conformance-srd.md`](docs/conformance-srd.md).
|
||||
|
||||
Règles modélisées :
|
||||
|
||||
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
|
||||
- Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
|
||||
- Critique : (dés physiques + bonus plat) × multiplicateur ; les dés
|
||||
énergétiques des spécificités d'arme (flaming, etc.) et les dés de précision
|
||||
ne sont PAS multipliés (exception CRB « Critical Hits ») ; le 20 naturel au
|
||||
jet de
|
||||
confirmation confirme automatiquement la critique (conformance P2 — le jet
|
||||
de confirmation est un jet d'attaque) ; la RD s'applique après
|
||||
multiplication, un type de contournement annule la RD — une RD à plusieurs
|
||||
@@ -330,7 +337,12 @@ Règles modélisées :
|
||||
puis **échoue automatiquement** son jet de stabilisation (aucun d20 jeté) et
|
||||
perd donc 1 pv de plus par tour en plus des dégâts continus (CRB « Stable
|
||||
Characters and Recovery ») — elle meurt ainsi plus vite qu'une créature
|
||||
mourante non saignante.
|
||||
mourante non saignante. Les morts-vivants et les constructeurs n'ont pas de
|
||||
score de Constitution et sont **détruits dès `hp ≤ 0`** — ni bande
|
||||
d'invalidité à 0 pv, ni bande d'agonie (SRD traits « Undead » /
|
||||
« Construct » : « undead do not risk death from massive damage, but are
|
||||
immediately destroyed when reduced to 0 hit points or fewer ») ; le seuil
|
||||
`hp ≤ -CON` ne s'applique qu'aux créatures vivantes.
|
||||
- Mort simplifiée des monstres (option `--simple-monster-death`, défaut
|
||||
désactivé) : quand l'option est présente dans la commande, un **monstre**
|
||||
meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre
|
||||
@@ -361,6 +373,26 @@ Règles modélisées :
|
||||
appliqué.
|
||||
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
|
||||
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
|
||||
- Round de surprise (SRD « Surprise ») : le champ booléen `aware` du JSON de
|
||||
combattant (défaut `true`) encode la conscience des adversaires au début du
|
||||
combat. Si certains combattants actifs sont `aware` et d'autres non, un
|
||||
round de surprise (round 0) précède le round 1 : « In initiative order
|
||||
(highest to lowest), combatants who started the battle aware of their
|
||||
opponents each take a standard or move action during the surprise round.
|
||||
You can also take free actions during the surprise round. » Le filtre
|
||||
`_surprise_filter` applique cette allowance : une seule action standard
|
||||
**ou** de mouvement (la première de la séquence gagne), les actions libres
|
||||
conservées, les actions swift et à round complet retirées
|
||||
(`downgraded:surprise`) ; un `full_attack` se dégrade en attaque standard
|
||||
unique (même convention que le filtre accablé) et `withdraw` survit comme
|
||||
retraite restreinte standard (SRD « Full-Round Actions »). « Combatants who
|
||||
are unaware at the start of battle don't get to act in the surprise round »
|
||||
— les non-`aware` sautent leur tour (journalisé). « If no one or everyone is
|
||||
surprised, no surprise round occurs » — avec le défaut `aware: true`, le
|
||||
comportement est bit-identique à l'absence de la règle. Simplification
|
||||
documentée : les combattants non avertis ne sont pas marqués `flat_footed`
|
||||
pendant le round de surprise (le moteur n'injecte jamais cette condition
|
||||
lui-même — voir « Actions immédiates »).
|
||||
- Économie d'action : un tour normal donne une action standard + une action de
|
||||
mouvement (ou une action à round complet), plus swift/free/immediate. La
|
||||
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
|
||||
@@ -390,15 +422,28 @@ Règles modélisées :
|
||||
`(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ;
|
||||
`(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` →
|
||||
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
|
||||
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
|
||||
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
|
||||
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
||||
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
|
||||
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
|
||||
voir « Temps d'incantation » plus bas ; standard par défaut) ;
|
||||
**mouvement** = `move`, `stand_up`,
|
||||
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||
`repair_weapon` ; **swift** = `swift`, `deed`,
|
||||
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
|
||||
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
|
||||
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
||||
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
|
||||
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
|
||||
voir « Temps d'incantation » plus bas ; standard par défaut) ; exception
|
||||
par action (conformance D5 — APG « Cackle ») : `hex` avec
|
||||
`hex_name="cackle"` occupe le compartiment **libre** (résolu par
|
||||
`_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ;
|
||||
**mouvement** = `move`, `stand_up`,
|
||||
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||
`repair_weapon` ; exception conformance L7 (SRD « Move Actions ») : « If
|
||||
you have a base attack bonus of +1 or higher, you may draw a weapon as a
|
||||
free action combined with a regular move » — quand la séquence du tour
|
||||
contient une action `move` ET que le combattant a un BAB ≥ +1,
|
||||
`retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw`
|
||||
résolu par `_effective_slot`) et n'entame pas le budget mouvement ;
|
||||
`repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ;
|
||||
exception S5a (Ultimate Combat, deed « Quick Clear ») : `deed` avec
|
||||
`deed_name="quick_clear"` occupe le compartiment **standard** (résolu par
|
||||
`_effective_slot`, le kind `deed` restant swift pour up_close_and_deadly) ;
|
||||
**swift** = `swift`, `deed`,
|
||||
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
|
||||
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
|
||||
@@ -429,24 +474,53 @@ Règles modélisées :
|
||||
courage et est aussi retiré quand le swift est consommé par une immédiate,
|
||||
via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste
|
||||
hors-tour et consomme le prochain swift) ; `full_round` → round complet ;
|
||||
`one_round` → **round complet par clamp** — approximation documentée : le
|
||||
moteur ne tient pas d'incantation en cours entre les tours, le sort
|
||||
d'1 round se résout au bout du tour courant au lieu du suivant ;
|
||||
`one_round` → **round complet** — SRD « Casting Time » : « A spell that
|
||||
takes 1 round to cast is a full-round action » ; l'emplacement est dépensé
|
||||
et les gardes de début d'incantation (AoO, concentration) résolus sur le
|
||||
tour courant, mais **l'effet est différé au début du tour suivant** du
|
||||
lanceur (voir « Incantation d'1 round » ci-dessous) ;
|
||||
`long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non
|
||||
planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme
|
||||
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
|
||||
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
|
||||
jamais un sort à temps long). Le moteur résout le code via sa registry
|
||||
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
|
||||
filtres purs sous forme de résolveur `CastTimeResolver`. Les 107 sorts
|
||||
filtres purs sous forme de résolveur `CastTimeResolver`. Les 108 sorts
|
||||
importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés,
|
||||
backfillés depuis le SRD) : 95 `standard`, 6 `one_round` (Enhance Water,
|
||||
backfillés depuis le SRD) : 96 `standard`, 6 `one_round` (Enhance Water,
|
||||
Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate`
|
||||
(Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation,
|
||||
Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans
|
||||
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
|
||||
d'incantation à un round complet sans don Quickened — la signature
|
||||
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
|
||||
- Incantation d'1 round (`cast_time: one_round`) : SRD « Casting Time » —
|
||||
« It comes into effect just before the beginning of your turn in the round
|
||||
after you began casting the spell. You then act normally after the spell is
|
||||
completed. » Sur le tour d'incantation, l'emplacement est dépensé, les
|
||||
gardes de début d'incantation jouent (AoO d'incantation + jet de
|
||||
concentration sur dégât, incantation défensive, agrippé), puis le sort est
|
||||
consigné comme incantation en cours (`PendingCast` sur l'état du lanceur :
|
||||
nom du sort, cible, point de centre, métamagie, niveau d'emplacement, pv au
|
||||
moment de l'incantation) et **rien n'est appliqué** — les décisions
|
||||
d'incantation (attaque de contact, RM, sauvegardes, effets) se prennent à la
|
||||
résolution (« You make all pertinent decisions about a spell [...] when the
|
||||
spell comes into effect »). Au début du tour suivant du lanceur, avant le
|
||||
portail de début de tour et avant l'appel de politique, l'incantation en
|
||||
cours se résout (journalisé `finishes casting X`), puis le lanceur agit
|
||||
normalement. Interruption : toute perte nette de pv entre l'incantation et
|
||||
la résolution impose le jet de concentration « blessé pendant l'incantation »
|
||||
(DD `10 + dégâts + niveau du sort`, **sans** le bonus Combat Casting — même
|
||||
helper `_check_concentration` que l'AoO d'incantation) ; un échec perd le
|
||||
sort (`spell fizzles`, emplacement brûlé). Un lanceur mis hors de combat
|
||||
avant sa résolution perd le sort (journalisé `lost (cannot act)`).
|
||||
Simplifications documentées : l'interruption est mesurée en **perte nette
|
||||
de pv à la résolution** et non par un crochet à chaque site de dégâts — un
|
||||
soin intermédiaire peut masquer des dégâts subis ; l'incantation en cours
|
||||
est exposée dans l'observation via la clé `pending_cast`
|
||||
(`{spell_name, target_id, slot_level}` ou `None`). L'incantation `long`
|
||||
(> 1 round) reste hors périmètre (jetée du budget, voir « Temps
|
||||
d'incantation »).
|
||||
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
|
||||
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
|
||||
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
|
||||
@@ -472,13 +546,35 @@ Règles modélisées :
|
||||
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
|
||||
ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ←
|
||||
dégâts subis par un allié du préparant infligés par un ennemi. Quand
|
||||
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
|
||||
de swift** (au plus une action préparée par événement, sans cascade), puis
|
||||
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
|
||||
remplace la précédente ; le reste du tour (action de mouvement) reste
|
||||
exécutable. Le snapshot expose l'état via la clé `readied`
|
||||
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
||||
(disponible via une politique personnalisée).
|
||||
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
|
||||
de swift** (au plus une action préparée par événement, sans cascade), puis
|
||||
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
|
||||
remplace la précédente ; le reste du tour (action de mouvement) reste
|
||||
exécutable. **S4b — l'action préparée change l'initiative** (SRD « Ready —
|
||||
Initiative Consequences of Readying ») : « Your initiative result becomes
|
||||
the count on which you took the readied action, and you act immediately
|
||||
ahead of the character whose action triggered your readied action » — quand
|
||||
la préparation se déclenche, `_check_readied` appelle `_ready_shift_initiative`
|
||||
qui réécrit la clé d'initiative du préparant dans `_initiative_keys` (même
|
||||
forme de tuple que `_execute_delay` : total du déclencheur ± le pas de 0,5,
|
||||
en conservant le modificateur d'initiative et l'index de liste du préparant),
|
||||
si bien que `_build_round_order` conserve la nouvelle position pour **tous
|
||||
les rounds suivants**. Le pas est **ajouté** (le préparant agit juste AVANT
|
||||
le déclencheur), à l'inverse du retard qui le **retranche** (agir après) —
|
||||
les deux intentions sont opposées. Déterministe : aucun dé consommé, l'ordre
|
||||
du round courant (déjà construit) n'est pas modifié, seuls les rounds
|
||||
suivants se réordonnent. Conformance L4 (SRD « Ready — Initiative
|
||||
Consequences of
|
||||
Readying ») : « If you come to your next action and have not yet performed
|
||||
your readied action, you don't get to take the readied action (though you
|
||||
can ready the same action again) » — la préparation non déclenchée
|
||||
**expire au début du tour suivant** du préparant (bloc de début de tour de
|
||||
`_take_turn`, journalisé `ready expired (trigger never occurred)`), avant
|
||||
l'appel de politique ; elle survit donc à la fin du round courant et peut
|
||||
se déclencher au round suivant tant que le tour du préparant n'est pas
|
||||
arrivé. Le snapshot expose l'état via la clé `readied`
|
||||
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
||||
(disponible via une politique personnalisée).
|
||||
- Défense totale (`Action(kind="total_defense")`, action standard) : le
|
||||
combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au
|
||||
début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé
|
||||
@@ -542,7 +638,7 @@ Règles modélisées :
|
||||
staggered/nauseated (ou swift consommé par une immédiate) →
|
||||
`downgraded:action_economy` / `downgraded:cast_time` /
|
||||
`downgraded:staggered` / `downgraded:nauseated` /
|
||||
`downgraded:immediate_swift` ;
|
||||
`downgraded:immediate_swift` / `downgraded:surprise` ;
|
||||
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
||||
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
||||
@@ -560,9 +656,16 @@ Règles modélisées :
|
||||
résout `count` balayages indépendants dans la même action ; les balayages
|
||||
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
|
||||
rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des
|
||||
attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1)
|
||||
ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque
|
||||
à outrance ne sont pas modélisées.
|
||||
attaques supplémentaires à -5/-10/-15 pour les armes manufacturées. Les
|
||||
armes naturelles (`natural: true` dans le JSON — morsures, griffes, sabots,
|
||||
talons…) ne gagnent **jamais** d'itératifs : une attaque à outrance avec une
|
||||
arme naturelle résout **un seul** balayage quel que soit le BAB (CRB
|
||||
« Natural Attacks » : « You do not receive additional natural attacks for a
|
||||
high base attack bonus »). Une attaque naturelle marquée `secondary: true`
|
||||
(les attaques secondaires du bestiaire) frappe à **BAB −5** ; le don
|
||||
Multiattack réduit cette pénalité à **−2** (CRB « Natural Attacks » /
|
||||
« Multiattack »). Plusieurs armes naturelles en une seule attaque à outrance
|
||||
ne sont pas modélisées.
|
||||
Choix de l'arme : `Action(kind="attack", weapon_id=...)` et
|
||||
`Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée
|
||||
(l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au
|
||||
@@ -574,8 +677,9 @@ Règles modélisées :
|
||||
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
|
||||
politique personnalisée).
|
||||
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
|
||||
l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge
|
||||
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature
|
||||
l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium
|
||||
5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un
|
||||
**disque** : la créature
|
||||
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
|
||||
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
|
||||
menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque
|
||||
@@ -586,6 +690,19 @@ Règles modélisées :
|
||||
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
|
||||
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
|
||||
allonge menace les cases adjacentes (disque de 5 ft).
|
||||
**S4c — allonge nulle des créatures Tiny et plus petites** (CRB « Big and
|
||||
Little Creatures in Combat » : « Tiny, Diminutive, and Fine Creatures [...]
|
||||
They must enter an opponent's square to attack in melee ») : l'allonge
|
||||
naturelle de Tiny/Diminutive/Fine vaut **0 ft** — une telle créature ne
|
||||
**menace aucune case** (aucune AoO) et ne peut pas frapper une case
|
||||
adjacente ; son attaque de mêlée n'est utilisable qu'à **distance 0**, la
|
||||
case qu'elle partage avec sa cible (l'abstraction « entrer dans la case de
|
||||
l'adversaire »). L'allonge effective reste `max(allonge naturelle,
|
||||
reach_ft de l'arme)` : une attaque naturelle dont le JSON déclare
|
||||
explicitement `reach_ft` (ierlieth : morsure `reach_ft: 5`) conserve cette
|
||||
allonge — le `reach_ft` d'une attaque naturelle EST son allonge naturelle,
|
||||
une créature Tiny peut donc être encodée avec une allonge non nulle si sa
|
||||
fiche l'exige.
|
||||
Mouvement : un déplacement parcourt jusqu'à la
|
||||
vitesse du combattant en cases le long du plus court chemin réel (champ de
|
||||
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
|
||||
@@ -593,10 +710,22 @@ Règles modélisées :
|
||||
d'osciller contre eux. Conformance L5 (SRD « Movement, Position and
|
||||
Distance ») : « You can move through a square occupied by a friendly
|
||||
character, unless you are charging » — `move` et `run` **traversent** les
|
||||
cases des alliés (les ennemis bloquent toujours le chemin), mais ne
|
||||
peuvent jamais **finir** sur une case occupée : entrer sur une case alliée
|
||||
cases des alliés, mais ne peuvent jamais **finir** sur une case occupée :
|
||||
entrer sur une case alliée
|
||||
exige un prolongement libre (règle du « pass-through »), et la queue du
|
||||
chemin est tronquée si le budget s'épuise sur une case alliée. La charge
|
||||
chemin est tronquée si le budget s'épuise sur une case alliée. SRD
|
||||
« Moving Through a Square » : « Any creature can move through a
|
||||
square occupied by a creature three size categories larger than itself
|
||||
[...] Creatures moving through squares occupied by other creatures
|
||||
provoke attacks of opportunity from those creatures » — un ennemi séparé
|
||||
du déplaceur par **trois catégories de taille ou plus** est traversé de
|
||||
la même façon (jamais fini dessus : « You can't end your movement in the
|
||||
same square as another creature unless it is helpless »), et l'entrée dans
|
||||
sa case **provoque une AoO de l'occupant** (une seule par entrée fraîche,
|
||||
plafonnée par l'allowance du round ; résolu par `_check_enter_aoo` dans la
|
||||
marche pas à pas). Un ennemi à 0–2 catégories
|
||||
reste un blocage total (« You can't move through a square occupied by an
|
||||
opponent unless the opponent is helpless »). La charge
|
||||
reste bloquée par les créatures (ligne droite dégagée), comme la retraite
|
||||
(limite documentée : le passage à travers les alliés n'est pas modélisé
|
||||
pour `withdraw` et le pas de placement auto-dirigé).
|
||||
@@ -628,6 +757,21 @@ Règles modélisées :
|
||||
manœuvres et invocation sont généralisés aux cellules occupées (distance =
|
||||
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
|
||||
reste bit-identique.
|
||||
**S4c — co-occupation des créatures Tiny et plus petites** (CRB « Big and
|
||||
Little Creatures in Combat » : « Very small creatures take up less than 1
|
||||
square of space. This means that more than one such creature can fit into a
|
||||
single square ») : une créature Tiny/Diminutive/Fine peut **traverser et
|
||||
finir son déplacement** dans une case occupée par une créature plus grande,
|
||||
et une créature plus grande peut traverser et finir dans une case occupée
|
||||
par une créature Tiny ou plus petite — la case n'est pas « bloquée » pour
|
||||
ce couple de tailles (`_can_cooccupy` : tailles différentes ET la plus
|
||||
petite est Tiny ou moins). Deux créatures de même taille (ou Small et plus
|
||||
grande) ne partagent jamais une case : le blocage reste total pour elles.
|
||||
La co-occupation s'applique au mouvement (`_blocked`/`_blocked_hostile`
|
||||
sont conscients du déplaceur), à la destination explicite (`_cells_free`)
|
||||
et à la charge ; deux créatures partageant une case sont à **distance 0**
|
||||
l'une de l'autre (ligne d'effet directe, attaque de mêlée possible — c'est
|
||||
la voie d'attaque des créatures à allonge nulle, voir « Mêlée »).
|
||||
- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et
|
||||
`attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées
|
||||
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
|
||||
@@ -654,12 +798,18 @@ Règles modélisées :
|
||||
posé par le moteur sur l'acteur du tour) : tout swift encore présent dans
|
||||
la séquence est retiré (`downgraded:immediate_swift`) et aucune seconde
|
||||
immédiate ne part ce round ; le swift du tour **suivant** reste disponible
|
||||
(contrairement à l'immédiate hors-tour). Simplification documentée :
|
||||
l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour
|
||||
suivant, le moteur la borne au round en cours.
|
||||
Les actions préparées (`ready`) sont traitées sur ces mêmes
|
||||
événements **avant** et indépendamment de la politique de réaction (voir
|
||||
« Préparation » plus haut).
|
||||
(contrairement à l'immédiate hors-tour). Simplification documentée :
|
||||
l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour
|
||||
suivant, le moteur la borne au round en cours. Conformance L5 (SRD
|
||||
« Immediate Actions ») : « You also cannot use an immediate action if you
|
||||
are flat-footed » — un combattant portant la condition `flat_footed` est
|
||||
**sauté** par la porte de réaction (`_check_reactions`) : aucune immédiate
|
||||
ne part tant qu'il est à plat (la condition est injectable via
|
||||
`active_conditions` ; le moteur ne la pose jamais lui-même — voie
|
||||
d'entrée future).
|
||||
Les actions préparées (`ready`) sont traitées sur ces mêmes
|
||||
événements **avant** et indépendamment de la politique de réaction (voir
|
||||
« Préparation » plus haut).
|
||||
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
||||
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||||
visée.
|
||||
@@ -667,17 +817,26 @@ Règles modélisées :
|
||||
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
|
||||
confirmation de critique ; couvert mou : une créature active entre
|
||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
|
||||
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
|
||||
une créature multi-cases (Large +) ne subit que le couvert de SA meilleure
|
||||
case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
|
||||
toute ligne vers tout coin des cases de la cible compte (maximum sur les
|
||||
coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
|
||||
quelle case occupée par une créature multi-cases pour déterminer le couvert
|
||||
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee
|
||||
attack against such a creature, you can pick any of the squares it occupies
|
||||
to determine if it has cover against you ») — le score est donc le minimum
|
||||
sur les cases de la cible en mêlée, le maximum à distance.
|
||||
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
|
||||
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
|
||||
conformance L9 (`los.py`, `cover_bonus_cells`) : à distance, le score de
|
||||
chaque case attaquante est le **minimum sur ses quatre coins** de (maximum
|
||||
sur les lignes vers les coins de la cible) — « choose a corner of your
|
||||
square » — puis le minimum sur les cases de l'attaquant ; un créneau
|
||||
n'est donc jamais compté contre l'attaquant si l'un de ses coins l'évite
|
||||
(le bonus reste cohérent avec la porte `has_cover_cells`, qui teste déjà
|
||||
« tout coin de l'attaquant »). Une créature multi-cases (Large +)
|
||||
ne subit que le couvert de SA meilleure
|
||||
case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
|
||||
toute ligne vers tout coin des cases de la cible compte (maximum sur les
|
||||
coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
|
||||
quelle case occupée par une créature multi-cases pour déterminer le couvert
|
||||
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee
|
||||
attack against such a creature, you can pick any of the squares it occupies
|
||||
to determine if it has cover against you ») — le score est donc le minimum
|
||||
sur les cases de la cible en mêlée, le maximum à distance. En mêlée, la
|
||||
règle du pire coin attaquant est conservée (« any line from ANY corner of
|
||||
your square »).
|
||||
- Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
|
||||
« Touch AC ») si un allié actif menace réellement la cible depuis la
|
||||
bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
|
||||
@@ -715,32 +874,49 @@ Règles modélisées :
|
||||
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
|
||||
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
|
||||
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
|
||||
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
|
||||
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
|
||||
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
|
||||
considérée comme menacée — aucune AoO en la quittant. Les cases
|
||||
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
|
||||
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
|
||||
une politique personnalisée).
|
||||
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
|
||||
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
|
||||
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
|
||||
considérée comme menacée — aucune AoO en la quittant. Les cases
|
||||
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
|
||||
le mouvement). Conformance L6 (SRD « Full-Round Actions — Restricted
|
||||
Withdraw ») : « If you are limited to taking only a standard action each
|
||||
round you can withdraw as a standard action. In this case, you may move up
|
||||
to your speed » — un combattant `staggered`/invalidé qui reçoit `withdraw`
|
||||
le conserve comme **action standard** (le filtre staggered ne le jette
|
||||
plus) avec un budget de **1× vitesse** au lieu de 2× (`_flee_path` via
|
||||
`_is_staggered_limited`). `default_policy` ne choisit pas la retraite (disponible via
|
||||
une politique personnalisée).
|
||||
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
|
||||
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
|
||||
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
|
||||
réutilisant exactement la mécanique de `move` (descente du champ de coût
|
||||
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
|
||||
AoO), **sauf que la course ne traverse pas le terrain difficile**
|
||||
(conformance R2 — CRB « Movement, Position and Distance — Difficult
|
||||
Terrain » : « You can't run or charge across difficult terrain ») : la
|
||||
descente refuse tout pas dont le footprint toucherait une case de coût > 1
|
||||
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
|
||||
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
|
||||
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que
|
||||
le rejet de l'action). À l'achèvement, le combattant est `running` jusqu'au
|
||||
début de son prochain tour : il **perd son bonus de Dextérité à la CA**
|
||||
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les
|
||||
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
|
||||
(`unreachable_destination`) ; sans cible ni destination, rejet
|
||||
(`missing_target`). `default_policy` ne choisit pas la course (disponible
|
||||
via une politique personnalisée).
|
||||
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c).
|
||||
**S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » :
|
||||
« When you run, you can move up to four times your speed **in a straight
|
||||
line** ») : contrairement à `move` (descente Dijkstra qui contourne les
|
||||
obstacles), la course suit la **ligne de Bresenham** entre la position
|
||||
courante et la cible/destination, et **s'arrête au premier obstacle** —
|
||||
terrain infranchissable, terrain difficile, ou créature bloquante — en
|
||||
miroir de la logique de ligne dégagée de la charge (`_run_line_path`,
|
||||
`_charge_path_clear`). Le déplacement reste pas à pas avec **provocation
|
||||
d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le
|
||||
drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements.
|
||||
Comme pour `move`, la course **traverse les cases des alliés** sans jamais
|
||||
y **finir** (SRD « Movement », conformance L5 — la queue du chemin est
|
||||
tronquée) ; une créature hostile (la cible incluse) arrête la course une
|
||||
case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**.
|
||||
La course **ne traverse pas le terrain difficile** (conformance R2 — CRB
|
||||
« Difficult Terrain » : « You can't run or charge across difficult
|
||||
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
|
||||
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
|
||||
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
|
||||
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
|
||||
`destination` que la ligne droite ne peut pas honorer dans le budget 4×
|
||||
(hors portée, bloquée par un obstacle/une créature, ou joignable uniquement
|
||||
en tournant un coin ou en traversant le terrain difficile) est **rejetée**
|
||||
(`unreachable_destination`) — le verdict et le chemin parcouru sont
|
||||
cohérents (pas de contournement silencieux) ; sans cible ni destination,
|
||||
rejet (`missing_target`). `default_policy` ne choisit pas la course
|
||||
(disponible via une politique personnalisée).
|
||||
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
|
||||
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
|
||||
avec tout autre mouvement (move, charge, retraite) dans le même tour —
|
||||
@@ -834,7 +1010,19 @@ Règles modélisées :
|
||||
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
|
||||
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
|
||||
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
|
||||
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
|
||||
ne sont pas modélisés. Conformance L3 (CRB « Cast a Spell — Touch Attacks »,
|
||||
« Cover », « Concealment », « Range — Touch ») : le jet de contact (mêlée ou
|
||||
distance) est une **attaque** — la CA de contact inclut le **couvert** de la
|
||||
cible (terrain +2/+4/+8 et couvert mou +4 à distance, même chemin
|
||||
`_attack_cover_bonus` que `resolve_attack` ; mêlée = ligne terrain seule), la
|
||||
**dissimulation** peut faire rater l'attaque après la touche (jet de d100,
|
||||
convention `max(concealment de la cible, 50)` si l'attaquant est `blinded`,
|
||||
journalisé « spell lost » / « spell delivered »), et un sort de contact qui
|
||||
**inflige des dégâts** menace le critique sur un 20 naturel et **double ses
|
||||
dégâts** sur confirmation réussie (le jet de confirmation est un jet
|
||||
d'attaque contre la même CA ; un sort de contact sans dégâts — Grace,
|
||||
Ray of Enfeeblement — ne menace pas, aucun dé consommé). Magic Missile
|
||||
(touche automatiquement), les sorts de
|
||||
zone et les sorts à sauvegarde simple ne sont pas concernés. Un sort ciblé avec sauvegarde fait pleins dégâts
|
||||
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
||||
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
||||
@@ -852,12 +1040,90 @@ Règles modélisées :
|
||||
centré sur la créature est inchangé. `default_policy` ne choisit pas de point
|
||||
(disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via
|
||||
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
|
||||
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
|
||||
`AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic —
|
||||
Aiming a Spell »). Le **burst** reste un cercle centré sur la case résolue
|
||||
(cible ou `target_pos`, U7b). Le **cône** (90° — « a quarter-circle », la
|
||||
largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large,
|
||||
longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés
|
||||
vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le
|
||||
cône exclut la case d'origine, la ligne part d'un **coin** de la case du
|
||||
jeteur et n'affecte **pas** sa propre case (CRB « Aiming a Spell » : « It
|
||||
starts from any corner of your square » — SRD Lightning Bolt : « The bolt
|
||||
begins at your fingertips », le trait quitte la case du jeteur au lieu de la
|
||||
remplir ; un viseur dégénéré origine==cible n'affecte personne). Toute
|
||||
créature dont une case occupée intersecte
|
||||
le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un
|
||||
burst (application partagée, géométrie dans `areas.py`). Le snapshot
|
||||
d'observation expose `{shape, radius_ft}` via la clé `area`.
|
||||
Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1,
|
||||
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
|
||||
level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) »,
|
||||
Shocking Grasp « 1d6 per level (maximum 5d6) ») et/ou
|
||||
`"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}` (bonus plat par
|
||||
niveau — Inflict Light Wounds « 1d8 + 1 per caster level (maximum +5) ») ;
|
||||
à l'incantation, `effective_formula` résout dés additionnels =
|
||||
`min(max_dice, niveau // per_levels)` et bonus plat =
|
||||
`min(max, (niveau // per_levels) × amount)` avec la formule de base — le
|
||||
total scalé suit ensuite le critique, la résistance énergétique et la
|
||||
métamagie comme n'importe quelle formule (le chargeur refuse un dé de
|
||||
scalage de type différent du dé de base). Absence de scalage = comportement
|
||||
inchangé. Le scalage s'applique aussi aux soins (S5b) : un `HealEffect`
|
||||
porte le même `scaling_flat` (bonus plat par niveau) résolu par
|
||||
`effective_formula(niveau)` à l'incantation — Cure Light Wounds
|
||||
« 1d8 + 1 per caster level (maximum +5) » et Cure Moderate Wounds
|
||||
« 2d8 + 1 per caster level (maximum +10) » (SRD spells/cure-light-wounds.md,
|
||||
spells/cure-moderate-wounds.md) sont désormais scalés au lieu d'épinger
|
||||
une formule figée.
|
||||
- Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer
|
||||
`"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le
|
||||
nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels`
|
||||
(delta de niveau planché à 0), plafonné à `max` (SRD Magic Missile : « one
|
||||
missile, plus one additional missile per two caster levels beyond 1st [...]
|
||||
maximum of five » → 1 au niveau 1, 2 au niveau 4, 5 au niveau 9 ; Scorching
|
||||
Ray : `per_levels` 4, `at_level` 3, `max` 3 → 1/2/3 rayons aux niveaux
|
||||
1/7/11). À l'incantation, chaque projectile **jetle ses propres dégâts** et,
|
||||
si le sort exige une attaque de contact à distance (`requires_ranged_touch`,
|
||||
Scorching Ray), **jetle sa propre attaque de contact à distance** (couvert,
|
||||
dissimulation et menace de critique incluss — un rayon qui rate ne inflige
|
||||
rien, les autres partent quand même) ; les dégâts de sort ne sont pas réduits
|
||||
par la RD, et la résistance énergétique s'applique à chaque projectile. La RS
|
||||
est opposée une fois par cible (le moteur ne répartit pas les projectiles sur
|
||||
plusieurs cibles — toutes touchent la cible résolue). Si la cible tombe en
|
||||
cours de séquence (pv < 0 ou morte), les projectiles restants sont **perdus**
|
||||
(même convention que les attaques multiples d'une arme `count` > 1). Magic
|
||||
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)
|
||||
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
|
||||
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
|
||||
résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à
|
||||
zone, la SR est résolue par créature touchée (pas seulement sur la cible
|
||||
primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
|
||||
primaire). **Drapeau `spell_resistance` par sort (S2e)** : `SpellSpec` porte un
|
||||
booléen `spell_resistance` (JSON `"spell_resistance"`, défaut `true`). Quand un
|
||||
sort le déclare `false` (les sorts dont la fiche SRD indique « Spell Resistance:
|
||||
no » — Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, les
|
||||
`Protection from …`, etc. ; 28 sorts importés vérifiés contre le KB), la
|
||||
vérification de SR est **totalement sautée**, tanto sur la cible directe que par
|
||||
créature dans une zone. Les 28 sorts SRD « RM : non » portent désormais
|
||||
`"spell_resistance": false` ; les 79 autres gardent le défaut `true`. Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
|
||||
annulés par l'immunité (`immunities`) ou réduits par la résistance du type
|
||||
correspondant, **composante par composante** (conformance B21, CRB « Energy
|
||||
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
|
||||
@@ -883,15 +1149,21 @@ Règles modélisées :
|
||||
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 **total** (`miss_chance ≥ 50 %`) ne peut pas être la
|
||||
victime d'une attaque sournoise ; une dissimulation partielle (flou 20 %) ne
|
||||
bloque plus la sournoise (conformance A7 — CRB rogue « Sneak Attack » :
|
||||
« cannot sneak attack while striking a creature with total concealment »).
|
||||
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
|
||||
adversaires comme ayant un concealment total — le même jet unique de d100 est
|
||||
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
|
||||
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
|
||||
concealment.
|
||||
cible avec **n'importe quelle dissimulation** (`miss_chance > 0` — flou 20 %
|
||||
comme dissimulation totale 50 %) ne peut pas être la victime d'une attaque
|
||||
sournoise (conformance A7 — SRD rogue « Sneak Attack » :
|
||||
« A rogue cannot sneak attack while striking a creature with concealment »).
|
||||
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
|
||||
adversaires comme ayant un concealment total — le même jet unique de d100 est
|
||||
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
|
||||
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
|
||||
concealment. Conformance L2 (CRB « Conditions — Blinded » : « All opponents
|
||||
are considered to have total concealment (50% miss chance) against the
|
||||
blinded character ») : la même convention `max(cible, 50)` s'applique aux
|
||||
deux autres portes de concealment de l'attaquant — l'attaque sournoise est
|
||||
**impossible** pour un aveugle (dissimulation totale) et ses attaques
|
||||
d'opportunité sont **supprimées** contre toute cible (`suppressed (total
|
||||
concealment)`, budget d'AoO non consommé).
|
||||
- Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses
|
||||
jets d'attaque et de confirmation de critique** et sa cible **perd son bonus
|
||||
de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course).
|
||||
@@ -917,8 +1189,9 @@ Règles modélisées :
|
||||
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 à dissimulation **totale** (`miss_chance ≥ 50 %` ; conformance A7 — une
|
||||
dissimulation partielle comme le flou 20 % n'empêche plus la sournoise).
|
||||
cible qui a une dissimulation, même partielle (`miss_chance > 0` ;
|
||||
conformance A7 — SRD rogue : « A rogue cannot sneak attack while striking
|
||||
a creature with concealment »).
|
||||
Conformance P2 : les morts-vivants (`undead`) et les
|
||||
constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible
|
||||
ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW
|
||||
@@ -927,8 +1200,12 @@ Règles modélisées :
|
||||
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é.
|
||||
cases) ; la pénalité est **totalement évitée (0)** si la cible ou l'adversaire
|
||||
menaçant est à ≥ 10 ft (2 cases) de l'attaquant (SRD « Attack — Shooting or
|
||||
Throwing into a Melee » : « you can avoid the −4 penalty »). Simplification
|
||||
documentée : la réduction RAW à −2 quand la cible est de deux catégories de
|
||||
taille au-dessus des personnages amis (et 0 à trois ou plus) n'est pas
|
||||
modélisée. Le don « Precise Shot » annule la pénalité.
|
||||
Les attaques de mêlée ne sont pas concernées.
|
||||
- Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le
|
||||
terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
|
||||
@@ -941,7 +1218,7 @@ Règles modélisées :
|
||||
(`_expire_summons` décrémente puis retire).
|
||||
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
|
||||
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
|
||||
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
|
||||
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux, épinglé) n'autorise qu'une unique action
|
||||
de mouvement — conformance L9 (SRD « Conditions — Nauseated ») : « The only
|
||||
action such a character can take is a single move actions per turn » — seuls
|
||||
les vraies actions de mouvement (`move`, `stand_up`, `retrieve_weapon`,
|
||||
@@ -965,7 +1242,11 @@ Règles modélisées :
|
||||
désormais réellement aux compétences de Dexterity (Acrobaties, Escalade,
|
||||
Équilibre, Évasion, Vol, Équitation, Manipulation de corde, Discrétion,
|
||||
Natation, Désamorçage — conformance P2) —, −2 attaque/CA/Réflexes, sans
|
||||
déplacement ni AoO. Épinglé : même −4 `dex_skills`.
|
||||
déplacement ni AoO. Épinglé : même −4 `dex_skills`, et `cant_attack` — un
|
||||
épinglé ne peut pas attaquer (SRD « Conditions — Pinned » : il « peut
|
||||
seulement tenter de se libérer ») ; lacune documentée : le moteur ne pose
|
||||
**jamais** `pinned` (pas de boucle de maintien du grapple), la condition
|
||||
n'est atteignable que par injection directe.
|
||||
Aveuglé (conformance P2) : −2 CA, perte du bonus de Dextérité à la CA, −4
|
||||
aux compétences de Dexterity (`dex_skills`) et **aucun malus plat
|
||||
d'attaque** — la pénalité RAW est la dissimulation totale (50 % d'échec,
|
||||
@@ -980,7 +1261,11 @@ Règles modélisées :
|
||||
Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage`
|
||||
points de dégâts au **début de son tour** (avant politique et flags) ; tout
|
||||
effet restaurant des points de vie (sort de soin, channel energy) stoppe le
|
||||
saignement (`bleed_damage = 0`) et retire la condition. Simplification
|
||||
saignement (`bleed_damage = 0`) et retire la condition ; l'expiration de la
|
||||
dernière condition `bleeding` inflige son **dernier tick** puis remet
|
||||
`bleed_damage = 0` (conformance D2 — SRD « Bleed » : le cantrip, sauvegarde
|
||||
de **Volonté** annule, pose la condition `bleeding` avec `bleed_damage = 1`,
|
||||
donc 1 point de dégâts par round). Simplification
|
||||
documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur
|
||||
n'a pas d'action Soin).
|
||||
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
|
||||
@@ -990,7 +1275,9 @@ Règles modélisées :
|
||||
ennemi, une seule si en allonge ; sinon le tour est perdu, journalisé).
|
||||
Simplification documentée : la clause « si elle est blessée, riposte
|
||||
l'agresseur » est réduite à « attaque la créature la plus proche » ; le coup
|
||||
de grâce n'est pas modélisé.
|
||||
de grâce n'est pas modélisé. Une créature confuse ne fait **aucune**
|
||||
attaque d'opportunité (`cant_aoo`) — simplification de la clause SRD « not
|
||||
already devoted to attacking », le moteur n'ayant pas ce concept.
|
||||
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
|
||||
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
|
||||
comme une tentative unique (1d20 + CMB contre CMD) : le jet de manœuvre est
|
||||
@@ -1011,31 +1298,51 @@ Règles modélisées :
|
||||
pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance
|
||||
L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller
|
||||
use their Dexterity modifier in place of their Strength modifier to
|
||||
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
|
||||
le modificateur de Dextérité à la place de celui de Force ; lacune
|
||||
documentée : l'allonge nulle et la co-occupation des créatures Tiny et
|
||||
plus petites ne sont pas modélisées (abstraction à la grille de 5 ft).
|
||||
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
|
||||
le modificateur de Dextérité à la place de celui de Force. Leur allonge
|
||||
naturelle nulle (aucune case menacée, attaque dans leur propre case) et la
|
||||
co-occupation Tiny-or-smaller avec une créature strictement plus grande
|
||||
sont modélisées (conformance S4c) ; une attaque naturelle dotée d'un
|
||||
`reach_ft` explicite (ex. morsure d'ierlieth) conserve cette allonge.
|
||||
Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute
|
||||
les bonus de CA des huit types autorisés (« A creature can also add any
|
||||
circumstance, deflection, dodge, insight, luck, morale, profane, and
|
||||
sacred bonuses to AC to its CMD » — bonus issus des buffs/conditions/
|
||||
dons, défense totale et combat défensif inclus) et une créature au
|
||||
dons, défense totale et combat défensif inclus), **toutes les pénalités de CA
|
||||
(« Any penalties to a creature's AC also apply to its CMD » — pénalités sans
|
||||
type comme grapplé −2 ou à plat ventre −4 contre la mêlée, la manœuvre
|
||||
étant un jet d'attaque de mêlée)** et une créature au
|
||||
contact pris de dos ne ajoute pas son bonus de Dextérité (« A flat-footed
|
||||
creature does not add its Dexterity bonus to its CMD » — la pénalité de
|
||||
Dextérité, qui n'est pas un bonus, reste appliquée).
|
||||
(SRD « Trip » / « Bull Rush » / « Overrun ») : « You can only [affect] an
|
||||
opponent who is no more than one size category larger than you » — conformance
|
||||
L8 : trip, bull_rush et overrun contre une cible de **deux catégories de
|
||||
taille ou plus au-dessus** de l'attaquant sont illégaux (tentative refusée,
|
||||
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
|
||||
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
|
||||
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
|
||||
(SRD « Trip » / « Bull Rush » / « Overrun » ; APG « Reposition » / « Drag ») :
|
||||
« You can only [affect/reposition/drag] an opponent [who is] no more than
|
||||
one size category larger than you » — conformance
|
||||
L8 : trip, bull_rush, overrun, reposition et drag contre une cible de
|
||||
**deux catégories de taille ou plus au-dessus** de l'attaquant sont illégaux
|
||||
(tentative refusée,
|
||||
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
|
||||
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
|
||||
trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » :
|
||||
« your target drops one item it is carrying » — `weapon_dropped`, exclue
|
||||
de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou
|
||||
plus fait tomber **l'arme de l'attaquant**) ; bull_rush → pousse la
|
||||
cible (direction sélectionnable via `Action.direction`) ; conformance L6
|
||||
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
|
||||
your attack exceeds your opponent's CMD you can push the target back an
|
||||
additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle
|
||||
(un 20 naturel sous le CMD ne donne que la poussée de base) ;
|
||||
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
||||
grapple → `grappled` uniquement (la boucle de maintien du grapple et
|
||||
l'application de `pinned` sont hors périmètre du moteur — la définition
|
||||
de la condition `pinned` est conforme au SRD, mais elle n'est jamais
|
||||
posée par le moteur lui-même) ; conformance L8 (SRD « Grapple ») : « If
|
||||
you successfully grapple a creature that is not adjacent to you, move
|
||||
that creature to an adjacent open space (if no space is available, your
|
||||
grapple fails) » — une réussite à distance **tire la cible** sur la case
|
||||
libre adjacente la plus proche (distance 5-10-5, départage (ligne,
|
||||
colonne) ; journalisé `grapple pulls`), et **échoue** sans poser la
|
||||
condition si aucune case adjacente libre n'existe (`FAILED (no adjacent
|
||||
space)`) ; escape → **une** sauvegarde (bonus statique de la
|
||||
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
||||
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
|
||||
naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
|
||||
@@ -1056,26 +1363,61 @@ Règles modélisées :
|
||||
la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ;
|
||||
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
|
||||
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick →
|
||||
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
|
||||
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de
|
||||
mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
|
||||
`Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la
|
||||
cible vers le premier voisin libre (direction sélectionnable via
|
||||
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant
|
||||
occupant sa case d'origine. `default_policy` ne
|
||||
condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée
|
||||
= 1 round + 1 round par 5 points dont l'attaque dépasse le CMD — APG
|
||||
« Dirty Trick » ;
|
||||
`blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles
|
||||
(liste exacte de l'APG), `nauseated` exclu car hors de
|
||||
la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le modèle ne porte
|
||||
aucun objet transporté non tenu : en application de l'APG « Steal » (« Items
|
||||
held in the hands (such as wielded weapons or wands) also cannot be taken
|
||||
with the steal maneuver—you must use the disarm combat maneuver instead »),
|
||||
une réussite ne vole rien (journalisé `nothing stealable — wielded weapons
|
||||
require disarm`) ; reposition → déplacement de la cible de 5 ft plus
|
||||
« For every 5 by which your attack exceeds your opponent's CMD, you can
|
||||
move the target an additional 5 feet » (APG « Reposition ») — `1 +
|
||||
marge//5` cases le long de la direction choisie (premier voisin libre par
|
||||
défaut, direction sélectionnable via `Action.direction`), stoppées au
|
||||
premier obstacle ; drag → traction de la cible de 5 ft plus 5 ft
|
||||
supplémentaires par 5 points d'excès sur le CMD (APG « Drag »), l'attaquant
|
||||
occupant sa case d'origine, stoppée au premier obstacle. `default_policy` ne
|
||||
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
|
||||
politique personnalisée).
|
||||
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
|
||||
Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1).
|
||||
- Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence`
|
||||
(evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà
|
||||
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
|
||||
(evil_eye, slumber, cackle) ; `evil_eye` (APG, conformance D3) inflige −2 à
|
||||
**une seule option** — l'option choisie est modélisée par les jets d'attaque —
|
||||
pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais
|
||||
**réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ;
|
||||
`slumber` (APG, conformance D4) endort la cible pour un **nombre de rounds
|
||||
égal au niveau de la sorcière** (Volonté annule ; simplification documentée :
|
||||
la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée,
|
||||
le moteur n'a pas de crochet de réveil) ;
|
||||
`cackle` (APG, conformance D5) est une **action libre sans coût de
|
||||
ressource** qui prolonge d'un round tous les sortilèges evil-eye (et
|
||||
fortune/misfortune — non implémentés, seule extension modélisée : les
|
||||
buffs evil_eye) portés par **toutes les créatures à 30 ft** de la
|
||||
sorcière, sur une liste instantanée pour ne pas s'étendre à l'infini.
|
||||
Simplification documentée : le slot « hex » reste standard pour
|
||||
evil_eye/slumber ; les autres hexes à prolonger ne sont pas implémentés.
|
||||
Les sorts à
|
||||
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
|
||||
zone effectue la sauvegarde listée par le sort (Volonté / Réflexes /
|
||||
Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès
|
||||
annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
|
||||
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
|
||||
annule » (Sleep, Color Spray, Web) sauvent donc aussi.
|
||||
annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
|
||||
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
|
||||
annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de
|
||||
dégâts peut déclarer `save_negates: false` (conformance D1 — SRD
|
||||
« Sound Burst » : « Fortitude partial » n'annule que l'étourdissement,
|
||||
jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts.
|
||||
**Sauvegardes partielles (S2d)** : un `ConditionEffect` peut déclarer
|
||||
`"on_success": {"condition_name": "shaken", "duration_rounds": 1}` (champ
|
||||
`save_partial`) — sur une sauvegarde **réussie**, au lieu d'annuler
|
||||
totalement l'effet, la cible subit la condition affaiblie indiquée (SRD
|
||||
Fear : paniqué à l'échec, `shaken` 1 round au succès). Sans `on_success`,
|
||||
le comportement reste « sauvegarde annule ». L'Extend Spell double aussi la
|
||||
durée de la condition partielle.
|
||||
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
|
||||
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
|
||||
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
|
||||
@@ -1088,11 +1430,33 @@ Règles modélisées :
|
||||
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
|
||||
modélisé ; les créatures blessées obtiennent un jet de Volonté DD `10 +
|
||||
demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts
|
||||
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
|
||||
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
|
||||
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
|
||||
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
|
||||
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
|
||||
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
|
||||
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
|
||||
résolue : si elle est une attaque **d'arme à feu** à distance ET à ≤ 30 ft
|
||||
(Ultimate Combat « Deeds — Up Close and Deadly » : « a successful attack
|
||||
with a firearm against a target within 30 feet »), +1d6 s'ajoute aux dégâts ;
|
||||
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
|
||||
deed `quick_clear` (S5a — conformance implémentée, Ultimate Combat « Deeds —
|
||||
Quick Clear » : « As a standard action, you can spend 1 point from your grit
|
||||
pool to remove the broken condition from a single firearm you are holding,
|
||||
as long as that condition was gained by a firearm misfire ») — le deed
|
||||
dépense 1 grit et répare la **première** arme à feu brisée par un raté
|
||||
(ordre des attaques, déterministe) ; sans grit, l'action est un no-op
|
||||
journalisé ; sans arme à feu brisée, aucun grit n'est dépensé. Le deed
|
||||
occupe le compartiment **standard** du budget (exception par action résolue
|
||||
par `_effective_slot`, comme cackle pour les hex ; le kind `deed` reste
|
||||
swift pour up_close_and_deadly, et le filtre swift-consommé-par-immédiate
|
||||
ne jette plus le quick_clear). Simplifications documentées : la variante
|
||||
RAW « as part of a move action to clear all firearms » n'est pas modélisée
|
||||
(un seul firearm par deed), et Quick Clear ne répare que le broken issu
|
||||
d'un misfire — le broken d'un sunder se répare via `repair_weapon`.
|
||||
Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When
|
||||
a firearm misfires, it gains the broken condition ») : une attaque d'arme à
|
||||
feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`,
|
||||
journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la
|
||||
progression de la valeur de misfire et l'explosion des early firearms
|
||||
re-cassées ne sont pas modélisées (une arme brisée ne peut plus tirer ici)),
|
||||
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
|
||||
complet, incante un sort standard et porte toutes ses attaques de mêlée à
|
||||
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
|
||||
d'attaque et de confirmation ; simplification : le coût de pool arcanique
|
||||
@@ -1122,8 +1486,12 @@ Règles modélisées :
|
||||
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
|
||||
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
||||
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
|
||||
weapon_broken/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack,
|
||||
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
|
||||
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
||||
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
||||
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
|
||||
dr {amount, bypass} ou None (C1), creature_type (C3),
|
||||
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
||||
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
||||
fraîche), casting_ability ou None pour non-jeteur
|
||||
@@ -1157,7 +1525,10 @@ Règles modélisées :
|
||||
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
|
||||
dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
|
||||
readied ({kind, trigger} de l'action préparée ou None — préparation en
|
||||
cours, consommée quand le déclencheur la fait tirer),
|
||||
cours, consommée quand le déclencheur la fait tirer), pending_cast
|
||||
({spell_name, target_id, slot_level} de l'incantation d'1 round en cours
|
||||
ou None — résolue au début du prochain tour du lanceur, voir « Incantation
|
||||
d'1 round »),
|
||||
defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale
|
||||
(+4) ou du combat défensif (+2), réinitialisé au début du tour du
|
||||
combattant), fight_defensively (bool — combat défensif actif : −4 aux
|
||||
@@ -1172,7 +1543,10 @@ Règles modélisées :
|
||||
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 —
|
||||
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,
|
||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
|
||||
@@ -1231,18 +1605,67 @@ la lecture mais non appliquée dans la résolution.
|
||||
Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées
|
||||
plutôt que bricolées) :
|
||||
|
||||
- **Pas de round de surprise** : le modèle de combattant n'a pas de champ de
|
||||
perception/attention (conscience du combat), donc le SRD « Surprise » ne
|
||||
peut pas être résolu — le combat démarre toujours au round 1 complet.
|
||||
- **Incantation `one_round` bornée à un round complet** : le moteur ne tient
|
||||
pas d'incantation en cours entre les tours ; un sort d'1 round se résout à
|
||||
la fin du tour courant au lieu du suivant (approximation documentée dans
|
||||
« Temps d'incantation »). L'incantation multi-round vraie (> 1 round) est
|
||||
jetée du budget (`long`).
|
||||
- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur
|
||||
allonge nulle et la co-occupation de leurs cases (plusieurs créatures
|
||||
minuscules sur une même case) ne sont pas modélisées ; seule la substitution
|
||||
Dextérité/Force du CMB (conformance L1) tient compte de leur taille.
|
||||
- **Incantation multi-round vraie (`long`) hors périmètre** : le round de
|
||||
surprise et l'incantation d'1 round sont modélisés (voir « Round de
|
||||
surprise » et « Incantation d'1 round ») ; restent hors périmètre les temps
|
||||
d'incantation > 1 round (3 rounds, 10 minutes, 1 heure — SRD « Casting
|
||||
Time » : « These actions must be consecutive and uninterrupted, or the
|
||||
spell automatically fails ») : non planifiables dans un tour, ils sont
|
||||
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
|
||||
données d'objectif pour ces sorts rituels.
|
||||
- **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller
|
||||
est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque
|
||||
possible dans sa propre case, et partage de case avec une créature
|
||||
strictement plus grande (déplacement à travers et fin de mouvement dessus) ;
|
||||
en revanche deux créatures de même taille (Small et plus) ne partagent
|
||||
jamais une case, et la co-occupation de plusieurs Tiny sur une même case
|
||||
n'est pas modélisée.
|
||||
- **Squeeze (se serrer dans un passage étroit) hors périmètre** (SRD
|
||||
« Moving Around or Through an Open Square » / « Squeezing Through » : une
|
||||
créature Large+ peut « squeeze » à travers un espace assez large pour une
|
||||
créature d'une catégorie de taille en dessous, à coût de mouvement accru et
|
||||
pénalités) : la largeur de corridor n'est pas modélisée par rapport à la
|
||||
taille — une créature Huge (3×3) est simplement bloquée par tout endroit où
|
||||
son footprint ne rentre pas ; ni le squeeze payant, ni la réduction de
|
||||
footprint dans les couloirs ne sont modélisés.
|
||||
- **Passage à travers une créature (3 catégories d'écart) — approximations** :
|
||||
la règle est modélisée (traversée + AoO d'entrée, voir « Mouvement ») ;
|
||||
restent hors périmètre le test d'Acrobatics « Tumbling » (« A trained
|
||||
character can attempt to use Acrobatics to move through a square occupied
|
||||
by an opponent » — la compétence n'existe pas dans le moteur) et la clause
|
||||
des très grosses créatures inertes (« Some creatures, particularly very
|
||||
large ones, may present an obstacle even when helpless. In such cases, each
|
||||
square you move through counts as 2 squares » — les cases d'une créature
|
||||
inactive ne bloquent pas et ne coûtent pas double).
|
||||
- **Échec d'incantation pour un personnage sourd hors périmètre** (SRD
|
||||
« Conditions — Deafened » : « a 20% chance of spell failure when casting
|
||||
spells with verbal components ») : le moteur ne porte pas la composante
|
||||
verbale des sorts (`SpellSpec` n'a pas de champ `components` — il
|
||||
faudrait l'encoder sur les 108 sorts du registre) et ne modélise pas non
|
||||
plus le jet de Perception DD 20 basé sur le son ni l'échec automatique
|
||||
associé ; la condition `deafened` reste donc un porteur de modificateurs
|
||||
(−4 d'initiative) sans la faillibilité d'incantation. Le soin des
|
||||
morts-vivants par les sorts de soin (`cure`) est en revanche modélisé :
|
||||
SRD « Cure Light Wounds » — « this spell deals damage to them instead of
|
||||
curing their wounds [...] can attempt a Will save to take half damage ».
|
||||
|
||||
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
|
||||
|
||||
- **Fear — durée figée** (SRD spells/fear.md : « each 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 round » ; cône de 30 ft —
|
||||
modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic
|
||||
à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus
|
||||
bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds.
|
||||
- **Bladed Dash — approximation** (SRD spells/bladed-dash.md : « When you cast
|
||||
this spell, you immediately move up to 30 feet in a straight line any
|
||||
direction [...] This movement does not provoke attacks of opportunity. You
|
||||
may make a single melee attack at your highest base attack bonus against any
|
||||
creature you are adjacent to at any point along this 30 feet ») : le sort est
|
||||
modélisé comme un buff de vitesse +30 ft pour 1 round ; l'attaque de mêlée
|
||||
gratuite, le déplacement sans AoO, la contrainte de ligne droite et le bonus
|
||||
de circonstance à l'attaque (mod d'Int ou de Cha, le plus élevé) ne sont pas
|
||||
modélisés.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -1260,6 +1683,11 @@ plutôt que bricolées) :
|
||||
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
|
||||
d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via
|
||||
`cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`.
|
||||
- `areas.py` — géométrie des gabarits de zone cône et ligne (CRB « Magic —
|
||||
Aiming a Spell ») : `cone_cells` (quart de cercle de 90°, test d'angle ±45°
|
||||
en arithmétique entière exacte, case d'origine exclue) et `line_cells`
|
||||
(raie de Bresenham de 5 ft de large, longue de `radius_ft`, partant de la
|
||||
case du jeteur). Les bursts restent gérés par `_creatures_in_range`.
|
||||
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
|
||||
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
|
||||
pour les attaques touch et firearms), tailles et allonges (occupation
|
||||
@@ -1312,7 +1740,11 @@ plutôt que bricolées) :
|
||||
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
|
||||
(rayons), temps d'incantation `cast_time`) et types d'effets
|
||||
(`DamageEffect`, `ConditionEffect`,
|
||||
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
||||
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
|
||||
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
|
||||
optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par
|
||||
`effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e
|
||||
(personal, touch, close, medium,
|
||||
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
||||
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
|
||||
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
|
||||
@@ -1343,7 +1775,7 @@ plutôt que bricolées) :
|
||||
La gate de validation complète (tests + lint + types) :
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 714 tests
|
||||
uv run pytest -q # 1491 tests
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright src # mode strict
|
||||
```
|
||||
@@ -1357,7 +1789,7 @@ uv run basedpyright src # mode strict
|
||||
## Feuille de route
|
||||
|
||||
- **Phase 1** — magie et états : largement réalisée. Sorts par emplacements
|
||||
(`spells.py` + base `data/spells/`, 107 sorts), DD et sauvegardes, RD non
|
||||
(`spells.py` + base `data/spells/`, 108 sorts), DD et sauvegardes, RD non
|
||||
appliquée aux dégâts de sorts, concentration par situation, invocation,
|
||||
conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms
|
||||
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
|
||||
|
||||
@@ -1,53 +1,67 @@
|
||||
{
|
||||
"name": "Celestial Wolf",
|
||||
"level": 2,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 13,
|
||||
"dex_score": 15,
|
||||
"con_score": 12,
|
||||
"int_score": 2,
|
||||
"wis_score": 12,
|
||||
"cha_score": 6
|
||||
},
|
||||
"hp_max": 15,
|
||||
"ac": {
|
||||
"total": 14,
|
||||
"touch": 13,
|
||||
"flat_footed": 12
|
||||
},
|
||||
"bab": 1,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 50,
|
||||
"saves": {
|
||||
"fort": 4,
|
||||
"ref": 5,
|
||||
"will": 2
|
||||
},
|
||||
"attacks": [
|
||||
"name": "Celestial Wolf",
|
||||
"level": 2,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 13,
|
||||
"dex_score": 15,
|
||||
"con_score": 15,
|
||||
"int_score": 2,
|
||||
"wis_score": 12,
|
||||
"cha_score": 6
|
||||
},
|
||||
"hp_max": 13,
|
||||
"ac": {
|
||||
"total": 14,
|
||||
"touch": 12,
|
||||
"flat_footed": 12
|
||||
},
|
||||
"bab": 1,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 50,
|
||||
"saves": {
|
||||
"fort": 5,
|
||||
"ref": 5,
|
||||
"will": 1
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"id": "celestial_wolf:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 2,
|
||||
"damage": [
|
||||
{
|
||||
"id": "celestial_wolf:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 3,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6+1",
|
||||
"types": ["piercing"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
"formula": "1d6+1",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": "summon monster II",
|
||||
"features": [],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true
|
||||
}
|
||||
],
|
||||
"dr": {
|
||||
"amount": 5,
|
||||
"bypass": ["magic"]
|
||||
},
|
||||
"resistances": {
|
||||
"acid": 5,
|
||||
"cold": 5,
|
||||
"electricity": 5
|
||||
},
|
||||
"spell_resistance": 6,
|
||||
"notes": "SRD Bestiary wolf (2d8+4 hp 13, AC 14/touch 12/flat 12, Fort +5, Will +1, bite +2 1d6+1 plus trip, Str 13 Dex 15 Con 15) + celestial template. Encoded from the template: DR 5/magic, energy resistance acid/cold/electricity 5, SR 6 (CR 1 + 5). Not encodable in this model: smite evil 2/day (+2 damage vs evil), the bite's free trip on hit (the engine has trip only as a standard-action maneuver, no auto-trip attack qualifier), and the weapon model has no magic property so no equipment bypasses the DR 5/magic (correct for mundane weapons). Summoning scaling (energy resistance +2 and SR +5 per summon spell level above 2nd) is not modeled; this sheet is the summon monster II baseline.",
|
||||
"source": "summon monster II",
|
||||
"features": [],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,70 @@
|
||||
{
|
||||
"name": "Giant Belostomatid",
|
||||
"level": 3,
|
||||
"size": "Large",
|
||||
"cr": "3",
|
||||
"xp": 800,
|
||||
"source": "Bestiary > Giant Belostomatid",
|
||||
"notes": "Digestive enzyme (DC 15 Fort or paralyzed 1d4 rounds + 1d2 Con damage/round) and grab on claws not modeled in Phase 0; Int - (mindless) modeled as 0; immune to mind-affecting not modeled; swim 40 ft / fly 20 ft (poor) not modeled, land speed only.",
|
||||
"abilities": {
|
||||
"str_score": 15,
|
||||
"dex_score": 13,
|
||||
"con_score": 16,
|
||||
"int_score": 0,
|
||||
"wis_score": 11,
|
||||
"cha_score": 2
|
||||
},
|
||||
"hp_max": 30,
|
||||
"ac": { "total": 15, "touch": 10, "flat_footed": 14 },
|
||||
"bab": 3,
|
||||
"initiative_mod": 1,
|
||||
"speed_land_ft": 20,
|
||||
"saves": { "fort": 7, "ref": 2, "will": 1 },
|
||||
"attacks": [
|
||||
"name": "Giant Belostomatid",
|
||||
"level": 3,
|
||||
"size": "Large",
|
||||
"cr": "3",
|
||||
"xp": 800,
|
||||
"source": "Bestiary > Giant Belostomatid",
|
||||
"notes": "Digestive enzyme (DC 15 Fort or paralyzed 1d4 rounds + 1d2 Con damage/round) and grab on claws not modeled in Phase 0; Int - (mindless) modeled as 0; immune to mind-affecting not modeled; swim 40 ft / fly 20 ft (poor) not modeled, land speed only.",
|
||||
"abilities": {
|
||||
"str_score": 15,
|
||||
"dex_score": 13,
|
||||
"con_score": 16,
|
||||
"int_score": 0,
|
||||
"wis_score": 11,
|
||||
"cha_score": 2
|
||||
},
|
||||
"hp_max": 30,
|
||||
"ac": {
|
||||
"total": 15,
|
||||
"touch": 10,
|
||||
"flat_footed": 14
|
||||
},
|
||||
"bab": 3,
|
||||
"initiative_mod": 1,
|
||||
"speed_land_ft": 20,
|
||||
"saves": {
|
||||
"fort": 7,
|
||||
"ref": 2,
|
||||
"will": 1
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"name": "bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"name": "bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [{ "formula": "1d6", "types": ["piercing"] }],
|
||||
"damage_bonus": 2,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 10
|
||||
},
|
||||
{
|
||||
"name": "claw",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
|
||||
"damage_bonus": 2,
|
||||
"crit_mult": 2,
|
||||
"count": 2,
|
||||
"reach_ft": 10
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"cmb": 6,
|
||||
"cmd": 17
|
||||
}
|
||||
],
|
||||
"damage_bonus": 2,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 10,
|
||||
"natural": true
|
||||
},
|
||||
{
|
||||
"name": "claw",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 2,
|
||||
"crit_mult": 2,
|
||||
"count": 2,
|
||||
"reach_ft": 10,
|
||||
"natural": true
|
||||
}
|
||||
],
|
||||
"cmb": 6,
|
||||
"cmd": 17
|
||||
}
|
||||
|
||||
+10
-9
@@ -6,24 +6,24 @@
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 14,
|
||||
"con_score": 14,
|
||||
"con_score": 10,
|
||||
"int_score": 20,
|
||||
"wis_score": 14,
|
||||
"cha_score": 16
|
||||
},
|
||||
"hp_max": 100,
|
||||
"ac": {
|
||||
"total": 25,
|
||||
"total": 23,
|
||||
"touch": 14,
|
||||
"flat_footed": 23
|
||||
"flat_footed": 21
|
||||
},
|
||||
"bab": 8,
|
||||
"bab": 5,
|
||||
"initiative_mod": 6,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {
|
||||
"fort": 10,
|
||||
"ref": 10,
|
||||
"will": 14
|
||||
"fort": 6,
|
||||
"ref": 7,
|
||||
"will": 11
|
||||
},
|
||||
"casting_ability": "int",
|
||||
"spell_slots": {
|
||||
@@ -39,14 +39,15 @@
|
||||
"id": "lich:touch",
|
||||
"name": "Paralyzing Touch",
|
||||
"kind": "touch",
|
||||
"attack_bonus": 10,
|
||||
"attack_bonus": 5,
|
||||
"natural": true,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d8+5",
|
||||
"types": ["negative"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 5,
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"attack_bonus": 8,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"formula": "1d4",
|
||||
"types": ["bludgeoning"]
|
||||
}
|
||||
],
|
||||
@@ -40,9 +40,11 @@
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 10,
|
||||
"count": 2
|
||||
"count": 2,
|
||||
"natural": true
|
||||
}
|
||||
],
|
||||
"notes": "Light-horse mount shared with Esha's sheet: hooves are 1d4 bludgeoning (SRD Bestiary horse, light - 2 hooves 1d4), natural attacks. No bite encoded: the SRD light horse has no bite attack and the Foundry sheet lists no mount bite (unlike Jeanne's sheet, which does).",
|
||||
"source": "fiches_personnages/esha_sheet.json (mount)",
|
||||
"features": [
|
||||
"Combat Reflexes",
|
||||
|
||||
+79
-72
@@ -1,76 +1,83 @@
|
||||
{
|
||||
"name": "Harvie",
|
||||
"level": 2,
|
||||
"size": "Small",
|
||||
"abilities": {
|
||||
"str_score": 10,
|
||||
"dex_score": 15,
|
||||
"con_score": 12,
|
||||
"int_score": 2,
|
||||
"wis_score": 12,
|
||||
"cha_score": 6
|
||||
},
|
||||
"hp_max": 15,
|
||||
"ac": {
|
||||
"total": 14,
|
||||
"touch": 13,
|
||||
"flat_footed": 12
|
||||
},
|
||||
"bab": 1,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 10,
|
||||
"speed_fly_ft": 80,
|
||||
"saves": {
|
||||
"fort": 4,
|
||||
"ref": 5,
|
||||
"will": 2
|
||||
},
|
||||
"name": "Harvie",
|
||||
"level": 2,
|
||||
"size": "Small",
|
||||
"abilities": {
|
||||
"str_score": 10,
|
||||
"dex_score": 15,
|
||||
"con_score": 12,
|
||||
"int_score": 2,
|
||||
"wis_score": 14,
|
||||
"cha_score": 6
|
||||
},
|
||||
"hp_max": 15,
|
||||
"ac": {
|
||||
"total": 14,
|
||||
"touch": 13,
|
||||
"flat_footed": 12
|
||||
},
|
||||
"bab": 1,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 10,
|
||||
"speed_fly_ft": 80,
|
||||
"saves": {
|
||||
"fort": 4,
|
||||
"ref": 5,
|
||||
"will": 2
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"id": "harvie:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 3,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": ["piercing"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"id": "harvie:claw",
|
||||
"name": "Claw",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 3,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d3",
|
||||
"types": ["slashing"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
}
|
||||
{
|
||||
"id": "harvie:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 2,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true,
|
||||
"secondary": true
|
||||
},
|
||||
{
|
||||
"id": "harvie:talons",
|
||||
"name": "Talons",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 2,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 2,
|
||||
"natural": true
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/harvie_sheet.json",
|
||||
"features": [
|
||||
"Friendly Face",
|
||||
"Fey-touched",
|
||||
"Animal (race)"
|
||||
],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"source": "fiches_personnages/harvie_sheet.json",
|
||||
"features": [
|
||||
"Friendly Face",
|
||||
"Fey-touched",
|
||||
"Animal (race)"
|
||||
],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
}
|
||||
|
||||
+78
-54
@@ -1,59 +1,83 @@
|
||||
{
|
||||
"name": "Ierlieth",
|
||||
"level": 4,
|
||||
"size": "Tiny",
|
||||
"abilities": {
|
||||
"str_score": 8,
|
||||
"dex_score": 17,
|
||||
"con_score": 11,
|
||||
"int_score": 10,
|
||||
"wis_score": 10,
|
||||
"cha_score": 9
|
||||
},
|
||||
"hp_max": 29,
|
||||
"ac": {
|
||||
"total": 17,
|
||||
"touch": 15,
|
||||
"flat_footed": 14
|
||||
},
|
||||
"bab": 3,
|
||||
"initiative_mod": 3,
|
||||
"speed_land_ft": 15,
|
||||
"speed_fly_ft": 40,
|
||||
"saves": {
|
||||
"fort": 3,
|
||||
"ref": 6,
|
||||
"will": 3
|
||||
},
|
||||
"attacks": [
|
||||
"name": "Ierlieth",
|
||||
"level": 4,
|
||||
"size": "Tiny",
|
||||
"abilities": {
|
||||
"str_score": 8,
|
||||
"dex_score": 17,
|
||||
"con_score": 11,
|
||||
"int_score": 10,
|
||||
"wis_score": 10,
|
||||
"cha_score": 9
|
||||
},
|
||||
"hp_max": 29,
|
||||
"ac": {
|
||||
"total": 17,
|
||||
"touch": 15,
|
||||
"flat_footed": 14
|
||||
},
|
||||
"bab": 3,
|
||||
"initiative_mod": 3,
|
||||
"speed_land_ft": 15,
|
||||
"speed_fly_ft": 40,
|
||||
"saves": {
|
||||
"fort": 3,
|
||||
"ref": 6,
|
||||
"will": 3
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"id": "ierlieth:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"id": "ierlieth:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": ["piercing"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": -1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true
|
||||
},
|
||||
{
|
||||
"id": "ierlieth:tail",
|
||||
"name": "Tail",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"bludgeoning"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true,
|
||||
"secondary": true
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/ierlieth_sheet.json",
|
||||
"features": [
|
||||
"Drake (race)",
|
||||
"Toughness",
|
||||
"Power Attack"
|
||||
],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"source": "fiches_personnages/ierlieth_sheet.json",
|
||||
"features": [
|
||||
"Drake (race)",
|
||||
"Toughness",
|
||||
"Power Attack"
|
||||
],
|
||||
"spells": [],
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
}
|
||||
|
||||
+22
-2
@@ -32,7 +32,7 @@
|
||||
"attack_bonus": 8,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"formula": "1d4",
|
||||
"types": ["bludgeoning"]
|
||||
}
|
||||
],
|
||||
@@ -40,7 +40,27 @@
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 10,
|
||||
"count": 2
|
||||
"count": 2,
|
||||
"natural": true
|
||||
},
|
||||
{
|
||||
"id": "jeanne:bite",
|
||||
"name": "Bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 8,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d3",
|
||||
"types": ["piercing"]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 2,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true,
|
||||
"secondary": true
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/jeanne_sheet.json",
|
||||
|
||||
+165
-162
@@ -1,166 +1,169 @@
|
||||
{
|
||||
"name": "Misty",
|
||||
"level": 4,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 19,
|
||||
"con_score": 14,
|
||||
"int_score": 16,
|
||||
"wis_score": 7,
|
||||
"cha_score": 18
|
||||
},
|
||||
"hp_max": 31,
|
||||
"ac": {
|
||||
"total": 20,
|
||||
"touch": 14,
|
||||
"flat_footed": 16
|
||||
},
|
||||
"bab": 3,
|
||||
"initiative_mod": 4,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {
|
||||
"fort": 7,
|
||||
"ref": 9,
|
||||
"will": 3
|
||||
},
|
||||
"casting_ability": "cha",
|
||||
"spell_slots": {
|
||||
"1": 4,
|
||||
"2": 3
|
||||
},
|
||||
"resources": {
|
||||
"bardic_rounds": 12
|
||||
},
|
||||
"attacks": [
|
||||
"name": "Misty",
|
||||
"level": 4,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 19,
|
||||
"con_score": 14,
|
||||
"int_score": 16,
|
||||
"wis_score": 7,
|
||||
"cha_score": 18
|
||||
},
|
||||
"hp_max": 31,
|
||||
"ac": {
|
||||
"total": 20,
|
||||
"touch": 14,
|
||||
"flat_footed": 16
|
||||
},
|
||||
"bab": 3,
|
||||
"initiative_mod": 4,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {
|
||||
"fort": 7,
|
||||
"ref": 9,
|
||||
"will": 3
|
||||
},
|
||||
"casting_ability": "cha",
|
||||
"spell_slots": {
|
||||
"1": 4,
|
||||
"2": 3
|
||||
},
|
||||
"resources": {
|
||||
"bardic_rounds": 12
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"name": "MWK Rapier",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 8,
|
||||
"damage": [
|
||||
{
|
||||
"name": "MWK Rapier",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 8,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 18,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"name": "Dagger",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 7,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 19,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 10
|
||||
},
|
||||
{
|
||||
"name": "Talons (attaque naturelle)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"name": "Bite (attaque naturelle)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"name": "Unarmed Strike",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d3",
|
||||
"types": [
|
||||
"bludgeoning"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/misty_sheet.json",
|
||||
"features": [
|
||||
"Harmonic Spell",
|
||||
"Weapon Finesse",
|
||||
"Axe to Grind (combat)",
|
||||
"Spell Vulnerability (divination; drawback)",
|
||||
"Born Damned (race: tiefling)",
|
||||
"Gifted Adept (dispel magic; magic)",
|
||||
"Prehensile Tail (tiefling daemon-spawn)",
|
||||
"Scaled Skin (electricity) (tiefling daemon-spawn)",
|
||||
"Maw or Claw (Bite) (tiefling daemon-spawn)"
|
||||
],
|
||||
"spells": [
|
||||
"Prestidigitation",
|
||||
"Oath of Anonymity",
|
||||
"Detect Magic",
|
||||
"Read Magic",
|
||||
"Disrupt Undead",
|
||||
"Ray of Frost",
|
||||
"Summon Instrument",
|
||||
"Mage Hand",
|
||||
"Open/Close",
|
||||
"Arcane Mark",
|
||||
"Acid Splash",
|
||||
"Grasp",
|
||||
"Shield",
|
||||
"Cure Light Wounds",
|
||||
"Color Spray",
|
||||
"Saving Finale",
|
||||
"Vanish",
|
||||
"Shocking Grasp",
|
||||
"Burning Hands",
|
||||
"Alarm",
|
||||
"Scorching Ray",
|
||||
"Bladed Dash",
|
||||
"Mirror Image",
|
||||
"Heroism"
|
||||
]
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 18,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"name": "Dagger",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 7,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 19,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 10
|
||||
},
|
||||
{
|
||||
"name": "Talons (attaque naturelle)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true
|
||||
},
|
||||
{
|
||||
"name": "Bite (attaque naturelle)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d4",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true
|
||||
},
|
||||
{
|
||||
"name": "Unarmed Strike",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 4,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d3",
|
||||
"types": [
|
||||
"bludgeoning"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/misty_sheet.json",
|
||||
"features": [
|
||||
"Harmonic Spell",
|
||||
"Weapon Finesse",
|
||||
"Axe to Grind (combat)",
|
||||
"Spell Vulnerability (divination; drawback)",
|
||||
"Born Damned (race: tiefling)",
|
||||
"Gifted Adept (dispel magic; magic)",
|
||||
"Prehensile Tail (tiefling daemon-spawn)",
|
||||
"Scaled Skin (electricity) (tiefling daemon-spawn)",
|
||||
"Maw or Claw (Bite) (tiefling daemon-spawn)",
|
||||
"Plot Armor"
|
||||
],
|
||||
"spells": [
|
||||
"Prestidigitation",
|
||||
"Oath of Anonymity",
|
||||
"Detect Magic",
|
||||
"Read Magic",
|
||||
"Disrupt Undead",
|
||||
"Ray of Frost",
|
||||
"Summon Instrument",
|
||||
"Mage Hand",
|
||||
"Open/Close",
|
||||
"Arcane Mark",
|
||||
"Acid Splash",
|
||||
"Grasp",
|
||||
"Shield",
|
||||
"Cure Light Wounds",
|
||||
"Color Spray",
|
||||
"Saving Finale",
|
||||
"Vanish",
|
||||
"Shocking Grasp",
|
||||
"Burning Hands",
|
||||
"Alarm",
|
||||
"Scorching Ray",
|
||||
"Bladed Dash",
|
||||
"Mirror Image",
|
||||
"Heroism"
|
||||
]
|
||||
}
|
||||
|
||||
+123
-122
@@ -1,126 +1,127 @@
|
||||
{
|
||||
"name": "Oni Triumvir",
|
||||
"level": 4,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 14,
|
||||
"con_score": 14,
|
||||
"int_score": 12,
|
||||
"wis_score": 18,
|
||||
"cha_score": 16
|
||||
},
|
||||
"hp_max": 36,
|
||||
"ac": {
|
||||
"total": 19,
|
||||
"touch": 18,
|
||||
"flat_footed": 17
|
||||
},
|
||||
"bab": 4,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {
|
||||
"fort": 7,
|
||||
"ref": 7,
|
||||
"will": 9
|
||||
},
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {
|
||||
"1": 4,
|
||||
"2": 3
|
||||
},
|
||||
"resources": {
|
||||
"channel_energy": 6
|
||||
},
|
||||
"attacks": [
|
||||
"name": "Oni Triumvir",
|
||||
"level": 4,
|
||||
"size": "Medium",
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 14,
|
||||
"con_score": 14,
|
||||
"int_score": 12,
|
||||
"wis_score": 18,
|
||||
"cha_score": 16
|
||||
},
|
||||
"hp_max": 36,
|
||||
"ac": {
|
||||
"total": 19,
|
||||
"touch": 18,
|
||||
"flat_footed": 17
|
||||
},
|
||||
"bab": 4,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {
|
||||
"fort": 7,
|
||||
"ref": 7,
|
||||
"will": 9
|
||||
},
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {
|
||||
"1": 4,
|
||||
"2": 3
|
||||
},
|
||||
"resources": {
|
||||
"channel_energy": 6
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"name": "Longbow",
|
||||
"kind": "ranged",
|
||||
"attack_bonus": 6,
|
||||
"damage": [
|
||||
{
|
||||
"name": "Longbow",
|
||||
"kind": "ranged",
|
||||
"attack_bonus": 6,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d8",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 3,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 100
|
||||
},
|
||||
{
|
||||
"name": "Alchemist's Fire",
|
||||
"kind": "touch",
|
||||
"attack_bonus": 6,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"fire"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 10
|
||||
},
|
||||
{
|
||||
"name": "Attaques naturelles (Shifter Claws / formes fées)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 5,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1
|
||||
"formula": "1d8",
|
||||
"types": [
|
||||
"piercing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/oni_sheet.json",
|
||||
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
|
||||
"features": [
|
||||
"Summon Good Monster",
|
||||
"Sacred Summons",
|
||||
"Selective Channeling",
|
||||
"Extremely Fashionable",
|
||||
"Exalted of the Society (Cleric, Pathfinder Society)",
|
||||
"Bitter",
|
||||
"Ambitious"
|
||||
],
|
||||
"spells": [
|
||||
"Guidance",
|
||||
"Detect Magic",
|
||||
"Stabilize",
|
||||
"Read Magic",
|
||||
"Detect Poison",
|
||||
"Purify Food and Drink",
|
||||
"Summon Monster I",
|
||||
"Shield of Faith",
|
||||
"Forbid Action",
|
||||
"Cure Light Wounds",
|
||||
"Hidden Diplomacy",
|
||||
"Protection from Evil",
|
||||
"Bless",
|
||||
"Summon Monster II",
|
||||
"Cure Moderate Wounds",
|
||||
"Sound Burst",
|
||||
"Suppress Charms and Compulsions",
|
||||
"Delay Poison",
|
||||
"Ironskin",
|
||||
"Restoration, Lesser"
|
||||
]
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 3,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 100
|
||||
},
|
||||
{
|
||||
"name": "Alchemist's Fire",
|
||||
"kind": "touch",
|
||||
"attack_bonus": 6,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"fire"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 0,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 10
|
||||
},
|
||||
{
|
||||
"name": "Attaques naturelles (Shifter Claws / formes fées)",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 5,
|
||||
"damage": [
|
||||
{
|
||||
"formula": "1d6",
|
||||
"types": [
|
||||
"slashing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"damage_bonus": 1,
|
||||
"crit_range": 20,
|
||||
"crit_mult": 2,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"natural": true
|
||||
}
|
||||
],
|
||||
"source": "fiches_personnages/oni_sheet.json",
|
||||
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
|
||||
"features": [
|
||||
"Summon Good Monster",
|
||||
"Sacred Summons",
|
||||
"Selective Channeling",
|
||||
"Extremely Fashionable",
|
||||
"Exalted of the Society (Cleric, Pathfinder Society)",
|
||||
"Bitter",
|
||||
"Ambitious"
|
||||
],
|
||||
"spells": [
|
||||
"Guidance",
|
||||
"Detect Magic",
|
||||
"Stabilize",
|
||||
"Read Magic",
|
||||
"Detect Poison",
|
||||
"Purify Food and Drink",
|
||||
"Summon Monster I",
|
||||
"Shield of Faith",
|
||||
"Forbid Action",
|
||||
"Cure Light Wounds",
|
||||
"Hidden Diplomacy",
|
||||
"Protection from Evil",
|
||||
"Bless",
|
||||
"Summon Monster II",
|
||||
"Cure Moderate Wounds",
|
||||
"Sound Burst",
|
||||
"Suppress Charms and Compulsions",
|
||||
"Delay Poison",
|
||||
"Ironskin",
|
||||
"Restoration, Lesser"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "Abundant Ammunition",
|
||||
"level": 0,
|
||||
"school": "transmutation",
|
||||
"level": 1,
|
||||
"school": "conjuration",
|
||||
"range": "close",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip that conjures duplicate ammunition: attacks with the affected ammunition do not expend it. No combat-mechanical effect in the engine (ammunition is not tracked)."
|
||||
"description": "A 1st-level spell that conjures duplicate ammunition: attacks with the affected ammunition do not expend it. No combat-mechanical effect in the engine (ammunition is not tracked)."
|
||||
}
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
"name": "Acid Arrow",
|
||||
"level": 2,
|
||||
"school": "evocation",
|
||||
"range": "medium",
|
||||
"range": "long",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"requires_ranged_touch": true,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "2d4",
|
||||
"types": ["acid"],
|
||||
"half_on_save": false
|
||||
"half_on_save": false,
|
||||
"recurring": {
|
||||
"formula": "2d4",
|
||||
"types": ["acid"],
|
||||
"base_rounds": 1,
|
||||
"per_levels": 3,
|
||||
"max": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "An arrow of acid springs from your hand and speeds to its target, dealing 2d4 points of acid damage. 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."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"base_dc": 10,
|
||||
"requires_ranged_touch": true,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "Aid",
|
||||
"level": 1,
|
||||
"level": 2,
|
||||
"school": "enchantment",
|
||||
"range": "long",
|
||||
"range": "touch",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "Air Bubble",
|
||||
"level": 1,
|
||||
"school": "conjuration",
|
||||
"range": "personal",
|
||||
"range": "touch",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "You create a sphere of air around your head (or a touched creature's) that lasts 10 min/level, allowing breathing underwater. No combat-mechanical effect in the engine."
|
||||
"description": "SRD spells/air-bubble.md: range touch - a small pocket of breathable air surrounds the touched creature's head (or a touched object) for 1 min./level. No combat-mechanical effect in the engine."
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"name": "Alarm",
|
||||
"level": 1,
|
||||
"school": "abjuration",
|
||||
"range": "medium",
|
||||
"range": "close",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Wards a area (20-ft radius, 20 ft high) against intrusion for 2 hours/level, alerting you mentally. No combat-mechanical effect in the engine."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "Arcane Mark",
|
||||
"level": 0,
|
||||
"school": "divination",
|
||||
"school": "universal",
|
||||
"range": "touch",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Creates a permanent visible inscribed mark of your choosing on an object or surface, optionally invisible and keyed to you. Utility cantrip, no combat effect."
|
||||
}
|
||||
|
||||
+14
-6
@@ -8,11 +8,19 @@
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "shaken",
|
||||
"duration_rounds": 10,
|
||||
"save_negates": true
|
||||
"type": "area",
|
||||
"radius_ft": 50,
|
||||
"targets": "enemies",
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "shaken",
|
||||
"duration_rounds": 10,
|
||||
"save_negates": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Enemies within range take a -1 penalty on attack rolls and saving throws (approximated with the shaken condition, -2). Will negates. Concentration 1 min/level approximated as 10 rounds."
|
||||
}
|
||||
"description": "SRD spells/bane.md: enemies within a 50-ft burst centered on the caster take a -1 penalty on attack rolls and a -1 penalty on saving throws against fear effects; Will negates. The -1 penalty is approximated with the shaken condition (-2 on attacks and saves), matching the existing Doom approximation. Duration 1 min/level approximated as 10 rounds."
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"level": 2,
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
@@ -16,5 +16,5 @@
|
||||
"duration_rounds": 60
|
||||
}
|
||||
],
|
||||
"description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 Fortitude and +2 hit points (approximated as +2/+2). Fortitude negates (harmless). Duration 10 min/level approximated as 60 rounds."
|
||||
"description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 to Fortitude saves and +2 hit points per Hit Die (SRD bear's endurance; the hp modifier is informational — the engine has no per-HD hp pool). Will negates (harmless). Duration 10 min/level approximated as 60 rounds."
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "Bladed Dash",
|
||||
"level": 1,
|
||||
"level": 2,
|
||||
"school": "transmutation",
|
||||
"range": "close",
|
||||
"range": "personal",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
@@ -15,5 +15,5 @@
|
||||
"duration_rounds": 1
|
||||
}
|
||||
],
|
||||
"description": "You move up your speed as a move action and make one melee attack at the end. Simplified as a +30 ft speed buff for 1 round; the extra attack is not modeled."
|
||||
"description": "Upon casting, you immediately move up to 30 feet in a straight line in any direction; this movement does not provoke attacks of opportunity, and you may make a single melee attack at your highest base attack bonus against any creature you are adjacent to at any point along this 30 feet (SRD bladed dash). Simplified as a +30 ft speed buff for 1 round; the free melee attack, the no-AoO movement, the straight-line constraint and the circumstance attack bonus are not modeled."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"level": 0,
|
||||
"school": "necromancy",
|
||||
"range": "close",
|
||||
"save": "fort",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
@@ -14,5 +14,5 @@
|
||||
"save_negates": true
|
||||
}
|
||||
],
|
||||
"description": "A cantrip that causes a wound to bleed: the target takes 1 point of damage per round until healed. Fortitude negates. Modeled as the bleeding condition for 1 round."
|
||||
"description": "SRD spells/bleed.md: a living creature at -1 or fewer hit points begins dying, taking 1 point of damage per round; Will negates. Modeled as the bleeding condition for 1 round with bleed_damage = 1 (the engine's bleed tick deals it at the start of the target's turn)."
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
"radius_ft": 20,
|
||||
"radius_ft": 50,
|
||||
"targets": "allies",
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
{
|
||||
@@ -23,5 +24,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "All allies within 20 ft/level (modeled as a 20-ft burst applied to all creatures in area) gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds."
|
||||
"description": "The caster and all allies within a 50-ft burst centered on the caster gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"level": 2,
|
||||
"school": "necromancy",
|
||||
"range": "medium",
|
||||
"save": "will",
|
||||
"save": "fort",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
@@ -14,5 +14,5 @@
|
||||
"save_negates": true
|
||||
}
|
||||
],
|
||||
"description": "The target becomes blind or deaf (modeled as blinded) until removed by a caster of higher level. Permanent duration approximated as 100 rounds. Will negates."
|
||||
"description": "The target becomes blind or deaf (modeled as blinded) until removed by a caster of higher level. Permanent duration approximated as 100 rounds. Fortitude negates."
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
"level": 2,
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "attack", "value": 1, "bonus_type": "enhancement" },
|
||||
{ "target": "damage", "value": 1, "bonus_type": "enhancement" }
|
||||
{ "target": "attack", "value": 2, "bonus_type": "enhancement" },
|
||||
{ "target": "damage", "value": 2, "bonus_type": "enhancement" },
|
||||
{ "target": "cmb", "value": 2, "bonus_type": "enhancement" }
|
||||
],
|
||||
"duration_rounds": 60
|
||||
}
|
||||
],
|
||||
"description": "The subject gains a +4 enhancement bonus to Strength, granting +2 attack and +2 damage (approximated as +1/+1). Fortitude negates (harmless)."
|
||||
"description": "The subject gains a +4 enhancement bonus to Strength, granting +2 to melee attack rolls, melee damage rolls, and combat maneuver checks (SRD bull's strength). Will negates (harmless)."
|
||||
}
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
"name": "Burning Hands",
|
||||
"level": 1,
|
||||
"school": "evocation",
|
||||
"range": "touch",
|
||||
"range": "close",
|
||||
"save": "ref",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "1d4+1",
|
||||
"types": ["fire"],
|
||||
"half_on_save": true
|
||||
"type": "area",
|
||||
"shape": "cone",
|
||||
"radius_ft": 15,
|
||||
"save_for_half": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "0",
|
||||
"types": ["fire"],
|
||||
"half_on_save": true,
|
||||
"scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A cone of searing flame shoots from your fingertips, dealing 1d4+1 points of fire damage per caster level (max 5d4+5). Reflex save for half."
|
||||
"description": "SRD spells/burning-hands.md: a 15-ft. cone of searing flame dealing 1d4 points of fire damage per caster level (maximum 5d4); Reflex half. The SRD range is 15 ft. - modeled as the closest category (close, 25 ft. at low levels)."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Color Spray",
|
||||
"level": 1,
|
||||
"school": "enchantment",
|
||||
"school": "illusion",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
@@ -9,6 +9,7 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
"shape": "cone",
|
||||
"radius_ft": 15,
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
@@ -21,5 +22,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A cone of chaotic colors (modeled as a 15-ft burst) blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned). Will negates."
|
||||
"description": "SRD spells/color-spray.md: a 15-ft. cone of chaotic colors blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned; the HD tiers are not modeled). Will negates."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Comprehend Languages",
|
||||
"level": 0,
|
||||
"level": 1,
|
||||
"school": "divination",
|
||||
"range": "personal",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A cantrip that lets you understand the meaning of any spoken or written language you encounter for 1 min/level. Utility spell, no combat effect."
|
||||
"description": "A 1st-level spell that lets you understand the meaning of any spoken or written language you encounter for 1 min/level. Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "Create Water",
|
||||
"level": 1,
|
||||
"level": 0,
|
||||
"school": "conjuration",
|
||||
"range": "medium",
|
||||
"range": "close",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Conjures 4 gallons of clean water per level in an open space or container. Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "heal",
|
||||
"formula": "1d8+1"
|
||||
"formula": "1d8",
|
||||
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 }
|
||||
}
|
||||
],
|
||||
"description": "Cures 1d8+1 points of damage. Will save negates (harmless)."
|
||||
"description": "Cures 1d8 points of damage +1 point per caster level (maximum +5). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure light wounds)."
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "heal",
|
||||
"formula": "2d8+3"
|
||||
"formula": "2d8",
|
||||
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 10 }
|
||||
}
|
||||
],
|
||||
"description": "Cures 2d8+3 points of damage. Will save negates (harmless)."
|
||||
"description": "Cures 2d8 points of damage +1 point per caster level (maximum +10). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure moderate wounds)."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "Dancing Lights",
|
||||
"level": 0,
|
||||
"school": "conjuration",
|
||||
"range": "close",
|
||||
"school": "evocation",
|
||||
"range": "medium",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip that creates up to four flickering torch-like lights you move as desired (concentration, 1 min/level). Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"save_negates": true
|
||||
}
|
||||
],
|
||||
"description": "A cantrip that clouds the mind of a humanoid creature with 5 or fewer HD, making it unable to act for 1 round. Will negates."
|
||||
"description": "SRD spells/daze.md: a cantrip that clouds the mind of a humanoid creature with 4 HD or fewer, making it unable to act for 1 round. Humanoids of 5 or more HD are not affected. Will negates."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Deja Vu",
|
||||
"level": 0,
|
||||
"school": "divination",
|
||||
"range": "personal",
|
||||
"level": 1,
|
||||
"school": "enchantment",
|
||||
"range": "medium",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A cantrip that lets you reroll one attack roll, saving throw, or skill check made in the past round (once per casting). Not modeled by the engine's action flow, so it carries no mechanical effect."
|
||||
"description": "A 1st-level spell that lets you reroll one attack roll, saving throw, or skill check made in the past round (once per casting). Not modeled by the engine's action flow, so it carries no mechanical effect."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Delay Disease",
|
||||
"level": 3,
|
||||
"school": "abjuration",
|
||||
"level": 2,
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "You suppress the progression of one disease the subject carries for 1 hour/level, keeping it dormant. Utility spell, no combat-mechanical effect in the engine. Fortitude negates (harmless)."
|
||||
"description": "SRD spells/delay-disease.md: conjuration (healing), level 2 for nearly every divine/prime class (witch 1 for ratfolk); the engine pins the common level 2. The target becomes temporarily immune to disease for 1 day. Utility spell, no combat-mechanical effect in the engine. Fortitude negates (harmless)."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Delay Poison",
|
||||
"level": 2,
|
||||
"school": "abjuration",
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"base_dc": 10,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip that reveals whether a creature of the outsider (evil) subtype has been in the area within 10 minutes/HD, and hints at its nature. Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip that detects magical auras in a 60-ft cone (2 rounds to identify strength and school). Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip that determines whether a creature, object, or area is poisoned, venomous, or afflicted by acid. Utility spell, no combat effect."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "You draw upon divine power for 1 round/level: a +1 luck bonus on attack rolls and weapon damage per 4 caster levels. Simplified: the engine has no luck-bonus self-buff that preserves the pinned default-policy casting order, so no mechanical effect is applied."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "attack", "value": 1, "bonus_type": "luck" },
|
||||
{ "target": "damage", "value": 1, "bonus_type": "luck" }
|
||||
],
|
||||
"duration_rounds": 10,
|
||||
"scaling_flat": { "amount": 1, "per_levels": 3, "max": 2 }
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/divine-favor.md: evocation, level 1 (cleric/inquisitor/oracle/paladin/warpriest), range personal, duration 1 minute. You gain a +1 luck bonus on attack and weapon damage rolls, +1 for every three caster levels (at least +1, maximum +3). The bonus does not apply to spell damage - the engine's 'damage' modifier target only rides weapon damage rolls, so that clause holds by construction. Duration 1 minute = 10 rounds."
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "Doom",
|
||||
"level": 1,
|
||||
"school": "necromancy",
|
||||
"range": "close",
|
||||
"range": "medium",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
@@ -14,5 +14,5 @@
|
||||
"save_negates": true
|
||||
}
|
||||
],
|
||||
"description": "The target is paralyzed with fear and becomes shaken (-2 on attacks and saves; on a successful save it takes -1 instead, not modeled). Will negates. 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 approximated as 10 rounds."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
{
|
||||
"name": "Drunkard's Breath",
|
||||
"level": 1,
|
||||
"school": "abjuration",
|
||||
"range": "personal",
|
||||
"save": "none",
|
||||
"level": 2,
|
||||
"school": "conjuration",
|
||||
"range": "close",
|
||||
"save": "fort",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "For 1 hour/level you gain DR 5/- against alcohol effects and cannot become drunk. The engine has no DR buff target, so no mechanical effect is applied."
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
"shape": "cone",
|
||||
"radius_ft": 30,
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "nauseated",
|
||||
"duration_rounds": 4,
|
||||
"save_negates": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/drunkards-breath.md: bard 2, cleric 2, oracle 2, skald 2, warpriest 2; conjuration (creation). A cone-shaped burst emanating from your mouth functions like stinking cloud: living creatures in the area become nauseated (Fortitude negates; this is a poison effect). Spell Resistance: no. The SRD range is 30 ft. - modeled as the closest category (close). Duration 1 round/level approximated as 4 rounds (the SRD nausea lasts 1d4+1 rounds after leaving the cloud; fixed duration is the engine convention, cf. fear)."
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "Endure Elements",
|
||||
"level": 1,
|
||||
"school": "abjuration",
|
||||
"range": "personal",
|
||||
"range": "touch",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "For 24 hours you tolerate comfortable extremes of heat and cold (from -50 to 140 degrees F) without ill effect. Environmental protection, no combat-mechanical effect in the engine."
|
||||
"description": "SRD spells/endure-elements.md: range touch - the creature touched suffers no harm from hot or cold environments (from -50 to 140 degrees F) for 24 hours. Environmental protection, no combat-mechanical effect in the engine."
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "Enhance Water",
|
||||
"level": 2,
|
||||
"school": "abjuration",
|
||||
"range": "personal",
|
||||
"save": "none",
|
||||
"level": 1,
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"base_dc": 10,
|
||||
"cast_time": "one_round",
|
||||
"effects": [],
|
||||
|
||||
+15
-6
@@ -2,17 +2,26 @@
|
||||
"name": "Fear",
|
||||
"level": 4,
|
||||
"school": "necromancy",
|
||||
"range": "medium",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "frightened",
|
||||
"duration_rounds": 4,
|
||||
"save_negates": true
|
||||
"type": "area",
|
||||
"shape": "cone",
|
||||
"radius_ft": 30,
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "panicked",
|
||||
"duration_rounds": 4,
|
||||
"save_negates": true,
|
||||
"on_success": { "condition_name": "shaken", "duration_rounds": 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The affected creature becomes frightened and flees from the caster. Will negates."
|
||||
"description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save; a creature that succeeds on the save is instead shaken for 1 round (partial save). Modeled as panicked for 4 rounds on a failed save, shaken for 1 round on a successful save. The SRD range is 30 ft. - modeled as the closest category (close)."
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "6d6",
|
||||
"formula": "0",
|
||||
"types": ["fire"],
|
||||
"half_on_save": true
|
||||
"half_on_save": true,
|
||||
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half."
|
||||
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 1d6 points of fire damage per caster level (maximum 10d6). Reflex save for half."
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"save": "ref",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
"radius_ft": 20,
|
||||
"shape": "cone",
|
||||
"radius_ft": 30,
|
||||
"save_for_half": true,
|
||||
"effects": [
|
||||
{
|
||||
@@ -21,5 +23,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A flurry of snowballs hurtles at your foes: any creature in the area takes 4d6 points of cold damage. Reflex half. The RAW 30-ft cone-shaped burst is approximated as a 20-ft burst centered on the target point."
|
||||
"description": "SRD spells/flurry-of-snowballs.md: any creature in the 30-ft. cone-shaped burst takes 4d6 points of cold damage. Reflex half. The SRD range is 30 ft. - modeled as the closest category (close)."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "immediate",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip cast as an immediate action when you fail a Climb check and would fall: you immediately retry the Climb check at a -2 penalty (increasing by 2 per successive use in the same situation); on success you don't fall. Climbing is not modeled by the engine, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "1d8+1",
|
||||
"formula": "1d8",
|
||||
"types": ["negative"],
|
||||
"half_on_save": true
|
||||
"half_on_save": true,
|
||||
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 }
|
||||
}
|
||||
],
|
||||
"description": "Touching a creature channels negative energy, dealing 1d8 points of damage + 1 per caster level (maximum +5); at caster level 1 this is modeled as 1d8+1. Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets."
|
||||
"description": "SRD spells/inflict-light-wounds.md: touching a creature channels negative energy, dealing 1d8 points of damage + 1 point per caster level (maximum +5). Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip: a touched object glows like a torch, shedding normal light in a 20-foot radius (plus dim light for 20 more feet) for 10 min/level. No combat effect; illumination is not modeled by the engine."
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "6d6",
|
||||
"types": ["electricity"],
|
||||
"half_on_save": true
|
||||
"type": "area",
|
||||
"shape": "line",
|
||||
"radius_ft": 120,
|
||||
"save_for_half": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "0",
|
||||
"types": ["electricity"],
|
||||
"half_on_save": true,
|
||||
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "You release a powerful stroke of electrical energy, dealing 6d6 points of electricity damage. Reflex save for half."
|
||||
"description": "SRD spells/lightning-bolt.md: a 120-ft. line dealing 1d6 points of electricity damage per caster level (maximum 10d6). Reflex half. The line starts at the caster's cell (the bolt begins at your fingertips), so the caster's own square is in the area."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Mage Armor",
|
||||
"level": 1,
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 4, "bonus_type": "armor" }
|
||||
],
|
||||
"duration_rounds": 600
|
||||
}
|
||||
],
|
||||
"description": "An invisible but tangible field of force surrounds the subject, providing a +4 armor bonus to AC (SRD mage armor: school conjuration (creation) [force], range touch, duration 1 hour/level (modeled as a fixed 600 rounds), spell resistance: no; Will negates (harmless) is not modeled - the engine only self/target-buffs willing recipients). Known simplification: the engine bakes worn armor into the AC total of the JSON and cannot see it, so this armor bonus stacks additively with that baked-in armor value instead of replacing it; two mage armors still do not stack with each other (armor is a non-stacking bonus type)."
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip: a spectral hand lifts and moves one unattended nonmagical object weighing up to 5 lb. from up to 25 ft. away, as far as 15 feet per move action while you concentrate. No combat effect; object manipulation is not modeled by the engine."
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
"type": "damage",
|
||||
"formula": "1d4+1",
|
||||
"types": ["force"],
|
||||
"half_on_save": false
|
||||
"half_on_save": false,
|
||||
"projectiles": { "base": 1, "per_levels": 2, "at_level": 1, "max": 5 }
|
||||
}
|
||||
],
|
||||
"description": "A missile of magical energy darts forth from your fingertip and strikes its target, dealing 1d4+1 points of force damage."
|
||||
"description": "SRD spells/magic-missile.md: unerring force missiles, 1d4+1 points of force damage each. One missile, plus one additional missile per two caster levels beyond 1st (two at 3rd, three at 5th, four at 7th, maximum five at 9th or higher). Auto-hit (no attack roll); spell resistance applies to the target."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "long",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "After 1 hour of casting (plus masterwork-priced reagents), a non-masterwork weapon, armor, shield, tool, or skill kit becomes its masterwork equivalent. Item crafting is outside the engine's combat model, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip: you whisper messages and receive replies with targeted creatures within range for 10 min/level; bystanders hear nothing without a DC 25 Perception check. Communication is not modeled by the engine, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip: minor magical tricks for 1 hour - lift 1 lb. of material, color/clean/soil items in a 1-ft cube, chill/warm/flavor 1 lb. of nonliving material, or create crude trinkets. It can deal no damage and duplicates no other spell. No combat effect; the trivial manipulations are not modeled by the engine."
|
||||
"description": "SRD spells/prestidigitation.md: a cantrip - minor magical tricks for 1 hour; lift 1 lb. of material, color/clean/soil items in a 1-ft cube, chill/warm/flavor 1 lb. of nonliving material, or create crude trinkets. It deals no damage and duplicates no other spell. The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. No combat effect; the trivial manipulations are not modeled by the engine."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A barrier wards the subject from evil for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by evil creatures, renewed saves and suppression of mental control, and lawful summoned evil creatures cannot touch it. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Functions like protection from evil (+2 deflection to AC and +2 resistance on saves vs evil creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A barrier wards the subject from lawful creatures for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by lawful creatures, plus renewed saves against mental control by lawful creatures. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Functions like protection from law (+2 deflection to AC and +2 resistance on saves vs lawful creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A cantrip: cleanses 1 cu. ft./level of spoiled, rotten, diseased or poisoned food and water, making it pure and drinkable (no effect on creatures or magic potions). Will negates (object). Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
|
||||
"description": "SRD spells/purify-food-and-drink.md: a cantrip - cleanses 1 cu. ft./level of spoiled, rotten, diseased or poisoned food and water (no effect on creatures or magic potions). Will negates (object). The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A cantrip: instantly rots 1 cu. ft./level of edible food and makes water and liquids brackish and undrinkable (can also spoil a single potion; no effect on creatures or unholy water). Will negates (object). Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
|
||||
"description": "SRD spells/putrefy-food-and-drink.md: a cantrip - instantly rots 1 cu. ft./level of edible food and makes water and liquids brackish and undrinkable (can also spoil a single potion; no effect on creatures or unholy water). Will negates (object). The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
{
|
||||
"type": "condition",
|
||||
"condition_name": "fatigued",
|
||||
"duration_rounds": 4,
|
||||
"save_negates": true
|
||||
"type": "penalty",
|
||||
"formula": "1d6",
|
||||
"scaling_flat": { "amount": 1, "per_levels": 2, "max": 5 },
|
||||
"targets": ["attack", "damage"],
|
||||
"duration_rounds_per_level": 1,
|
||||
"half_on_save": true
|
||||
}
|
||||
],
|
||||
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability damage, so the Strength penalty is modeled as the fatigued condition (attack/damage penalties, no charge) for 4 rounds (1 round/level at caster level 4); Fortitude halves is approximated as Fortitude negates."
|
||||
}
|
||||
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability-score track, so the Strength penalty is modeled as negative untyped modifiers on the attack and damage rolls (the two combat rolls Strength feeds) for 1 round per caster level; the SRD 'cannot drop below 1' floor and the non-stacking clause are not modeled."
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "You ward a creature against attacks for 1 round/level: any opponent attempting to directly attack the warded creature must succeed on a Will save or be unable to follow through with that attack and cannot directly attack the subject for the duration. The subject cannot attack without ending the spell. Will negates. No combat effect encoded: the engine has no attack-refusal-by-save mechanic, so the protection cannot be represented."
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
"type": "damage",
|
||||
"formula": "4d6",
|
||||
"types": ["fire"],
|
||||
"half_on_save": false
|
||||
"half_on_save": false,
|
||||
"projectiles": { "base": 1, "per_levels": 4, "at_level": 3, "max": 3 }
|
||||
}
|
||||
],
|
||||
"description": "You blast your enemy with a searing beam of fire: each ray requires a ranged touch attack and deals 4d6 points of fire damage. You fire one ray, plus one additional ray per four levels beyond 3rd (max three at 11th) — the engine models a single ray. No save."
|
||||
"description": "SRD spells/scorching-ray.md: a searing beam of fire. One ray, plus one additional ray for every four caster levels beyond 3rd (to a maximum of three rays at 11th level). Each ray requires a ranged touch attack to hit and deals 4d6 points of fire damage. No save."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
"base_dc": 10,
|
||||
"requires_ranged_touch": true,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "0",
|
||||
"types": ["force"],
|
||||
"half_on_save": true,
|
||||
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 }
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/shard-of-chaos.md: evocation [chaotic], level 2 (cleric/oracle/warpriest), range close, dart-shaped projectile of chaotic energy delivered as a ranged touch attack, Will partial, spell resistance yes. A lawful creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on chaotic creatures, half damage to neutrals, the lawful-outsider 1d6/level + slowed variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models chaotic energy), Will half."
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 4, "bonus_type": "deflection" }
|
||||
{ "target": "ac", "value": 4, "bonus_type": "shield" }
|
||||
],
|
||||
"duration_rounds": 10
|
||||
}
|
||||
],
|
||||
"description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds; the shield bonus type is approximated as deflection since the engine has no shield bonus type). Negating magic missile and the ranged-attack deflection are not modeled."
|
||||
"description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds). Negating magic missile and the ranged-attack deflection are not modeled."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
"save": "will",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A shimmering magical field grants the target a +2 deflection bonus to AC (+1 per six caster levels, max +5) for 1 min./level. Will negates (harmless). Simplified: the engine has no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 2, "bonus_type": "deflection" }
|
||||
],
|
||||
"duration_rounds": 10,
|
||||
"scaling_flat": { "amount": 1, "per_levels": 6, "max": 3 }
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/shield-of-faith.md: abjuration, level 1 (cleric/inquisitor/oracle/warpriest), range touch, duration 1 min./level, Will negates (harmless), spell resistance yes (harmless). The subject gains a +2 deflection bonus to AC, +1 per six caster levels (maximum +5 at 18th). Duration 1 min/level approximated as 10 rounds (the engine has no per-level buff duration; cf. mirror image)."
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "1d6",
|
||||
"formula": "0",
|
||||
"types": ["electricity"],
|
||||
"half_on_save": false
|
||||
"half_on_save": false,
|
||||
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 5 }
|
||||
}
|
||||
],
|
||||
"description": "Your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6) — modeled at caster level 1 (1d6). No save; the +3 attack bonus against metal-armored foes is not modeled."
|
||||
"description": "SRD spells/shocking-grasp.md: your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6). No save; the +3 attack bonus against metal-armored foes is not modeled."
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
{
|
||||
"type": "area",
|
||||
"radius_ft": 10,
|
||||
"save_for_half": true,
|
||||
"save_for_half": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "1d8",
|
||||
"types": ["sonic"],
|
||||
"half_on_save": true
|
||||
"half_on_save": false,
|
||||
"save_negates": false
|
||||
},
|
||||
{
|
||||
"type": "condition",
|
||||
@@ -27,5 +28,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save or be stunned for 1 round (Fortitude partial — modeled as save for half damage and negates the stun). Creatures that cannot hear take the damage but are not stunned (not modeled)."
|
||||
"description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save to avoid being stunned for 1 round (SRD spells/sound-burst.md: the save only negates the stun - the damage is full regardless). Creatures that cannot hear take the damage but are not stunned (not modeled)."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
"base_dc": 10,
|
||||
"requires_ranged_touch": true,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
"description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "0",
|
||||
"types": ["force"],
|
||||
"half_on_save": true,
|
||||
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 }
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/spear-of-purity.md: evocation [good], level 2 (cleric/oracle/warpriest), range close, spear of good energy delivered as a ranged touch attack, Will partial, spell resistance yes. An evil creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on good creatures, half damage to neutrals, the evil-outsider 1d6/level + blinded variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models good energy), Will half."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "one_round",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A cantrip (casting time 1 round): you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). No combat effect: summoned objects are not modeled by the engine."
|
||||
"description": "SRD spells/summon-instrument.md: a cantrip (casting time 1 round) - you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). The SRD range is 0 ft. - the engine's personal category maps to 0 ft., so personal is the exact match. No combat effect: summoned objects are not modeled by the engine."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "one_round",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "summon",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "one_round",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "summon",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "An invisible, mindless, shapeless force performs simple tasks at your command for 1 hour/level: fetching, opening unstuck doors, holding chairs, cleaning or mending (effective Strength 2, speed 15 ft., no attacks, DC 10 skill limit). No combat effect: object manipulation by invisible servants is not modeled by the engine."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"save": "ref",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [
|
||||
{
|
||||
"type": "area",
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# Conformité SRD Pathfinder 1re édition — journal d'audit
|
||||
|
||||
Ce document consigne, de façon exhaustive et vérifiable, l'écart entre les règles
|
||||
implémentées par le moteur et le SRD Pathfinder 1re édition. Il complète le
|
||||
`README.md` (qui décrit le comportement attendu) en traçant **ce qui a été
|
||||
corrigé**, **ce qui est hors périmètre**, et **ce qui reste assumé comme
|
||||
résiduel**.
|
||||
|
||||
## Méthode
|
||||
|
||||
1. **Audit read-only par domaine** : chaque passe couvre un domaine étanche
|
||||
(sorts, mouvement, morts-vivants, dons, manœuvres, conditions, données de
|
||||
sorts, bestiaire). L'auditeur croise le code avec la base de connaissances
|
||||
SRD (`pathfinder-kb`, collection `pathfinder-1e-rules`) et cite la règle.
|
||||
2. **Classement** : `High` (change matériellement l'issue d'un combat),
|
||||
`Med` (clairement faux, impact étroit), `Low` (donnée / cosmétique).
|
||||
3. **Correction** : un commit par correctif, gate complète verte avant chaque
|
||||
commit (`ruff` `select=ALL`, `basedpyright` strict, `pytest`).
|
||||
4. **Non-régression** : le transcript doré (`tests/test_pc_combat_golden.py`)
|
||||
n'est réécrit que lorsqu'un correctif SRD change légitimement la sortie,
|
||||
avec justification ligne à ligne.
|
||||
5. **Traçabilité** : tout constat non corrigé est classé ici en *hors
|
||||
périmètre* (choix délibéré) ou *résiduel assumé* (écart connu, non corrigé).
|
||||
|
||||
## État de la gate
|
||||
|
||||
| Contrôle | Résultat |
|
||||
|---|---|
|
||||
| `uv run pytest -q` | 1521 tests verts |
|
||||
| `uv run ruff check src tests` (`select=ALL`) | conforme |
|
||||
| `uv run basedpyright src` (strict) | 0 erreur |
|
||||
| Transcript doré depuis la passe 1 | inchangé |
|
||||
|
||||
---
|
||||
|
||||
## 1. Écarts corrigés
|
||||
|
||||
### Correctifs de la passe 2 (commits)
|
||||
|
||||
| Commit | Objet |
|
||||
|---|---|
|
||||
| `b03a61d` | Données de sorts : niveaux (`abundant_ammunition`, `comprehend_languages`, `deja_vu` 0→1 ; `create_water` 1→0 ; `enhance_water` 2→1), sauvegardes (`blindness_deafness` Will→Fort ; `enhance_water` none→Fort), RM de `summon_monster_i/ii`, écoles (6 sorts), portées (4 sorts) + pins de registre |
|
||||
| `317f91a` | *Entangled* : jet de concentration DD 15 + niveau ; `dex_skills` −4 |
|
||||
| `bfe0b76` | *Exhausted* : −6 Dextérité → −3 CA / −3 Réflexes |
|
||||
| `6220edc` | *Fatigued* : −2 Dextérité → −1 CA / −1 Réflexes |
|
||||
| `def8533` | *Nauseated* : privé d'attaques d'opportunité |
|
||||
| `68a03a3` | *Confused* : privé d'attaques d'opportunité (simplification) |
|
||||
| `cf8a073` | *Grappled/Pinned* : −2 aux jets de manœuvre (hors grapple/escape) |
|
||||
| `f31cc88` | *Pinned* : ne peut plus attaquer |
|
||||
| `4d45409` | Weapon Focus : correspondance insensible à la casse (le don s'applique enfin) |
|
||||
| `2807f71` | Attaque sournoise : toute dissimulation l'annule |
|
||||
| `350844f` | *Up Close And Deadly* : réservé aux armes à feu |
|
||||
| `fdc9127` | *Toughness* : +3 pv sous 4 DV (`max(3, niveau)`) |
|
||||
| `2b5920c` | *Disarm* : l'arme tombe (`weapon_dropped`) au lieu d'une pénalité −4 |
|
||||
| `06da519` | *Dirty trick* : liste RAW étendue (`shaken`, `sickened`) |
|
||||
| `0cccea2` | Manœuvres : succès automatique contre cible incapable |
|
||||
| `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 |
|
||||
|
||||
> 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).
|
||||
|
||||
### Passe 1 — sorts de buff, mouvement/grille, morts-vivants, armes naturelles
|
||||
|
||||
| # | Constat | Sév. | Correctif (commit) |
|
||||
|---|---|---|---|
|
||||
| B1 | *Bless* affectait **toutes** les créatures d'un burst de 20 ft (y compris les ennemis) ; le SRD cible « you and each of your allies within a 50-ft. burst » | **High** | `e43066e` — filtre `AreaEffect.targets` (`all`/`allies`/`enemies`), burst 50 ft, alliés + lanceur |
|
||||
| B2 | *Bane* était un sort mono-cible ; le SRD cible les **ennemis** dans un burst de 50 ft | Med | `5e3505d` — conversion en `AreaEffect` 50 ft, `targets: "enemies"` |
|
||||
| B3 | *Bladed Dash* : niveau 1 (au lieu de 2), portée `close` (au lieu de `personal`) | Med | `312f9fd` |
|
||||
| B4 | *Aid* : niveau 1 (au lieu de 2), portée `long` (au lieu de `touch`) | Med | `312f9fd` |
|
||||
| B5 | *Doom* : portée `close` (au lieu de `medium`) | Med | `312f9fd` |
|
||||
| B6 | *Bull's Strength* : sauvegarde `fort` (le SRD n'accorde pas de jet ; valeur neutre corrigée en `will`) | Med | `312f9fd` |
|
||||
| B7 | *Bear's Endurance* : sauvegarde `fort` (idem) | Med | `312f9fd` |
|
||||
| B8 | *Mage Armor* absent du registre | Low | `3e19d29` — sort ajouté (niveau 1, conjuration, contact, +4 CA d'**armure**, RM non) + type de bonus `"armor"` non cumulable (registre porté à 108 sorts) |
|
||||
| C1 | Budget de déplacement : parité des diagonales figée à 0 dans les descentes gloutonnes → la règle 5-10-5 était **violée** (un marcheur vitesse 30 parcourait 6 cases en diagonale au lieu de 4) | Med | `2cc2ba1` — parité propagée (`_best_move_step`, `_descend_path`, `_run_line_path`, `_flee_path`) + `_path_cost_5105` pour la charge |
|
||||
| C2 | Pas de placement (5 ft) autorisé depuis/vers un terrain difficile | Med | `437507e` — rejet si le footprint courant **ou** la destination touche du terrain difficile |
|
||||
| C3 | Entrer dans la case d'une créature hostile ne provoquait pas d'AoO | Med | `9a16ca0` — `_check_enter_aoo` câblé dans `_walk_steps` |
|
||||
| C4 | Traversée d'une case hostile à ≥ 3 catégories d'écart de taille non modélisée | Med | `9a16ca0` — `_can_pass_through` (porte « traversée ») distincte de `_blocked` (porte « fin de mouvement ») |
|
||||
| C5 | Un combattant **fatigué/épuisé** pouvait courir (seule la charge était bloquée) ; le SRD : « can neither run nor charge » | Low | `7fc7a1d` — garde `cant_charge` ajoutée à `_execute_run` |
|
||||
| D1 | Les morts-vivants (et les créatures artificielles) mouraient à `hp ≤ -CON` au lieu d'être détruits à `hp ≤ 0` | Med | `59caf67` — seuil de mort à 0, pas de bande d'agonie |
|
||||
| D2 | Fiche du liche non conforme au bloc SRD : BAB 8 (au lieu de +5), contact +10 (au lieu de +5), `damage_bonus` 5 doublant la formule (dégâts réels 1d8+10 au lieu de 1d8+5), CA 25/14/23 (au lieu de 23/14/21), jets 10/10/14 (au lieu de 6/7/11), `con_score` 14 alors qu'un mort-vivant n'a pas de Constitution | Low | `881f2ef` |
|
||||
| D3 | Les sorts de soin (`cure`) soignaient les morts-vivants au lieu de les **blesser** | Low | `cad35e2` — dégâts (Volonté pour moitié), ni RD ni stabilisation |
|
||||
| A1 | Armes naturelles : itératifs de BAB accordés à tort et absence de la pénalité −5 des attaques secondaires | Med | `e5691bd` — champs `natural`/`secondary` (une seule attaque, secondaires −5, Multiattack −2) |
|
||||
|
||||
### Passe 2 — dons/capacités, manœuvres, conditions, données de sorts
|
||||
|
||||
Constats d'audit (6 High, ~28 Med, ~45 Low). La colonne *Disposition* indique
|
||||
le sort réservé : **corrigé** (commit), **à corriger**, **hors périmètre** (non
|
||||
modélisable proprement) ou **résiduel assumé** (écart connu, conservé).
|
||||
|
||||
#### 2a. Données de sorts (vérifiées une à une contre le KB)
|
||||
|
||||
| Constat | Sév. | Disposition |
|
||||
|---|---|---|
|
||||
| `abundant_ammunition` niveau 0 → **1** (toutes classes) | High | corrigé (passe 2) |
|
||||
| `comprehend_languages` niveau 0 → **1** | High | corrigé (passe 2) |
|
||||
| `deja_vu` niveau 0 → **1** | High | corrigé (passe 2) |
|
||||
| `blindness_deafness` sauvegarde `will` → **`fort`** | High | corrigé (passe 2) |
|
||||
| `create_water` niveau 1 → **0** (clerc/druide) | Med | corrigé (passe 2) |
|
||||
| `enhance_water` niveau 2 → **1**, portée personal → **touch** | Med | corrigé (passe 2) |
|
||||
| `deja_vu` portée personal → **medium** | Med | corrigé (passe 2) |
|
||||
| `summon_monster_i` / `summon_monster_ii` RM true → **false** | Med | corrigé (passe 2) |
|
||||
| `divine_favor` — effet SRD (+1 chance attaque/dégâts par 3 niveaux) absent | Med | résiduel assumé |
|
||||
| `owls_wisdom` — effet SRD (+4 Sagesse) absent | Med | résiduel assumé |
|
||||
| `shield_of_faith` — effet SRD (+2 CA déflexion) absent | Med | résiduel assumé |
|
||||
| `weapon_of_awe` — effet SRD (+2 dégâts sacrés) absent | Med | résiduel assumé |
|
||||
| Écoles fausses : `arcane_mark` divination→**universal**, `color_spray` enchantment→**illusion**, `dancing_lights` conjuration→**evocation**, `deja_vu` divination→**enchantment**, `enhance_water` abjuration→**transmutation**, `abundant_ammunition` transmutation→**conjuration** | Low | corrigé (passe 2) |
|
||||
| Portées fausses : `dancing_lights` close→**medium**, `create_water` medium→**close** | Low | corrigé (passe 2) |
|
||||
| `enhance_water` sauvegarde none → **fort** (inoffensif) | Low | corrigé (passe 2) |
|
||||
| Effets absents (sorts utilitaires) : `guidance`, `resistance`, `protection_from_evil`, `sanctuary`, `virtue`, `shard_of_chaos`, `spear_of_purity`, `spiritual_weapon` | Low | hors périmètre |
|
||||
| *Faux positifs* (conformes) : `web` RM, `flurry_of_snowballs` RM, `detect_fiendish_presence` niveau, `scorching_ray` projectiles (max 3), `make_whole`/`restoration_lesser` temps d'incantation (`long`) | — | conformes |
|
||||
| *Non vérifiable* (le KB omet la ligne RM) : `shield`, `read_magic`, `comprehend_languages` (RM) | — | indéterminé |
|
||||
| Portées « pieds fixes » (15/30/50/60/120 ft) approchées par les catégories close/medium/long pour les cônes, lignes et bursts (`burning_hands`, `color_spray`, `fear`, `flurry_of_snowballs`, `lightning_bolt`, `bless`, `bane`, `obscuring_mist`, `detect_magic`) | Low | hors périmètre (modèle de portée par catégories) |
|
||||
|
||||
#### 2b. Conditions
|
||||
|
||||
| Constat | Sév. | Disposition |
|
||||
|---|---|---|
|
||||
| *Entangled* : aucun jet de concentration **DD 15 + niveau** à l'incantation | Med | corrigé (passe 2) |
|
||||
| *Exhausted* : pénalités **−3 CA / −3 Réflexes** manquantes (−6 Dex) | Med | corrigé (passe 2) |
|
||||
| *Nauseated* : peut porter des **attaques d'opportunité** | Med | corrigé (passe 2) |
|
||||
| *Confused* : peut porter des attaques d'opportunité (le SRD l'interdit) | Med | corrigé (passe 2) |
|
||||
| *Grappled* : **−2 aux jets de manœuvre** (sauf grapple/escape) non appliqué | Med | corrigé (passe 2) |
|
||||
| *Pinned* : peut **attaquer** ; de plus la condition n'est **jamais appliquée** (code mort) | Med | corrigé (f31cc88) / hors périmètre (entretien) |
|
||||
| *Fatigued* : **−1 CA / −1 Réflexes** manquants (−2 Dex) | Low | corrigé (passe 2) |
|
||||
| *Fatigued/Exhausted* : pénalité de dégâts appliquée aussi aux armes à distance | Low | résiduel assumé |
|
||||
| *Entangled* : `dex_skills` −4 manquant (incohérent avec grappled/pinned) | Low | corrigé (passe 2) |
|
||||
| *Shaken/Sickened/Frightened/Panicked* : pénalités de compétence/caractéristique non modélisées | Low | hors périmètre (pas de tests de compétence) |
|
||||
| *Frightened/Panicked* : fuite = retraite 2× au lieu de la course 4× ; acculé attend au lieu de se recroqueviller | Low | résiduel assumé |
|
||||
| *Dazzled* / *Blinded* : malus de Perception (dont opposition) non modélisés | Low | hors périmètre |
|
||||
| *Deafened* : échec d'incantation 20 % | Low | hors périmètre (déjà documenté) |
|
||||
| *Stunned/Panicked* : « lâche tout ce qu'elle tient » non modélisé | Low | hors périmètre (pas de modèle d'objet tenu) |
|
||||
| *Grappled* : « aucune action à deux mains » non modélisé | Low | hors périmètre |
|
||||
| *Pinned* : composantes somatiques/matérielles non modélisées | Low | hors périmètre |
|
||||
| *Bleeding* : règle de non-cumul (« le pire effet l'emporte ») non vérifiée | Low | à vérifier |
|
||||
|
||||
#### 2c. Manœuvres de combat
|
||||
|
||||
| Constat | Sév. | Disposition |
|
||||
|---|---|---|
|
||||
| *Grapple* : aucun entretien/pin/dégâts/déplacement ; `pinned` jamais appliqué — **le README « grapple → grappled/pinned » est faux** | High | hors périmètre (README corrigé, e89ea42) |
|
||||
| *Disarm* : pénalité **−4 synthétique** au lieu du **lâcher d'arme** (le SRD fait tomber l'objet) | High | à corriger |
|
||||
| *Grappled* : −2 non appliqué aux jets de manœuvre de combat | Med | corrigé (cf. 2b) |
|
||||
| *Grapple* : plafond arbitraire de 10 rounds ; pas de bonus de +5 d'entretien | Med | hors périmètre |
|
||||
| *Disarm* : pas d'échec-de-10 → l'attaquant lâche son arme | Med | résiduel assumé |
|
||||
| *Trip* : `prone` expire en 1 round au lieu de persister jusqu'à `stand_up` | Med | résiduel assumé (README documenté) |
|
||||
| *Sunder* : dégâts `str_mod + 2` fixes au lieu des dégâts d'arme ; **dureté non modélisée** | Med | résiduel assumé (README documenté) |
|
||||
| *Sunder* : arme cassée retirée des attaques au lieu de **−2** (SRD : reste utilisable) | Med | résiduel assumé |
|
||||
| Pas de **succès automatique** contre une cible incapable/inconsciente | Med | à corriger |
|
||||
| *Reposition* : contrainte d'allonge non appliquée au déplacement | Med | résiduel assumé |
|
||||
| *Dirty trick* : options **shaken/sickened** manquantes ; pas de retrait par action de mouvement | Med | à corriger |
|
||||
| *Trip* : créatures sans jambes/volantes non trippables ; +2 par paire de jambes | Low | hors périmètre (pas de traits de créature) |
|
||||
| *Overrun* : option « se laisser éviter » non modélisée | Low | hors périmètre |
|
||||
| *Bull rush* : vérifications secondaires contre les créatures sur le passage non modélisées | Low | hors périmètre |
|
||||
| *Drag* : budget de déplacement de l'attaquant non vérifié | Low | hors périmètre |
|
||||
| *Disarm* : à mains nues −4, +10 → lâcher des deux objets, attente d'un round | Low | résiduel assumé |
|
||||
| *Escape* : option « devenir le grappleur » non modélisée | Low | hors périmètre |
|
||||
| *Grapple* : −4 si pas deux mains libres ; restrictions arme légère/Stealth | Low | hors périmètre (pas de modèle de mains) |
|
||||
| *CMD* : double comptage de Dextérité latent pour une créature Tiny avec `cmb` pré-calculé | Low | à corriger |
|
||||
|
||||
#### 2d. Dons et capacités de classe
|
||||
|
||||
| Constat | Sév. | Disposition |
|
||||
|---|---|---|
|
||||
| **Weapon Focus (Pistolet) est un don mort** : le filtre de don compare exactement « Pistol » au nom d'arme « Braises d'Automne (pistolet ?) » → le +1 ne s'applique jamais | Med | à corriger |
|
||||
| Attaque sournoise autorisée contre une **dissimulation partielle** (le SRD bloque dès « concealment » ; le README revendique à tort « total concealment ») | Med | à corriger |
|
||||
| *Up Close And Deadly* s'applique à **toute** attaque à distance ; le SRD l'exige d'une **arme à feu** | Med | à corriger |
|
||||
| *Toughness* : +niveau au lieu de **+3 puis +1 par DV au-delà de 3** (faux aux niveaux 1-2 ; données actuelles ≥ 3) | Low | à corriger |
|
||||
| *Channel Energy* : 2d6 **figés** (pas de progression +1d6 par 2 niveaux) ; le canaliseur est toujours inclus | Low | résiduel assumé |
|
||||
| *Combat Reflexes* : planché à 1 AoO (le SRD autorise 0 avec un modificateur de Dextérité négatif) | Low | résiduel assumé |
|
||||
| *Cackle* : étend les `evil_eye` de **tous** les lanceurs, pas seulement les siens | Low | résiduel assumé |
|
||||
| *Improved Critical* appliqué à **toutes** les armes (SRD : un type d'arme) | Low | résiduel assumé (README documenté) |
|
||||
| *Inspire Courage* : +1 plat (progression +1 par 4 niveaux non modélisée) | Low | résiduel assumé (README documenté) |
|
||||
| *Spell Combat* : le coût de pool arcanique est un no-op (misty n'a pas de pool) | Low | sans objet |
|
||||
|
||||
---
|
||||
|
||||
## 2. Hors périmètre (choix délibéré, documenté)
|
||||
|
||||
Ces règles sont **volontairement** non modélisées et signalées dans le
|
||||
`README.md` (sections « Limites hors périmètre » et « Limites de sorts ») pour
|
||||
ne pas être confondues avec des bogues.
|
||||
|
||||
| Sujet | Raison |
|
||||
|---|---|
|
||||
| **Squeeze** (se serrer dans un passage étroit) | La largeur de couloir n'est pas comparée à la taille ; une créature bloquée par son footprint l'est sans option de squeeze payante |
|
||||
| **Acrobatics « Tumbling »** (traverser une case ennemie sans AoO) | Le moteur n'a pas de système de compétences |
|
||||
| **Créature inerte très grande = obstacle à 2 cases** | Les cases d'une créature inactive ne bloquent pas et ne coûtent pas double |
|
||||
| **Échec d'incantation du personnage sourd (20 %)** | `SpellSpec` n'a pas de composante verbale ; il faudrait encoder les composantes sur les 108 sorts |
|
||||
| **Incantation multi-round (`long` : 3 rounds, 10 min, 1 h)** | Non planifiable dans un tour ; jetée du budget (`downgraded:cast_time`) plutôt qu'approximée |
|
||||
| **Co-occupation au-delà de Tiny vs plus grand** | Deux créatures Small+ ne partagent jamais une case ; plusieurs Tiny sur une case non gérés |
|
||||
| **Combat à deux armes / arme en main secondaire (×0,5 Force)** | Hors du modèle d'attaque actuel |
|
||||
| **Vol 3D / vol par-dessus les obstacles** | La vitesse de vol est un budget au sol ; pas de maniabilité ni de survol du terrain |
|
||||
| **`markers`** (points nommés de la carte) | Validés à la lecture, non appliqués à la résolution |
|
||||
|
||||
---
|
||||
|
||||
## 3. Résiduels assumés (écart connu, non corrigé)
|
||||
|
||||
| Sujet | Nature de l'écart | Décision |
|
||||
|---|---|---|
|
||||
| **Course — durée liée à la Constitution, don Run, armure lourde (×3)** | Le SRD limite la course à `CON` tours puis impose un jet de Constitution DD 10, et réduit la vitesse de course en armure lourde ; le moteur applique toujours ×4 | Assumé : aucun scénario courant ne course plus de quelques tours |
|
||||
| **Dégâts non létaux** | Absents du modèle de PV | Assumé : aucune source de dégâts non létaux dans les données |
|
||||
| **Armes à éclaboussures / dégâts d'éclaboussure** | Absents (pas d'objet concerné dans les données) | Assumé |
|
||||
| **Sort « harmless » sur cible consentante** | Le moteur roule tout de même une sauvegarde (consommation de RNG) ; l'issue du soin n'en dépend pas | Assumé : fidélité de déterminisme, pas d'effet observable |
|
||||
| **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 |
|
||||
| **`selective_channeling` non modélisé** | Le canal d'énergie affecte toutes les créatures du type visé dans la zone | Assumé (documenté au README) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Conformités vérifiées (garde-fous anti-régression)
|
||||
|
||||
Constats explicitement contrôlés et **confirmés conformes** :
|
||||
|
||||
- **Résistance aux sorts** : le drapeau `spell_resistance` est respecté et
|
||||
`_check_spell_resistance` court-circuite sans consommer de RNG quand la cible
|
||||
n'a pas de RM.
|
||||
- **Description de *Lightning Bolt*** (ligne 120 ft, 1d6/niveau plafonné 10d6,
|
||||
Réflexes demi) : conforme.
|
||||
- **Diagonale entrant en terrain difficile** : coût plat de 3 cases, convention
|
||||
de table explicite dans `grid.py`, cohérente entre Dijkstra et descentes.
|
||||
- **Terrain mixte** : les coûts de pas s'additionnent sans double comptage.
|
||||
- **Déplacement d'un personnage aveuglé** : le SRD n'interdit ni le
|
||||
déplacement, ni la course, ni la charge à un aveugle (seuls la touche, la CA
|
||||
et les compétences sont affectés) — le constat d'audit initial ne visait pas
|
||||
une vraie règle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Journal des audits
|
||||
|
||||
| Passe | Domaines | Sessions | Constats | Statut |
|
||||
|---|---|---|---|---|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Relancer une passe d'audit
|
||||
|
||||
Chaque passe suit le même protocole :
|
||||
|
||||
1. Choisir un domaine **non encore audité** (voir le journal § 5).
|
||||
2. Lancer un auditeur read-only (agent `oracle`) avec la base SRD `pathfinder-kb`
|
||||
et l'exigence de citer la règle et la ligne de code fautive.
|
||||
3. Trier chaque constat : **corriger** (si l'impact est matériel et le correctif
|
||||
propre) ou **classer** en hors périmètre / résiduel assumé (ce document).
|
||||
4. Un commit par correctif ; gate complète verte ; transcript doré préservé ou
|
||||
justifié ligne à ligne.
|
||||
5. Reporter les constats dans les tableaux ci-dessus et la ligne de journal.
|
||||
@@ -37,7 +37,10 @@ class AbilitySpec:
|
||||
|
||||
|
||||
# ── Passive combat feats ─────────────────────────────────────────────────────
|
||||
# Weapon Focus: +1 attack with a specific weapon (untyped, CRB)
|
||||
# Weapon Focus: +1 attack with the named weapon GROUP (untyped, CRB) — the
|
||||
# filter matches a weapon name case-insensitively as a substring (the engine
|
||||
# resolves it in CombatEngine._weapon_filter_matches), so "Pistol" covers
|
||||
# sheets that name individual pistols (e.g. "... (pistolet, masterwork)").
|
||||
def weapon_focus(weapon_name: str) -> AbilitySpec:
|
||||
return AbilitySpec(
|
||||
name=f"Weapon Focus ({weapon_name})",
|
||||
@@ -49,16 +52,16 @@ def weapon_focus(weapon_name: str) -> AbilitySpec:
|
||||
)
|
||||
|
||||
|
||||
# Toughness: +1 HP per Hit Die (approximated as +level HP, untyped, CRB)
|
||||
# For simplicity at this stage, we model it as a flat HP bonus.
|
||||
# The engine doesn't yet compute per-HD bonuses, so the caller sets the value.
|
||||
# Toughness (CRB): "+3 hit points. If the character has 4 or more Hit Dice,
|
||||
# she gains +1 hit point per Hit Die." => max(3, HD). The engine doesn't yet
|
||||
# compute per-HD bonuses, so the caller passes the level.
|
||||
def toughness(level: int) -> AbilitySpec:
|
||||
return AbilitySpec(
|
||||
name="Toughness",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="hp", value=level),
|
||||
StatModifier(target="hp", value=max(3, level)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""PF1e area-shape geometry: cone and line templates on the 5-ft square grid.
|
||||
|
||||
PF1e (CRB, Magic — Aiming a Spell):
|
||||
- "A cone-shaped spell shoots away from you in a quarter-circle in the
|
||||
direction you designate. It starts from any corner of your square and
|
||||
widens out as it goes." — modeled here as every cell whose center lies
|
||||
within the cone's length of the origin and within ±45° of the aim
|
||||
direction (a 90°-wide quarter circle), excluding the origin cell.
|
||||
- "A line-shaped spell shoots away from you in a line in the direction you
|
||||
designate. It starts from any corner of your square and extends to the
|
||||
limit of its range [...]. A line-shaped spell affects all creatures in
|
||||
squares through which the line passes." — modeled here as the 5-ft-wide
|
||||
Bresenham ray from the origin through the aim cell, truncated to the
|
||||
line's length. The line "starts from any corner of your square", so the
|
||||
CASTER's own square is NOT one of the affected squares: the affected
|
||||
squares are the ones beyond the origin along the ray (SRD Lightning Bolt:
|
||||
"The bolt begins at your fingertips" — it leaves your square, it does not
|
||||
fill it).
|
||||
|
||||
Bursts need no template here: they are circles around a center cell and are
|
||||
resolved by ``CombatEngine._creatures_in_range`` (unchanged).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import Pos
|
||||
|
||||
|
||||
def cone_cells(
|
||||
grid: Grid, origin: Pos, aim: Pos, radius_cells: int
|
||||
) -> frozenset[Pos]:
|
||||
"""Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``.
|
||||
|
||||
The cone opens toward ``aim`` (any cell in the aim direction, usually the
|
||||
resolved target or point-cast center). A cell is affected when its offset
|
||||
from the origin is within ``radius_cells`` (5-10-5 grid distance), points
|
||||
strictly into the cone's forward half-plane, and stays within ±45° of the
|
||||
aim bearing. The origin cell itself is never affected (CRB: the cone
|
||||
"shoots away from you"). A degenerate aim (origin == aim) affects nothing.
|
||||
|
||||
The ±45° test is exact integer arithmetic: with v the cell offset and d
|
||||
the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to
|
||||
``2 * (v·d)^2 >= |v|^2 * |d|^2`` together with ``v·d > 0``.
|
||||
"""
|
||||
d_row = aim[0] - origin[0]
|
||||
d_col = aim[1] - origin[1]
|
||||
if d_row == 0 and d_col == 0:
|
||||
return frozenset()
|
||||
aim_sq = d_row * d_row + d_col * d_col
|
||||
cells: set[Pos] = set()
|
||||
for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1):
|
||||
for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1):
|
||||
cell = (row, col)
|
||||
if cell == origin or not grid.in_bounds(cell):
|
||||
continue
|
||||
v_row = row - origin[0]
|
||||
v_col = col - origin[1]
|
||||
dot = v_row * d_row + v_col * d_col
|
||||
if dot <= 0:
|
||||
continue
|
||||
if 2 * dot * dot < (v_row * v_row + v_col * v_col) * aim_sq:
|
||||
continue
|
||||
if grid.distance(origin, cell) > radius_cells:
|
||||
continue
|
||||
cells.add(cell)
|
||||
return frozenset(cells)
|
||||
|
||||
|
||||
def line_cells(
|
||||
grid: Grid, origin: Pos, aim: Pos, length_cells: int
|
||||
) -> frozenset[Pos]:
|
||||
"""Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``.
|
||||
|
||||
The line runs along the Bresenham ray from the origin toward ``aim``,
|
||||
extended to ``length_cells`` squares (CRB: it "extends to the limit of
|
||||
its range"). The line "starts from any corner of your square" (CRB, Magic
|
||||
— Aiming a Spell), so the CASTER's own square is NOT affected: the
|
||||
``length_cells`` affected squares are the ones *beyond* the origin along
|
||||
the ray. Out-of-bounds cells simply drop out of the template (creatures
|
||||
only exist in bounds). A degenerate aim (origin == aim) affects nothing.
|
||||
"""
|
||||
if length_cells <= 0:
|
||||
return frozenset()
|
||||
d_row = aim[0] - origin[0]
|
||||
d_col = aim[1] - origin[1]
|
||||
if d_row == 0 and d_col == 0:
|
||||
return frozenset()
|
||||
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
|
||||
# the origin (the dominant-axis step count of a Bresenham line is
|
||||
# max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop.
|
||||
scale = (length_cells + 1) / max(abs(d_row), abs(d_col))
|
||||
end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale))
|
||||
# The line is `length_cells` squares LONG starting just past the caster:
|
||||
# 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning
|
||||
# Bolt: "The bolt begins at your fingertips" — it leaves your square).
|
||||
ray = _bresenham(origin, end)
|
||||
return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell))
|
||||
|
||||
|
||||
def _bresenham(start: Pos, end: Pos) -> list[Pos]:
|
||||
"""Inclusive Bresenham cell list from ``start`` to ``end`` (both ends kept)."""
|
||||
row, col = start
|
||||
end_row, end_col = end
|
||||
d_row = abs(end_row - row)
|
||||
d_col = abs(end_col - col)
|
||||
step_row = 1 if row < end_row else -1
|
||||
step_col = 1 if col < end_col else -1
|
||||
err = d_row - d_col
|
||||
cells: list[Pos] = [(row, col)]
|
||||
while (row, col) != (end_row, end_col):
|
||||
e2 = 2 * err
|
||||
if e2 > -d_col:
|
||||
err -= d_col
|
||||
row += step_row
|
||||
if e2 < d_row:
|
||||
err += d_row
|
||||
col += step_col
|
||||
cells.append((row, col))
|
||||
return cells
|
||||
+1931
-344
File diff suppressed because it is too large
Load Diff
@@ -99,36 +99,46 @@ SICKENED = Condition(
|
||||
)
|
||||
|
||||
# Fatigued: -2 STR, -2 DEX; can't run or charge (CRB)
|
||||
# Approximation: -1 attack (STR/DEX), -1 damage (STR)
|
||||
# Approximation: -1 attack, -1 damage (STR/DEX), -1 AC and -1 Ref (the -2 DEX
|
||||
# = -1 to each, same modelling as exhausted/entangled).
|
||||
FATIGUED = Condition(
|
||||
name="fatigued",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-1),
|
||||
StatModifier(target="damage", value=-1),
|
||||
StatModifier(target="ac", value=-1),
|
||||
StatModifier(target="ref", value=-1),
|
||||
),
|
||||
cant_charge=True,
|
||||
)
|
||||
|
||||
# Exhausted: -6 STR, -6 DEX; half speed; can't run or charge (CRB)
|
||||
# Approximation: -3 attack, -3 damage
|
||||
# Approximation: -3 attack, -3 damage (STR/DEX), -3 AC and -3 Ref (the -6 DEX
|
||||
# = -3 to each, mirroring how ENTANGLED models its -4 DEX as -2 AC/-2 Ref).
|
||||
EXHAUSTED = Condition(
|
||||
name="exhausted",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-3),
|
||||
StatModifier(target="damage", value=-3),
|
||||
StatModifier(target="ac", value=-3),
|
||||
StatModifier(target="ref", value=-3),
|
||||
),
|
||||
cant_charge=True,
|
||||
speed_mult=0.5,
|
||||
)
|
||||
|
||||
# Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB)
|
||||
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX)
|
||||
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX),
|
||||
# -4 to Dexterity skills (mirrors grappled/pinned). Casting while entangled
|
||||
# requires a DC 15 + spell level concentration check — enforced in
|
||||
# ``CombatEngine._spell_cast_interrupted`` (SRD Table 9-1).
|
||||
ENTANGLED = Condition(
|
||||
name="entangled",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="ac", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
StatModifier(target="dex_skills", value=-4), # -4 to Dex skills
|
||||
),
|
||||
cant_charge=True,
|
||||
speed_mult=0.5,
|
||||
@@ -197,9 +207,12 @@ PARALYZED = Condition(
|
||||
)
|
||||
|
||||
# Nauseated: can't attack/cast/concentrate; only single move action (CRB)
|
||||
# SRD "Conditions—Nauseated": "The character is unable to attack, cast
|
||||
# spells... " — being unable to attack also denies attacks of opportunity.
|
||||
NAUSEATED = Condition(
|
||||
name="nauseated",
|
||||
cant_attack=True,
|
||||
cant_aoo=True,
|
||||
)
|
||||
|
||||
# Dazed: can't act, no AC penalty (CRB)
|
||||
@@ -253,6 +266,12 @@ GRAPPLED = Condition(
|
||||
|
||||
|
||||
# Pinned: -4 Dex, -2 attack, no movement, can't cast, -4 AC vs melee (CRB)
|
||||
# SRD "Conditions—Pinned": a pinned creature is tightly bound and can only
|
||||
# attempt to escape (verbal/mental actions + the escape check) — it cannot
|
||||
# attack, so cant_attack applies (the engine's move-only filter then blocks
|
||||
# attacks, maneuvers and casts). Documented gap: the engine never APPLIES
|
||||
# pinned (no grapple-maintain loop), so this definition is exercised only
|
||||
# when the condition is injected directly.
|
||||
PINNED = Condition(
|
||||
name="pinned",
|
||||
modifiers=(
|
||||
@@ -263,6 +282,7 @@ PINNED = Condition(
|
||||
),
|
||||
cant_move=True,
|
||||
cant_charge=True,
|
||||
cant_attack=True,
|
||||
cant_aoo=True,
|
||||
denies_dex_to_ac=True,
|
||||
)
|
||||
@@ -304,9 +324,13 @@ BLEEDING = Condition(
|
||||
# Confused: at the beginning of each turn the subject rolls d% to act randomly
|
||||
# (CRB). The d% behavior is engine logic in ``_take_turn``; simplification:
|
||||
# the "if attacked, attack the attacker" clause is reduced to "nearest
|
||||
# creature". No stat modifiers or behavioral flags.
|
||||
# creature". SRD "Conditions—Confused": a confused creature "will not make
|
||||
# attacks of opportunity against anything that it is not already devoted to
|
||||
# attacking" — simplified to NO AoOs at all (cant_aoo), since the engine has
|
||||
# no "already devoted" concept. No stat modifiers.
|
||||
CONFUSED = Condition(
|
||||
name="confused",
|
||||
cant_aoo=True,
|
||||
)
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
|
||||
|
||||
PF1e bonus types determine whether multiple bonuses to the same stat stack:
|
||||
- Stacking types (dodge, racial, trait, untyped/"") all sum together.
|
||||
- Stacking types (dodge, untyped/"") all sum together (UM Table 2-7: "dodge
|
||||
bonuses always stack"; CRB: "A bonus that doesn't have a type stacks with
|
||||
any bonus").
|
||||
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
|
||||
enhancement, deflection, natural) keep only the highest value per type.
|
||||
- Penalties follow the same rules: same-type penalties don't stack (the
|
||||
least severe — i.e. the max value — is kept).
|
||||
racial, trait, enhancement, deflection, natural, shield, armor,
|
||||
circumstance) keep only the highest value per type (CRB "Combining Magic
|
||||
Effects": "two bonuses of the same type don't stack even if they come from
|
||||
different spells"; APG "Traits": "Trait bonuses do not stack").
|
||||
- Penalties: for a non-stacking type the WORST penalty applies (same-type
|
||||
penalties don't stack), and a same-type bonus and penalty apply together.
|
||||
|
||||
This module is the foundation for spells, conditions, feats, class abilities,
|
||||
and racial traits — all of which produce StatModifier instances that the
|
||||
@@ -31,12 +36,21 @@ BonusType = Literal[
|
||||
"competence", # no stack
|
||||
"insight", # no stack
|
||||
"luck", # no stack
|
||||
"racial", # stacks with itself
|
||||
"trait", # stacks with itself
|
||||
"racial", # no stack (CRB "Combining Magic Effects": same type)
|
||||
"trait", # no stack (APG "Traits": "Trait bonuses do not stack")
|
||||
"enhancement", # no stack
|
||||
"deflection", # no stack
|
||||
"natural", # no stack
|
||||
"shield", # no stack
|
||||
"armor", # no stack (SRD mage armor: "+4 armor bonus to AC")
|
||||
"circumstance", # no stack (simulator can't distinguish circumstances)
|
||||
# SRD "Ultimate Magic" Table 2-7: Bonus Types and Effects — remaining
|
||||
# named types, all non-stacking like every typed bonus (CRB "Combining
|
||||
# Magic Effects": "two bonuses of the same type don't stack").
|
||||
"alchemical", # no stack (UM T2-7: ability scores, saves; antitoxin)
|
||||
"inherent", # no stack (UM T2-7: ability scores; Manual of bodily health)
|
||||
"resistance", # no stack (UM T2-7: saves; cloak of resistance)
|
||||
"size", # no stack (UM T2-7: ability scores, attacks, AC; enlarge person)
|
||||
]
|
||||
|
||||
StatTarget = Literal[
|
||||
@@ -56,7 +70,7 @@ StatTarget = Literal[
|
||||
"dex_skills",
|
||||
]
|
||||
|
||||
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
|
||||
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,21 +129,33 @@ class ActiveCondition:
|
||||
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
|
||||
"""Sum stat modifiers applying PF1e stacking rules.
|
||||
|
||||
Stacking bonus types (dodge, racial, trait, untyped) all sum together.
|
||||
Non-stacking types keep only the highest value per type (max is used,
|
||||
which correctly handles both bonuses and penalties: for bonuses the
|
||||
highest is most beneficial, for penalties the highest is least severe).
|
||||
Stacking bonus types (dodge, untyped) all sum together. For each
|
||||
non-stacking type the best BONUS (max, floor 0) and the worst PENALTY
|
||||
(min) are kept separately and then summed — same-type bonuses don't
|
||||
stack (highest applies), same-type penalties don't stack (worst
|
||||
applies), and a bonus and a penalty of the same type apply together
|
||||
(CRB "Common Terms — Stacking": "Penalties and bonuses generally stack
|
||||
with one another").
|
||||
|
||||
The caller is responsible for filtering by condition and weapon before
|
||||
calling — this function performs pure stacking math.
|
||||
"""
|
||||
stacking_total = 0
|
||||
best_by_type: dict[str, int] = {}
|
||||
best_bonus_by_type: dict[str, int] = {}
|
||||
worst_penalty_by_type: dict[str, int] = {}
|
||||
for mod in modifiers:
|
||||
if mod.bonus_type in _STACKING_TYPES:
|
||||
stacking_total += mod.value
|
||||
else:
|
||||
prev = best_by_type.get(mod.bonus_type)
|
||||
elif mod.value >= 0:
|
||||
prev = best_bonus_by_type.get(mod.bonus_type)
|
||||
if prev is None or mod.value > prev:
|
||||
best_by_type[mod.bonus_type] = mod.value
|
||||
return stacking_total + sum(best_by_type.values())
|
||||
best_bonus_by_type[mod.bonus_type] = mod.value
|
||||
else:
|
||||
prev = worst_penalty_by_type.get(mod.bonus_type)
|
||||
if prev is None or mod.value < prev:
|
||||
worst_penalty_by_type[mod.bonus_type] = mod.value
|
||||
return (
|
||||
stacking_total
|
||||
+ sum(best_bonus_by_type.values())
|
||||
+ sum(worst_penalty_by_type.values())
|
||||
)
|
||||
|
||||
@@ -154,6 +154,7 @@ _NOOP_FEATS: frozenset[str] = frozenset({
|
||||
"animal (race)",
|
||||
"drake (race)",
|
||||
"orange - lazy ass",
|
||||
"plot armor",
|
||||
})
|
||||
|
||||
|
||||
@@ -208,6 +209,8 @@ def load_combatant(path: Path) -> Combatant:
|
||||
|
||||
``features`` in the JSON are a list of feat name strings resolved via
|
||||
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
|
||||
The ``aware`` flag (SRD "Surprise") is a plain boolean of the
|
||||
``Combatant`` schema (default ``true``), so it parses directly here.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -293,10 +293,16 @@ def cover_bonus_cells(
|
||||
)
|
||||
# PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and,
|
||||
# for a multi-square creature, the occupied square) that gives it the best
|
||||
# shot, so it suffers only the MINIMUM cover over its own cells. The
|
||||
# target side follows the same choice rule: RAW checks ANY line to ANY
|
||||
# corner of the (chosen) square, scored by the MAXIMUM over corner lines —
|
||||
# union over all target cells for ranged, best single cell for melee.
|
||||
# shot, so it suffers only the MINIMUM cover over its own cells. Ranged:
|
||||
# "choose a corner of your square. If any line from this corner to any
|
||||
# corner of the target's square passes through cover, the target has
|
||||
# cover" — each attacker corner is scored by the MAXIMUM over the target's
|
||||
# corner lines (union over all target cells), and the attacker picks its
|
||||
# BEST corner: MIN over attacker corners. Melee: the attacker may also
|
||||
# pick any single square of a multi-square target (CRB "Big Creatures and
|
||||
# Cover"), so the target side is a MIN over cells; the attacker side
|
||||
# stays the worst-corner rule ("any line from ANY corner of your
|
||||
# square").
|
||||
per_cell_best = (
|
||||
min(
|
||||
max(
|
||||
@@ -307,11 +313,13 @@ def cover_bonus_cells(
|
||||
for cell_t in target_cells
|
||||
)
|
||||
if not ranged
|
||||
else max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
else min(
|
||||
max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
for corner_a in _corners(cell_a)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
for cell_a in attacker_cells
|
||||
)
|
||||
|
||||
@@ -242,14 +242,18 @@ class AttackSpec(_FrozenModel):
|
||||
increment and against full AC beyond it. `two_handed` marks a weapon swung
|
||||
in two hands (two-handed weapon, one-handed weapon used two hands, or a
|
||||
primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage
|
||||
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
|
||||
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
|
||||
with the reach weapon quality (CRB "Big and Little Creatures in Combat"):
|
||||
it doubles the wielder's NATURAL reach but cannot strike at the wielder's
|
||||
natural reach or less — the usable zone is a RING (dead zone = natural
|
||||
reach). Without the flag a melee attack follows the creature's natural
|
||||
reach, which is a DISK: a Large creature's natural attack (its JSON
|
||||
`reach_ft` IS its natural reach, 10 ft) still strikes and threatens
|
||||
adjacent squares.
|
||||
adjacent squares. `natural` marks a natural weapon (bite, claw, etc.):
|
||||
PF1e (CRB "Natural Attacks") grants no BAB iteratives for natural
|
||||
weapons. `secondary` marks a secondary natural attack (tail, wing, etc.):
|
||||
the engine applies -5 to the attack bonus (reduced to -2 with the
|
||||
Multiattack feat).
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -266,6 +270,8 @@ class AttackSpec(_FrozenModel):
|
||||
count: int = Field(default=1, ge=1)
|
||||
firearm: bool = False
|
||||
two_handed: bool = False
|
||||
natural: bool = False
|
||||
secondary: bool = False
|
||||
|
||||
|
||||
class DamageReduction(_FrozenModel):
|
||||
@@ -309,3 +315,8 @@ class Combatant(_FrozenModel):
|
||||
spell_resistance: int = 0
|
||||
resistances: dict[str, int] = Field(default_factory=dict)
|
||||
immunities: tuple[str, ...] = ()
|
||||
# PF1e (SRD "Surprise"): "If some but not all of the combatants are aware
|
||||
# of their opponents, a surprise round happens before regular rounds
|
||||
# begin." Unaware combatants do not act in the surprise round. Default
|
||||
# True keeps every existing encounter on a full first round.
|
||||
aware: bool = True
|
||||
|
||||
+449
-44
@@ -19,26 +19,159 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
|
||||
|
||||
from pf1e_simulator.dice import DiceExpr, parse_dice
|
||||
from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice
|
||||
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pf1e_simulator.loaders.foundry import Json
|
||||
|
||||
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
||||
|
||||
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
||||
SaveType = Literal["fort", "ref", "will", "none"]
|
||||
# PF1e area templates (CRB, Magic - Aiming a Spell): burst (circle around a
|
||||
# point), cone (90° quarter circle from the caster), line (5-ft ray from the
|
||||
# caster to its length).
|
||||
AreaShape = Literal["burst", "cone", "line"]
|
||||
# PF1e area targeting (SRD spells/bless.md, spells/bane.md): a burst may
|
||||
# affect only allies ("The caster and all allies within a 50-ft. burst") or
|
||||
# only enemies ("Enemies within the area..."); the default affects everyone.
|
||||
AreaTargets = Literal["all", "allies", "enemies"]
|
||||
# PF1e casting times, normalized to budget codes (see combat._cast_time_slot).
|
||||
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DamageDieScaling:
|
||||
"""Per-caster-level extra damage dice (PF1e: "1d6 per caster level, max 10d6").
|
||||
|
||||
- dice: the dice added per scaling step (e.g. 1d6)
|
||||
- per_levels: caster levels required per added die
|
||||
- max_dice: cap on the total dice added by the scaling
|
||||
"""
|
||||
dice: DiceExpr
|
||||
per_levels: int
|
||||
max_dice: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DamageFlatScaling:
|
||||
"""Per-caster-level flat damage bonus (PF1e: "+1 per caster level, max +5").
|
||||
|
||||
- amount: flat points added per scaling step
|
||||
- per_levels: caster levels required per added step
|
||||
- max_amount: cap on the total flat bonus from scaling (JSON key "max")
|
||||
"""
|
||||
amount: int
|
||||
per_levels: int
|
||||
max_amount: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectileScaling:
|
||||
"""Multi-projectile count scaling with caster level.
|
||||
|
||||
PF1e (SRD spells/magic-missile.md): "one missile, plus one additional
|
||||
missile per two caster levels beyond 1st [...] maximum of five missiles";
|
||||
(SRD spells/scorching-ray.md): "one ray, plus one additional ray for every
|
||||
four levels beyond 3rd (to a maximum of three rays at 11th level)".
|
||||
|
||||
- base: projectiles at the reference level (usually 1)
|
||||
- at_level: caster level from which extra projectiles begin counting
|
||||
- per_levels: caster levels required per additional projectile
|
||||
- max_projectiles: cap (JSON key "max")
|
||||
"""
|
||||
base: int
|
||||
at_level: int
|
||||
per_levels: int
|
||||
max_projectiles: int
|
||||
|
||||
def count_for(self, caster_level: int) -> int:
|
||||
"""Projectiles fired by a caster of ``caster_level``.
|
||||
|
||||
``base + (caster_level - at_level) // per_levels``, the level delta
|
||||
floored at 0 (a caster below the reference level still fires the base
|
||||
projectiles), capped at ``max_projectiles``.
|
||||
"""
|
||||
extra = max(0, caster_level - self.at_level) // self.per_levels
|
||||
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)
|
||||
class DamageEffect:
|
||||
formula: DiceExpr
|
||||
types: tuple[str, ...]
|
||||
# False = save negates (targeted); True = half damage on save (ray/line/cone/AoE)
|
||||
half_on_save: bool = False
|
||||
# Only consulted when half_on_save is False. True = a successful save deals
|
||||
# no damage (save negates, the targeted-spell default); False = the save has
|
||||
# no effect on this damage at all (SRD Sound Burst: "Fortitude partial"
|
||||
# negates only the stun — every creature takes the full dice regardless).
|
||||
save_negates: bool = True
|
||||
# PF1e caster-level scaling (CRB Magic — Caster Level; e.g. Fireball
|
||||
# "1d6 points of fire damage per caster level (maximum 10d6)"). Absent =
|
||||
# level-independent damage, the pre-S1b behaviour.
|
||||
scaling: DamageDieScaling | None = None
|
||||
scaling_flat: DamageFlatScaling | None = None
|
||||
# PF1e multi-projectile spells (Magic Missile, Scorching Ray): the effect
|
||||
# fires `count_for(caster_level)` projectiles, each rolling its own damage
|
||||
# and (for a ray) its own attack. Absent = a single projectile.
|
||||
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:
|
||||
"""The damage formula resolved for a caster of ``caster_level``.
|
||||
|
||||
PF1e (CRB, Magic — Caster Level): spells whose damage is "per caster
|
||||
level" grow with the caster and stop at the stated cap. Extra dice =
|
||||
min(max_dice, level // per_levels); flat bonus = min(max,
|
||||
(level // per_levels) * amount). The loader guarantees the scaling
|
||||
dice share the base formula's die type (or the base is flat).
|
||||
"""
|
||||
count = self.formula.count
|
||||
sides = self.formula.sides
|
||||
bonus = self.formula.bonus
|
||||
if self.scaling is not None:
|
||||
extra = min(
|
||||
self.scaling.max_dice, caster_level // self.scaling.per_levels
|
||||
) * self.scaling.dice.count
|
||||
if count == 0 and extra > 0:
|
||||
sides = self.scaling.dice.sides
|
||||
count += extra
|
||||
if self.scaling_flat is not None:
|
||||
bonus += min(
|
||||
self.scaling_flat.max_amount,
|
||||
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
|
||||
)
|
||||
return DiceExpr(count=count, sides=sides, bonus=bonus)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -46,32 +179,106 @@ class ConditionEffect:
|
||||
condition_name: str
|
||||
duration_rounds: int
|
||||
save_negates: bool = True
|
||||
# PF1e partial save (SRD spells/fear.md): on a SUCCESSFUL save the target
|
||||
# is not spared outright but suffers a weaker condition instead. Encoded as
|
||||
# (condition_name, duration_rounds); None = a successful save fully negates.
|
||||
save_partial: tuple[str, int] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealEffect:
|
||||
formula: DiceExpr
|
||||
# PF1e caster-level scaling for healing (SRD spells/cure-light-wounds.md:
|
||||
# "cures 1d8 points of damage +1 point per caster level (maximum +5)" and
|
||||
# cure-moderate-wounds.md: "cures 2d8 points of damage +1 point per caster
|
||||
# level (maximum +10)"). Absent = level-independent healing, the pre-S5b
|
||||
# behaviour.
|
||||
scaling_flat: DamageFlatScaling | None = None
|
||||
|
||||
def effective_formula(self, caster_level: int) -> DiceExpr:
|
||||
"""The healing formula resolved for a caster of ``caster_level``.
|
||||
|
||||
PF1e (CRB, Magic — Caster Level): the flat bonus grows with the caster
|
||||
and stops at the stated cap — bonus = min(max, (level // per_levels)
|
||||
* amount), mirroring ``DamageEffect.effective_formula``.
|
||||
"""
|
||||
bonus = self.formula.bonus
|
||||
if self.scaling_flat is not None:
|
||||
bonus += min(
|
||||
self.scaling_flat.max_amount,
|
||||
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
|
||||
)
|
||||
return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuffEffect:
|
||||
"""Transient stat modifiers (PF1e buff spells).
|
||||
|
||||
``scaling_flat`` (SRD shield of faith "+2 deflection, +1 per six caster
|
||||
levels (maximum +5)", divine favor "+1 per three caster levels (maximum
|
||||
+3)") adds the resolved flat bonus to EVERY modifier's value at cast
|
||||
time, mirroring ``DamageEffect.effective_formula``'s flat scaling.
|
||||
"""
|
||||
|
||||
modifiers: tuple[StatModifier, ...]
|
||||
duration_rounds: int
|
||||
scaling_flat: DamageFlatScaling | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PenaltyEffect:
|
||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||
|
||||
The engine has no ability-score track, so the Strength penalty is
|
||||
modeled on the two combat rolls Strength feeds — attack and damage
|
||||
rolls — as negative untyped StatModifiers for the spell's duration.
|
||||
"""
|
||||
formula: DiceExpr
|
||||
scaling_flat: DamageFlatScaling | None = None
|
||||
targets: tuple[StatTarget, ...] = ()
|
||||
duration_rounds_per_level: int = 1
|
||||
half_on_save: bool = True
|
||||
|
||||
def effective_formula(self, caster_level: int) -> DiceExpr:
|
||||
"""The penalty formula resolved for a caster of ``caster_level``.
|
||||
|
||||
PF1e (CRB, Magic — Caster Level): the flat bonus grows with the
|
||||
caster and stops at the stated cap — bonus = min(max, (level //
|
||||
per_levels) * amount), mirroring ``DamageEffect.effective_formula``.
|
||||
"""
|
||||
bonus = self.formula.bonus
|
||||
if self.scaling_flat is not None:
|
||||
bonus += min(
|
||||
self.scaling_flat.max_amount,
|
||||
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
|
||||
)
|
||||
return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AreaEffect:
|
||||
"""Area of effect spell (burst only for Phase 0).
|
||||
"""Area of effect spell (burst / cone / line).
|
||||
|
||||
- shape: only "burst" supported (circle around target point)
|
||||
- radius_ft: radius in feet (e.g., 20 for fireball)
|
||||
PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on a chosen
|
||||
point; a cone "shoots away from you in a quarter-circle in the direction
|
||||
you designate"; a line "shoots away from you in a line in the direction
|
||||
you designate" and "affects all creatures in squares through which the
|
||||
line passes". Cones and lines therefore originate at the CASTER and are
|
||||
aimed at the resolved target cell (or the U7b point-cast center).
|
||||
|
||||
- shape: "burst" (circle), "cone" (90° quarter circle) or "line" (5 ft wide)
|
||||
- radius_ft: burst radius / cone length / line length, in feet
|
||||
- effects: the effects applied to each creature in the area
|
||||
- save_for_half: if True, successful save halves damage
|
||||
- targets: which creatures in the area are affected ("all", "allies",
|
||||
"enemies" — SRD bless/bane bursts)
|
||||
"""
|
||||
shape: Literal["burst"] = "burst"
|
||||
shape: AreaShape = "burst"
|
||||
radius_ft: int = 20
|
||||
effects: tuple[SpellEffect, ...] = ()
|
||||
save_for_half: bool = True
|
||||
targets: AreaTargets = "all"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SummonEffect:
|
||||
@@ -84,7 +291,10 @@ class SummonEffect:
|
||||
duration_rounds: int
|
||||
|
||||
|
||||
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
||||
SpellEffect = (
|
||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
| PenaltyEffect | AreaEffect | SummonEffect
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -100,6 +310,11 @@ class SpellSpec:
|
||||
# area/save-only spells keep this False.
|
||||
requires_ranged_touch: bool = False
|
||||
cast_time: CastTime = "standard"
|
||||
# PF1e (CRB, Magic — Spell Resistance): most spells are subject to a
|
||||
# target's SR, but the SRD stat block marks a handful "Spell Resistance:
|
||||
# no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check
|
||||
# entirely (targeted and per-creature in areas). Default True.
|
||||
spell_resistance: bool = True
|
||||
effects: tuple[SpellEffect, ...] = ()
|
||||
description: str = ""
|
||||
|
||||
@@ -190,6 +405,14 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
|
||||
return value in ("fort", "ref", "will", "none")
|
||||
|
||||
|
||||
def _is_area_shape(value: str) -> TypeGuard[AreaShape]:
|
||||
return value in get_args(AreaShape)
|
||||
|
||||
|
||||
def _is_area_targets(value: str) -> TypeGuard[AreaTargets]:
|
||||
return value in get_args(AreaTargets)
|
||||
|
||||
|
||||
def _is_cast_time(value: str) -> TypeGuard[CastTime]:
|
||||
return value in get_args(CastTime)
|
||||
|
||||
@@ -227,16 +450,21 @@ def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
|
||||
return value in get_args(StatTarget)
|
||||
|
||||
|
||||
def _parse_stat_target(value: Json) -> StatTarget:
|
||||
target = _as_str(value)
|
||||
if not _is_stat_target(target):
|
||||
msg = f"unknown stat target: {target!r}"
|
||||
raise ValueError(msg)
|
||||
return target
|
||||
|
||||
|
||||
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
|
||||
return value in get_args(BonusType)
|
||||
|
||||
|
||||
def _parse_modifier(value: Json) -> StatModifier:
|
||||
m = _as_dict(value)
|
||||
target = _as_str(m["target"])
|
||||
if not _is_stat_target(target):
|
||||
msg = f"unknown stat target: {target!r}"
|
||||
raise ValueError(msg)
|
||||
target = _parse_stat_target(m["target"])
|
||||
bonus = _as_str(m.get("bonus_type", ""))
|
||||
if not _is_bonus_type(bonus):
|
||||
msg = f"unknown bonus type: {bonus!r}"
|
||||
@@ -250,43 +478,219 @@ def _parse_modifier(value: Json) -> StatModifier:
|
||||
)
|
||||
|
||||
|
||||
def _parse_die_scaling(value: Json) -> DamageDieScaling:
|
||||
data = _as_dict(value)
|
||||
dice = parse_dice(_as_str(data["dice"]))
|
||||
per_levels = _as_int(data.get("per_levels", 1))
|
||||
if per_levels < 1:
|
||||
msg = f"scaling per_levels must be >= 1, got {per_levels}"
|
||||
raise ValueError(msg)
|
||||
max_dice = _as_int(data["max_dice"])
|
||||
if max_dice < 0:
|
||||
msg = f"scaling max_dice must be >= 0, got {max_dice}"
|
||||
raise ValueError(msg)
|
||||
return DamageDieScaling(dice=dice, per_levels=per_levels, max_dice=max_dice)
|
||||
|
||||
|
||||
def _parse_flat_scaling(value: Json) -> DamageFlatScaling:
|
||||
data = _as_dict(value)
|
||||
amount = _as_int(data.get("amount", 1))
|
||||
per_levels = _as_int(data.get("per_levels", 1))
|
||||
if per_levels < 1:
|
||||
msg = f"scaling_flat per_levels must be >= 1, got {per_levels}"
|
||||
raise ValueError(msg)
|
||||
max_amount = _as_int(data["max"])
|
||||
if max_amount < 0:
|
||||
msg = f"scaling_flat max must be >= 0, got {max_amount}"
|
||||
raise ValueError(msg)
|
||||
return DamageFlatScaling(
|
||||
amount=amount, per_levels=per_levels, max_amount=max_amount
|
||||
)
|
||||
|
||||
|
||||
def _parse_projectiles(value: Json) -> ProjectileScaling:
|
||||
data = _as_dict(value)
|
||||
base = _as_int(data.get("base", 1))
|
||||
at_level = _as_int(data.get("at_level", 1))
|
||||
per_levels = _as_int(data["per_levels"])
|
||||
if base < 1:
|
||||
msg = f"projectiles base must be >= 1, got {base}"
|
||||
raise ValueError(msg)
|
||||
if per_levels < 1:
|
||||
msg = f"projectiles per_levels must be >= 1, got {per_levels}"
|
||||
raise ValueError(msg)
|
||||
if at_level < 1:
|
||||
msg = f"projectiles at_level must be >= 1, got {at_level}"
|
||||
raise ValueError(msg)
|
||||
max_projectiles = _as_int(data["max"])
|
||||
if max_projectiles < base:
|
||||
msg = f"projectiles max must be >= base, got {max_projectiles} < {base}"
|
||||
raise ValueError(msg)
|
||||
return ProjectileScaling(
|
||||
base=base,
|
||||
at_level=at_level,
|
||||
per_levels=per_levels,
|
||||
max_projectiles=max_projectiles,
|
||||
)
|
||||
|
||||
|
||||
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_save_partial(value: Json) -> tuple[str, int]:
|
||||
data = _as_dict(value)
|
||||
duration = _as_int(data["duration_rounds"])
|
||||
if duration < 1:
|
||||
msg = f"on_success duration_rounds must be >= 1, got {duration}"
|
||||
raise ValueError(msg)
|
||||
return (_as_str(data["condition_name"]), duration)
|
||||
|
||||
|
||||
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
|
||||
formula = parse_dice(_as_str(effect["formula"]))
|
||||
scaling_raw = effect.get("scaling")
|
||||
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
|
||||
if (
|
||||
scaling is not None
|
||||
and formula.count > 0
|
||||
and scaling.dice.sides != formula.sides
|
||||
):
|
||||
msg = (
|
||||
"damage scaling dice must match the base formula die type: "
|
||||
f"{dice_to_str(scaling.dice)} vs {dice_to_str(formula)}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
proj_raw = effect.get("projectiles")
|
||||
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(
|
||||
formula=formula,
|
||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
||||
half_on_save=bool(effect.get("half_on_save", False)),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
scaling=scaling,
|
||||
scaling_flat=scaling_flat,
|
||||
projectiles=projectiles,
|
||||
recurring=recurring,
|
||||
)
|
||||
|
||||
|
||||
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
||||
partial_raw = effect.get("on_success")
|
||||
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
||||
return ConditionEffect(
|
||||
condition_name=_as_str(effect["condition_name"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
save_partial=save_partial,
|
||||
)
|
||||
|
||||
|
||||
def _parse_heal_effect(effect: dict[str, Json]) -> HealEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
return HealEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
scaling_flat=scaling_flat,
|
||||
)
|
||||
|
||||
|
||||
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
return BuffEffect(
|
||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||
scaling_flat=scaling_flat,
|
||||
)
|
||||
|
||||
|
||||
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
targets: tuple[StatTarget, ...] = tuple(
|
||||
_parse_stat_target(t) for t in _as_list(effect["targets"])
|
||||
)
|
||||
return PenaltyEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
scaling_flat=scaling_flat,
|
||||
targets=targets,
|
||||
duration_rounds_per_level=_as_int(effect.get("duration_rounds_per_level", 1)),
|
||||
half_on_save=bool(effect.get("half_on_save", True)),
|
||||
)
|
||||
|
||||
|
||||
def _parse_area_effect(effect: dict[str, Json]) -> AreaEffect:
|
||||
shape_raw = _as_str(effect.get("shape", "burst"))
|
||||
if not _is_area_shape(shape_raw):
|
||||
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
|
||||
raise ValueError(msg)
|
||||
targets_raw = _as_str(effect.get("targets", "all"))
|
||||
if not _is_area_targets(targets_raw):
|
||||
msg = (
|
||||
f"unknown area targets: {targets_raw!r} "
|
||||
f"(expected one of {get_args(AreaTargets)})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return AreaEffect(
|
||||
shape=shape_raw,
|
||||
radius_ft=_as_int(effect.get("radius_ft", 20)),
|
||||
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||
save_for_half=bool(effect.get("save_for_half", True)),
|
||||
targets=targets_raw,
|
||||
)
|
||||
|
||||
|
||||
def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect:
|
||||
return SummonEffect(
|
||||
creature=_as_str(effect["creature"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
)
|
||||
|
||||
|
||||
_EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
|
||||
"damage": _parse_damage_effect,
|
||||
"condition": _parse_condition_effect,
|
||||
"heal": _parse_heal_effect,
|
||||
"buff": _parse_buff_effect,
|
||||
"penalty": _parse_penalty_effect,
|
||||
"area": _parse_area_effect,
|
||||
"summon": _parse_summon_effect,
|
||||
}
|
||||
|
||||
|
||||
def _parse_effect(data: Json) -> SpellEffect:
|
||||
effect = _as_dict(data)
|
||||
effect_type = _as_str(effect["type"])
|
||||
if effect_type == "damage":
|
||||
return DamageEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
||||
half_on_save=bool(effect.get("half_on_save", False)),
|
||||
)
|
||||
if effect_type == "condition":
|
||||
return ConditionEffect(
|
||||
condition_name=_as_str(effect["condition_name"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
)
|
||||
if effect_type == "heal":
|
||||
return HealEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
)
|
||||
if effect_type == "buff":
|
||||
return BuffEffect(
|
||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||
)
|
||||
if effect_type == "area":
|
||||
return AreaEffect(
|
||||
radius_ft=_as_int(effect.get("radius_ft", 20)),
|
||||
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||
save_for_half=bool(effect.get("save_for_half", True)),
|
||||
)
|
||||
if effect_type == "summon":
|
||||
return SummonEffect(
|
||||
creature=_as_str(effect["creature"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
)
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
parser = _EFFECT_PARSERS.get(effect_type)
|
||||
if parser is None:
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
return parser(effect)
|
||||
|
||||
|
||||
def load_spell(path: str | Path) -> SpellSpec:
|
||||
@@ -310,6 +714,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
||||
base_dc=_as_int(data.get("base_dc", 10)),
|
||||
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
|
||||
cast_time=cast_time,
|
||||
spell_resistance=bool(data.get("spell_resistance", True)),
|
||||
effects=effects,
|
||||
description=_as_str(data.get("description", "")),
|
||||
)
|
||||
|
||||
@@ -158,26 +158,27 @@ def test_total_concealment_denies_sneak_attack() -> None:
|
||||
assert not any("sneak" in line for line in eng._transcript)
|
||||
|
||||
|
||||
def test_partial_concealment_still_allows_sneak_attack() -> None:
|
||||
"""(A7) a blurred (20%) target CAN be sneak attacked — CRB rogue text is
|
||||
"cannot sneak attack while striking a creature with total concealment",
|
||||
so partial concealment no longer denies the precision damage."""
|
||||
def test_partial_concealment_denies_sneak_attack() -> None:
|
||||
"""(A7) a blurred (20%) target CANNOT be sneak attacked — SRD rogue text
|
||||
is "A rogue cannot sneak attack while striking a creature with
|
||||
concealment": ANY concealment denies the precision damage (the rogue
|
||||
must see the target well enough to pick out a vital spot)."""
|
||||
sneak = _combatant("atk", level=3, features=("Sneak Attack",))
|
||||
eng = _engine(
|
||||
[sneak, _combatant("tgt")],
|
||||
[(0, 0), (0, 1)],
|
||||
["players", "monsters"],
|
||||
[15, 90, 6, 6, 4],
|
||||
[15, 90, 6],
|
||||
)
|
||||
a, t = eng._states
|
||||
_add_condition(t, "flat_footed")
|
||||
_add_condition(t, "blurred")
|
||||
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
|
||||
# d20=15 hits, d100=90 > 20% lands; 2d6 sneak (6+6=12) + 1d6 weapon (4)
|
||||
# = 16 precision-inclusive damage.
|
||||
assert result.damage == 16
|
||||
assert t.hp == 84
|
||||
assert any("sneak attack" in line and "2d6" in line for line in eng._transcript)
|
||||
# d20=15 hits, d100=90 > 20% lands; sneak denied by the 20% concealment,
|
||||
# so only the 1d6 weapon die (6) lands.
|
||||
assert result.damage == 6
|
||||
assert t.hp == 94
|
||||
assert not any("sneak" in line for line in eng._transcript)
|
||||
|
||||
|
||||
def test_concealment_miss_does_not_consume_deed() -> None:
|
||||
@@ -337,7 +338,13 @@ def test_firing_into_melee_minus4() -> None:
|
||||
assert t.hp == 100
|
||||
|
||||
|
||||
def test_firing_into_melee_minus2_when_target_10ft_away() -> None:
|
||||
def test_firing_into_melee_not_avoided_when_ally_engaged_5ft_away() -> None:
|
||||
# PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target
|
||||
# [...] is at least 10 feet away from the nearest friendly character, you
|
||||
# can avoid the -4 penalty" — the exemption is measured from the TARGET to
|
||||
# the attacker's nearest friendly character, not from the attacker. The
|
||||
# archer is 10 ft from the target, but the target is engaged at 5 ft by
|
||||
# the archer's own ally (the guard) -> the -4 still applies.
|
||||
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
|
||||
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
|
||||
guard = _combatant("guard", ac=99)
|
||||
@@ -349,8 +356,32 @@ def test_firing_into_melee_minus2_when_target_10ft_away() -> None:
|
||||
)
|
||||
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.melee_penalty == -4
|
||||
assert result.total == 13
|
||||
assert result.hit is False
|
||||
assert t.hp == 100
|
||||
|
||||
|
||||
def test_firing_into_melee_avoided_when_nearest_ally_20ft_away() -> None:
|
||||
# The target is engaged (a third-party melee threat stands next to it),
|
||||
# but the attacker's nearest friendly character is 20 ft from the target
|
||||
# -> the -4 is avoided even though the archer itself is only 5 ft away.
|
||||
# The OLD (buggy) rule measured attacker->target/attacker->threat and
|
||||
# would wrongly apply the -4 here.
|
||||
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
|
||||
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
|
||||
ally = _combatant("ally", ac=99)
|
||||
raider = _combatant("raider") # neither the archer's side nor the target's
|
||||
eng = _engine(
|
||||
[archer, tgt, ally, raider],
|
||||
[(0, 0), (0, 1), (0, 5), (1, 2)],
|
||||
["players", "monsters", "players", "gnolls"],
|
||||
[10, 3],
|
||||
)
|
||||
a, t, _al, _r = eng._states
|
||||
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
|
||||
assert result.melee_penalty == 0
|
||||
assert result.total == 18
|
||||
assert result.hit is True
|
||||
assert result.damage == 3
|
||||
|
||||
|
||||
+156
-9
@@ -127,6 +127,24 @@ def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid:
|
||||
return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend))
|
||||
|
||||
|
||||
def _walled_grid(size: int = 16) -> Grid:
|
||||
"""Open grid with a wall block at rows 1-4, col 5 (a straight-line barrier).
|
||||
|
||||
A hero at (0, 0) running toward a foe at (2, 8) would have to turn the
|
||||
corner around the wall (a Dijkstra ``move`` does); the straight-line run
|
||||
must instead stop at the wall face.
|
||||
"""
|
||||
terrain = []
|
||||
for r in range(size):
|
||||
row = ["#" if (1 <= r <= 4 and c == 5) else "." for c in range(size)]
|
||||
terrain.append("".join(row))
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
return Grid.from_spec(MapSpec(name="walled", terrain=tuple(terrain), legend=legend))
|
||||
|
||||
|
||||
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
|
||||
|
||||
|
||||
@@ -275,6 +293,32 @@ def test_failed_aid_grants_nothing() -> None:
|
||||
assert hero.hp == 97
|
||||
|
||||
|
||||
# ── (c2) the aid bonus expires at the AIDER's next turn, not the beneficiary's ─
|
||||
|
||||
def test_aid_bonus_survives_beneficiary_turn_and_expires_at_aider_turn() -> None:
|
||||
# SRD "Aid Another": the +2 lasts "as long as that attack comes before the
|
||||
# beginning of your next turn" — your = the AIDER's. The ally aids the
|
||||
# hero (ac) in round 1; the hero's own round-2 turn start must NOT clear
|
||||
# the bonus, the ally's round-2 turn start must.
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
cid = state.combatant.id
|
||||
if cid == "hero" and engine._current_round == 2:
|
||||
seen["hero_turn"] = state.aid_ac_bonus
|
||||
if cid == "foe" and engine._current_round == 2:
|
||||
seen["foe_turn"] = engine._states[0].aid_ac_bonus
|
||||
if cid == "ally" and engine._current_round == 1:
|
||||
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
|
||||
return _WAIT
|
||||
|
||||
# Rolls: 3 initiatives (10,10,10); aid d20=10+5=15 >= 10 success.
|
||||
eng = _trio(policy, [10, 10, 10, 10], round_cap=2)
|
||||
eng.run()
|
||||
assert seen["hero_turn"] == 2 # survived the beneficiary's turn start
|
||||
assert seen["foe_turn"] == 0 # expired at the aider's turn start
|
||||
|
||||
|
||||
# ── (d) run moves up to 4x speed (destination and target), sets running ──────
|
||||
|
||||
|
||||
@@ -340,7 +384,9 @@ def test_run_toward_target_outreaches_normal_move() -> None:
|
||||
)
|
||||
eng2.run()
|
||||
move_hero = eng2._states[0]
|
||||
assert move_hero.pos == (6, 6) # only 6 cells of the 15-cell path
|
||||
# 5-10-5: 4 diagonal cells cost 1+2+1+2 = 6 = the speed budget; the old
|
||||
# cell-count budget let a speed-30 walker cover 6 diagonal cells.
|
||||
assert move_hero.pos == (4, 4) # only 4 diagonal cells of the 15-cell path
|
||||
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
|
||||
|
||||
|
||||
@@ -368,12 +414,13 @@ def test_run_stops_before_difficult_terrain_toward_target() -> None:
|
||||
assert eng._states[0].pos == (2, 0)
|
||||
|
||||
|
||||
def test_run_stops_before_difficult_terrain_to_destination() -> None:
|
||||
def test_run_beyond_difficult_terrain_to_destination_is_rejected() -> None:
|
||||
"""Given: the same rubble row
|
||||
When: hero runs to an explicit destination beyond it
|
||||
Then: the descent stops in front of the rubble (the destination is
|
||||
reachable in budget, but the run cannot cross — it moves up to the
|
||||
obstacle and stops)."""
|
||||
When: hero runs to an explicit destination ONLY reachable across it
|
||||
Then: the destination is rejected (unreachable_destination) — the
|
||||
reachability check uses the same avoid-difficult constraint as the
|
||||
descent (CRB: "You can't run or charge across difficult terrain"), so
|
||||
the verdict and the walked path agree instead of silently stopping short."""
|
||||
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "hero":
|
||||
@@ -382,9 +429,28 @@ def test_run_stops_before_difficult_terrain_to_destination() -> None:
|
||||
|
||||
grid = _rubble_row_grid(size=8, row=3)
|
||||
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
# The default foe sits at (0, 1), adjacent to the start: the run walks
|
||||
# out of its threatened disk and provokes one (missed) AoO.
|
||||
eng = _duo(policy, [10, 5, 1, 1], round_cap=1, hero=hero, grid=grid)
|
||||
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
|
||||
result = eng.run()
|
||||
assert eng._states[0].pos == (0, 0)
|
||||
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
|
||||
|
||||
|
||||
def test_run_destination_in_front_of_difficult_terrain_is_reachable() -> None:
|
||||
"""Given: the same rubble row
|
||||
When: hero runs to an explicit destination that avoids the rubble
|
||||
Then: the run honours it (the field routes around difficult cells, the
|
||||
budget is generous — the verdict is positive and the path arrives)."""
|
||||
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "hero":
|
||||
return (Action(kind="run", destination=(2, 0)),)
|
||||
return _WAIT
|
||||
|
||||
grid = _rubble_row_grid(size=8, row=3)
|
||||
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
# The default foe at (0, 1) threatens the start: the run provokes one
|
||||
# (missed) AoO on the first step — scripted as a natural 1.
|
||||
eng = _duo(policy, [10, 5, 1], round_cap=1, hero=hero, grid=grid)
|
||||
result = eng.run()
|
||||
assert eng._states[0].pos == (2, 0)
|
||||
assert "round 1 hero: run" in result.transcript
|
||||
@@ -409,6 +475,87 @@ def test_move_can_enter_difficult_terrain_but_run_cannot() -> None:
|
||||
assert eng._states[0].pos[0] > 3
|
||||
|
||||
|
||||
# ── (d3) S4a: a run is a straight line (SRD Full-Round Actions — Run) ─────────
|
||||
|
||||
|
||||
def test_run_does_not_turn_a_corner_around_a_wall() -> None:
|
||||
"""Given: hero at (0,0), a wall column at col 5 (rows 1-4), foe at (2,8)
|
||||
When: hero runs toward the foe (a Dijkstra move would bend around the wall)
|
||||
Then: the run follows the straight Bresenham line and stops at the wall
|
||||
face — it does NOT turn the corner (SRD Run: "move up to four times your
|
||||
speed in a straight line")."""
|
||||
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "hero":
|
||||
return (Action(kind="run", target_id="foe"),)
|
||||
return _WAIT
|
||||
|
||||
grid = _walled_grid(size=12)
|
||||
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
foe = make_state(_combatant("foe"), "monsters", (2, 8))
|
||||
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
|
||||
eng.run()
|
||||
hero_state = eng._states[0]
|
||||
# The straight line (0,0)->(2,8) crosses col 5 at rows 1-2 (both wall), so
|
||||
# the run halts before the wall face and never reaches or bends past it.
|
||||
assert hero_state.pos[1] < 5
|
||||
assert hero_state.running is True
|
||||
# Contrast: a plain move DOES route around the wall (bends the corner).
|
||||
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
foe2 = make_state(_combatant("foe"), "monsters", (2, 8))
|
||||
eng2 = _duo(
|
||||
lambda _e, s: (
|
||||
(Action(kind="move", target_id="foe"),)
|
||||
if s.combatant.id == "hero" else _WAIT
|
||||
),
|
||||
[10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid,
|
||||
)
|
||||
eng2.run()
|
||||
assert eng2._states[0].pos[1] > 5 # the move got around to the far side
|
||||
|
||||
|
||||
def test_clear_straight_run_still_works() -> None:
|
||||
"""Given: an open grid, hero (0,0), foe far along a clear straight line
|
||||
When: hero runs toward the foe
|
||||
Then: the straight-line run reaches adjacent to the target (unchanged
|
||||
behaviour on an unobstructed line)."""
|
||||
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "hero":
|
||||
return (Action(kind="run", target_id="foe"),)
|
||||
return _WAIT
|
||||
|
||||
grid = _open_grid(16)
|
||||
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
foe = make_state(_combatant("foe"), "monsters", (0, 15))
|
||||
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
|
||||
eng.run()
|
||||
hero_state = eng._states[0]
|
||||
# 4x speed = 24 cells; the 15-cell straight run reaches (0,14), adjacent.
|
||||
assert hero_state.pos == (0, 14)
|
||||
assert hero_state.running is True
|
||||
|
||||
|
||||
def test_run_destination_around_a_corner_is_rejected() -> None:
|
||||
"""Given: the wall column, hero (0,0)
|
||||
When: hero runs to an explicit destination only reachable by turning the
|
||||
corner (2,8)
|
||||
Then: the straight line cannot honour it, so the destination is rejected
|
||||
(unreachable_destination) rather than the run bending around the wall."""
|
||||
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "hero":
|
||||
return (Action(kind="run", destination=(2, 8)),)
|
||||
return _WAIT
|
||||
|
||||
grid = _walled_grid(size=12)
|
||||
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
|
||||
result = eng.run()
|
||||
assert eng._states[0].pos == (0, 0)
|
||||
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
|
||||
|
||||
|
||||
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
|
||||
def test_running_combatant_is_denied_dex_to_ac() -> None:
|
||||
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
|
||||
@@ -78,6 +78,29 @@ def test_aoo_suppressed_vs_total_concealment() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_aoo_suppressed_when_attacker_is_blinded() -> None:
|
||||
"""Given: the guard is blinded and the runner has no concealment
|
||||
When: the guard resolves the AoO
|
||||
Then: it is suppressed — a blinded character's opponents all have total
|
||||
concealment (CRB Conditions — Blinded) — no die, no budget spent."""
|
||||
runner = make_combatant("runner", hp=20, ac=12)
|
||||
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
|
||||
states = [
|
||||
make_state(runner, "players", (2, 1)),
|
||||
make_state(guard, "monsters", (2, 0)),
|
||||
]
|
||||
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
|
||||
runner_s, guard_s = engine._states
|
||||
_add_condition(guard_s, "blinded")
|
||||
assert engine._resolve_aoo(guard_s, runner_s) is False
|
||||
assert engine._aoo_used.get("guard", 0) == 0
|
||||
assert len(engine._rng._queue) == 2
|
||||
assert runner_s.hp == 20
|
||||
assert any(
|
||||
"suppressed (total concealment)" in line for line in engine._transcript
|
||||
)
|
||||
|
||||
|
||||
def test_aoo_still_strikes_in_the_open() -> None:
|
||||
"""Given: no cover and no concealment between guard and runner
|
||||
When: the guard resolves the AoO
|
||||
@@ -112,3 +135,44 @@ def test_aoo_not_suppressed_by_partial_concealment() -> None:
|
||||
assert engine._resolve_aoo(guard_s, runner_s) is False
|
||||
assert engine._aoo_used.get("guard", 0) == 1
|
||||
assert not any("suppressed" in line for line in engine._transcript)
|
||||
|
||||
|
||||
def test_nauseated_guard_makes_no_aoo() -> None:
|
||||
"""Given: the guard is nauseated (SRD: "unable to attack")
|
||||
When: the runner provokes an AoO
|
||||
Then: no opportunity is taken — no die rolled, no budget spent."""
|
||||
runner = make_combatant("runner", hp=20, ac=12)
|
||||
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
|
||||
states = [
|
||||
make_state(runner, "players", (2, 1)),
|
||||
make_state(guard, "monsters", (2, 0)),
|
||||
]
|
||||
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
|
||||
runner_s, guard_s = engine._states
|
||||
_add_condition(guard_s, "nauseated")
|
||||
assert engine._resolve_aoo(guard_s, runner_s) is False
|
||||
assert engine._aoo_used.get("guard", 0) == 0
|
||||
assert len(engine._rng._queue) == 2
|
||||
assert runner_s.hp == 20
|
||||
|
||||
|
||||
def test_confused_guard_makes_no_aoo() -> None:
|
||||
"""Given: the guard is confused (SRD: "will not make attacks of
|
||||
opportunity against anything that it is not already devoted to
|
||||
attacking" — simplified to no AoOs at all, the engine has no
|
||||
"already devoted" concept)
|
||||
When: the runner provokes an AoO
|
||||
Then: no opportunity is taken — no die rolled, no budget spent."""
|
||||
runner = make_combatant("runner", hp=20, ac=12)
|
||||
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
|
||||
states = [
|
||||
make_state(runner, "players", (2, 1)),
|
||||
make_state(guard, "monsters", (2, 0)),
|
||||
]
|
||||
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
|
||||
runner_s, guard_s = engine._states
|
||||
_add_condition(guard_s, "confused")
|
||||
assert engine._resolve_aoo(guard_s, runner_s) is False
|
||||
assert engine._aoo_used.get("guard", 0) == 0
|
||||
assert len(engine._rng._queue) == 2
|
||||
assert runner_s.hp == 20
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Cone and line spell areas (Package S1a).
|
||||
|
||||
PF1e (CRB, Magic - Aiming a Spell): a cone "shoots away from you in a
|
||||
quarter-circle in the direction you designate"; a line "shoots away from you
|
||||
in a line in the direction you designate" and "affects all creatures in
|
||||
squares through which the line passes". Both originate at the CASTER and are
|
||||
aimed via ``cast_spell``'s existing ``target_id`` / ``target_pos``.
|
||||
|
||||
Factories are reused from ``tests/test_observation_enriched.py``; every roll
|
||||
is scripted. The cone spell is save-less (save="none", save_for_half=False)
|
||||
so each affected creature consumes exactly its damage dice — the scripted
|
||||
queues also pin the EXACT set of creatures caught in each template.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import Action, 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.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import (
|
||||
AreaEffect,
|
||||
DamageEffect,
|
||||
SpellSpec,
|
||||
load_spell,
|
||||
)
|
||||
from tests.test_observation_enriched import make_combatant, make_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_CONE_SPARKS = SpellSpec(
|
||||
name="Cone Sparks",
|
||||
level=1,
|
||||
school="evocation",
|
||||
range="close",
|
||||
save="none",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(
|
||||
shape="cone",
|
||||
radius_ft=15,
|
||||
effects=(
|
||||
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
|
||||
),
|
||||
save_for_half=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_LINE_SPARKS = SpellSpec(
|
||||
name="Line Sparks",
|
||||
level=2,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="none",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(
|
||||
shape="line",
|
||||
radius_ft=120,
|
||||
effects=(
|
||||
DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),
|
||||
),
|
||||
save_for_half=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_BURST_SPARKS = SpellSpec(
|
||||
name="Burst Sparks",
|
||||
level=3,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="none",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(
|
||||
radius_ft=20,
|
||||
effects=(
|
||||
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
|
||||
),
|
||||
save_for_half=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_REGISTRY = {
|
||||
"Cone Sparks": _CONE_SPARKS,
|
||||
"Line Sparks": _LINE_SPARKS,
|
||||
"Burst Sparks": _BURST_SPARKS,
|
||||
}
|
||||
|
||||
|
||||
def _open_grid(side: int = 12) -> Grid:
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="open", terrain=tuple(["." * side] * side), legend=legend)
|
||||
return Grid.from_spec(spec)
|
||||
|
||||
|
||||
def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine:
|
||||
return CombatEngine(
|
||||
ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
def _caster(hp: int = 100) -> CombatantState:
|
||||
return make_state(
|
||||
make_combatant(
|
||||
"caster",
|
||||
hp=hp,
|
||||
spells=["Cone Sparks", "Line Sparks", "Burst Sparks"],
|
||||
spell_slots={1: 9, 2: 9, 3: 9},
|
||||
),
|
||||
"players",
|
||||
(6, 2),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cone: 15 ft in front of the caster, ±45° wide, origin cell excluded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cone_hits_cells_in_front_and_not_behind() -> None:
|
||||
"""Given: caster (6,2); foe_front (6,4) east, foe_behind (6,0) west,
|
||||
foe_side (2,2) north, foe_far (6,7) east beyond the 15-ft reach
|
||||
When: cast a 15-ft cone aimed at foe_front (target_id)
|
||||
Then: only foe_front (in front, within 3 cells) is hit; the behind/side
|
||||
foes fall outside the ±45° quarter circle and foe_far exceeds the length.
|
||||
The caster's own cell (the origin) is excluded by the cone template."""
|
||||
caster = _caster()
|
||||
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
|
||||
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 0))
|
||||
foe_side = make_state(make_combatant("foe_side", hp=100), "monsters", (2, 2))
|
||||
foe_far = make_state(make_combatant("foe_far", hp=100), "monsters", (6, 7))
|
||||
eng = _engine([caster, foe_front, foe_behind, foe_side, foe_far], [4])
|
||||
result = eng._execute(
|
||||
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Cone Sparks")
|
||||
)
|
||||
assert result.ok
|
||||
assert foe_front.hp == 100 - 4
|
||||
assert foe_behind.hp == 100
|
||||
assert foe_side.hp == 100
|
||||
assert foe_far.hp == 100
|
||||
assert caster.hp == 100 # origin cell excluded — the caster is never caught
|
||||
|
||||
|
||||
def test_cone_covers_the_full_90_degree_arc() -> None:
|
||||
"""Given: caster (6,2) aiming east; foes on the straight axis (6,4), the
|
||||
45° edge (4,4) and just off the edge (3,4)
|
||||
When: the 15-ft cone resolves
|
||||
Then: the axis and the 45° edge cell are inside the quarter circle; the
|
||||
off-edge cell (beyond ±45°) is untouched."""
|
||||
caster = _caster()
|
||||
foe_axis = make_state(make_combatant("foe_axis", hp=100), "monsters", (6, 4))
|
||||
foe_edge = make_state(make_combatant("foe_edge", hp=100), "monsters", (4, 4))
|
||||
foe_outside = make_state(make_combatant("foe_outside", hp=100), "monsters", (3, 4))
|
||||
eng = _engine([caster, foe_axis, foe_edge, foe_outside], [4, 4, 4])
|
||||
eng._execute(
|
||||
caster, Action(kind="cast_spell", target_id="foe_axis", spell_name="Cone Sparks")
|
||||
)
|
||||
assert foe_axis.hp == 100 - 4
|
||||
assert foe_edge.hp == 100 - 4
|
||||
assert foe_outside.hp == 100
|
||||
|
||||
|
||||
def test_cone_directed_by_target_pos_point_cast() -> None:
|
||||
"""Given: the same scene, no target_id — the cone is aimed at an explicit
|
||||
cell (6,5) via target_pos (U7b point cast)
|
||||
When: the point cast resolves
|
||||
Then: the direction is caster -> point; the foe at (6,4) on that bearing
|
||||
is caught, the north foe is not."""
|
||||
caster = _caster()
|
||||
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (6, 4))
|
||||
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 2))
|
||||
eng = _engine([caster, foe_in, foe_out], [4])
|
||||
result = eng._execute(
|
||||
caster, Action(kind="cast_spell", spell_name="Cone Sparks", target_pos=(6, 5))
|
||||
)
|
||||
assert result.ok
|
||||
assert foe_in.hp == 100 - 4
|
||||
assert foe_out.hp == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# line: 120 ft straight ray, 5 ft wide — only the cells it passes through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_line_hits_the_straight_ray_only() -> None:
|
||||
"""Given: caster (6,2) aiming east at foe_ray (6,5); foe_off (5,5) sits a
|
||||
row away, foe_west (6,0) is behind the caster
|
||||
When: cast the 120-ft line aimed at foe_ray
|
||||
Then: every creature ON the ray beyond the caster's own square (foe_ray)
|
||||
takes damage; the caster's own cell is NOT part of the line (CRB: it
|
||||
"starts from any corner of your square"), and the off-row and the
|
||||
behind-caster foes are outside the 5-ft-wide template."""
|
||||
caster = _caster()
|
||||
foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5))
|
||||
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (5, 5))
|
||||
foe_west = make_state(make_combatant("foe_west", hp=100), "monsters", (6, 0))
|
||||
eng = _engine([caster, foe_ray, foe_off, foe_west], [4, 4])
|
||||
result = eng._execute(
|
||||
caster, Action(kind="cast_spell", target_id="foe_ray", spell_name="Line Sparks")
|
||||
)
|
||||
assert result.ok
|
||||
assert foe_ray.hp == 100 - 4
|
||||
assert caster.hp == 100 # the caster's own square is outside the line
|
||||
assert foe_off.hp == 100
|
||||
assert foe_west.hp == 100
|
||||
|
||||
|
||||
def test_line_follows_a_diagonal_bresenham_ray() -> None:
|
||||
"""Given: caster (6,2) aiming diagonally at foe_diag (2,6)
|
||||
When: the line resolves
|
||||
Then: the diagonal ray cells are affected (foe_diag is hit) while a cell
|
||||
beside the ray (foe_off at (2,5)) is not."""
|
||||
caster = _caster()
|
||||
foe_diag = make_state(make_combatant("foe_diag", hp=100), "monsters", (2, 6))
|
||||
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (2, 5))
|
||||
eng = _engine([caster, foe_diag, foe_off], [4, 4])
|
||||
eng._execute(
|
||||
caster, Action(kind="cast_spell", target_id="foe_diag", spell_name="Line Sparks")
|
||||
)
|
||||
assert foe_diag.hp == 100 - 4
|
||||
assert foe_off.hp == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# burst: unchanged circle around the resolved center
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_burst_still_centers_on_the_target_and_ignores_the_caster_side() -> None:
|
||||
"""Given: caster (6,2); foe_in (4,4) and foe_out (2,0) around a burst
|
||||
centered on target_id=foe_in (radius 20 ft = 4 cells)
|
||||
When: the burst cast resolves
|
||||
Then: the circle around the TARGET cell decides — foe_in (distance 0)
|
||||
and the caster (distance 3) are inside, foe_out (distance 5) is not."""
|
||||
caster = _caster()
|
||||
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (4, 4))
|
||||
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 0))
|
||||
eng = _engine([caster, foe_in, foe_out], [4, 4, 4])
|
||||
eng._execute(
|
||||
caster, Action(kind="cast_spell", target_id="foe_in", spell_name="Burst Sparks")
|
||||
)
|
||||
assert foe_in.hp == 100 - 4
|
||||
assert caster.hp == 100 - 4
|
||||
assert foe_out.hp == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# loader: shape parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_loader_parses_cone_and_line_shapes(tmp_path: Path) -> None:
|
||||
"""Given: area effects declaring "shape": "cone" / "line" / (absent)
|
||||
When: load_spell parses them
|
||||
Then: the shapes are carried on the AreaEffect; an absent shape defaults
|
||||
to "burst"; an unknown shape is a data error."""
|
||||
spell_file = tmp_path / "shapes.json"
|
||||
spell_file.write_text(
|
||||
'{"name": "Shapes", "level": 1, "school": "evocation",'
|
||||
' "range": "close", "save": "ref", "effects": ['
|
||||
' {"type": "area", "shape": "cone", "radius_ft": 15, "effects": []},'
|
||||
' {"type": "area", "shape": "line", "radius_ft": 120, "effects": []},'
|
||||
' {"type": "area", "radius_ft": 20, "effects": []}]}'
|
||||
)
|
||||
spell = load_spell(spell_file)
|
||||
areas = [e for e in spell.effects if isinstance(e, AreaEffect)]
|
||||
assert [a.shape for a in areas] == ["cone", "line", "burst"]
|
||||
|
||||
bad_file = tmp_path / "bad_shape.json"
|
||||
bad_file.write_text(
|
||||
'{"name": "Bad", "level": 1, "school": "evocation",'
|
||||
' "range": "close", "save": "none", "effects": ['
|
||||
' {"type": "area", "shape": "spiral", "radius_ft": 10, "effects": []}]}'
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown area shape"):
|
||||
load_spell(bad_file)
|
||||
@@ -102,7 +102,8 @@ def test_heal_spell_clears_bleed() -> None:
|
||||
_add_condition(a, "bleeding")
|
||||
a.conditions.append(BLEEDING)
|
||||
result = eng._apply_heal_effect(
|
||||
HealEffect(formula=parse_dice("1d8")), a, empower=False, maximize=False
|
||||
HealEffect(formula=parse_dice("1d8")), a, caster_level=1,
|
||||
empower=False, maximize=False, saved=False,
|
||||
)
|
||||
assert result == "+5 HP"
|
||||
assert a.hp == 55
|
||||
@@ -319,4 +320,4 @@ def test_snapshot_exposes_bleed_damage() -> None:
|
||||
defs = dict(snap["condition_definitions"])
|
||||
assert len(defs) == len(CONDITIONS_BY_NAME)
|
||||
assert defs["bleeding"]["flags"] == []
|
||||
assert defs["confused"]["flags"] == []
|
||||
assert defs["confused"]["flags"] == ["cant_aoo"]
|
||||
|
||||
+75
-44
@@ -1,19 +1,24 @@
|
||||
"""Tests for the cackle hex: bound the extension of hex conditions (Task 9).
|
||||
"""Tests for the cackle hex (APG): free action, no resource cost, extends
|
||||
every evil-eye hex on all creatures within 30 ft by exactly 1 round.
|
||||
|
||||
Cackle must extend exactly one +1 round per pre-existing hex condition, in a
|
||||
single pass, without re-extending the replacements it appends. The pre-fix code
|
||||
mutated ``target.active_conditions`` (remove/append) while iterating it, which
|
||||
grew the list unboundedly and looped forever.
|
||||
The pre-fix code spent a hex use, acted on a single target, and extended
|
||||
every ``hex:``-sourced condition (including slumber, which RAW cackle does
|
||||
not touch). Cackle now rebuilds each in-range creature's buff list in a
|
||||
single pass, so an extended hex is never re-extended and the call always
|
||||
terminates (the pre-Task-9 code mutated the list while iterating it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||
from pf1e_simulator.combat import (
|
||||
Action,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
_default_cast_time,
|
||||
_effective_slot,
|
||||
)
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.effects import ActiveCondition
|
||||
from pf1e_simulator.effects import ActiveBuff, StatModifier
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
@@ -26,11 +31,9 @@ from pf1e_simulator.models import (
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection
|
||||
|
||||
_HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2")
|
||||
_ROUNDS_BEFORE = (2, 1)
|
||||
_HEX_SOURCE = "hex: evil_eye by caster"
|
||||
_OTHER_SOURCE = "hex: evil_eye by caster-2"
|
||||
_UNRELATED_SOURCE = "spell: bull's strength"
|
||||
|
||||
|
||||
def _make_witch(cid: str = "caster") -> Combatant:
|
||||
@@ -90,7 +93,7 @@ def _make_target(cid: str = "target") -> Combatant:
|
||||
)
|
||||
|
||||
|
||||
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
|
||||
c = CombatantState(
|
||||
combatant=_make_witch(), side="players", pos=(0, 0), hp=20,
|
||||
resources_left={"hexes": 3},
|
||||
@@ -98,42 +101,70 @@ def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
t = CombatantState(
|
||||
combatant=_make_target(), side="monsters", pos=(3, 0), hp=30,
|
||||
)
|
||||
far = CombatantState(
|
||||
combatant=_make_target("far"), side="monsters", pos=(10, 0), hp=30,
|
||||
)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t])
|
||||
return engine, c, t
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t, far])
|
||||
return engine, c, t, far
|
||||
|
||||
|
||||
def _seed_hex_conditions(target: CombatantState) -> dict[str, int]:
|
||||
target.active_conditions = [
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["shaken"],
|
||||
rounds_left=rounds,
|
||||
source=source,
|
||||
)
|
||||
for source, rounds in zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True)
|
||||
]
|
||||
return dict(zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True))
|
||||
def _evil_eye(rounds_left: int, source: str = _HEX_SOURCE) -> ActiveBuff:
|
||||
return ActiveBuff(
|
||||
modifiers=(StatModifier(target="attack", value=-2),),
|
||||
rounds_left=rounds_left,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _rounds_by_source(conditions: Collection[ActiveCondition]) -> dict[str, int]:
|
||||
return {ac.source: ac.rounds_left for ac in conditions}
|
||||
def _rounds_by_source(buffs: list[ActiveBuff]) -> dict[str, int]:
|
||||
return {b.source: b.rounds_left for b in buffs}
|
||||
|
||||
|
||||
class TestCackleExtendsHexOnce:
|
||||
"""Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes,
|
||||
rounds_left 2 and 1)
|
||||
class TestCackleExtendsEvilEyeOnce:
|
||||
"""Given: a target carrying 2 pre-existing evil-eye buffs (2 Evil Eyes,
|
||||
rounds_left 2 and 1) and an unrelated buff, plus a creature beyond 30 ft
|
||||
carrying its own evil eye
|
||||
When: the witch cackles
|
||||
Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left
|
||||
rises by 1, the total hex-condition count stays 2 (no growth), and the
|
||||
call terminates (the pre-fix loop never did)."""
|
||||
Then: EXACTLY 2 extensions of +1 round happen in range — each evil eye's
|
||||
rounds_left rises by 1, the unrelated buff is untouched, the out-of-range
|
||||
creature is not extended, the buff count stays stable (no growth), and
|
||||
the call terminates."""
|
||||
|
||||
def test_exactly_two_hex_extensions_of_one_round(self) -> None:
|
||||
engine, c, t = _make_engine()
|
||||
expected = _seed_hex_conditions(t)
|
||||
def test_exactly_two_evil_eye_extensions_of_one_round(self) -> None:
|
||||
engine, c, t, far = _make_engine()
|
||||
t.active_buffs = [_evil_eye(2), _evil_eye(1, _OTHER_SOURCE),
|
||||
ActiveBuff(modifiers=(), rounds_left=5, source=_UNRELATED_SOURCE)]
|
||||
far.active_buffs = [_evil_eye(3, "hex: evil_eye by other-witch")]
|
||||
|
||||
engine._hex_cackle(c, t)
|
||||
engine._hex_cackle(c)
|
||||
|
||||
after = _rounds_by_source(t.active_conditions)
|
||||
assert len(after) == 2
|
||||
assert after == {source: rounds + 1 for source, rounds in expected.items()}
|
||||
after = _rounds_by_source(t.active_buffs)
|
||||
assert len(after) == 3
|
||||
assert after[_HEX_SOURCE] == 3
|
||||
assert after[_OTHER_SOURCE] == 2
|
||||
assert after[_UNRELATED_SOURCE] == 5
|
||||
assert _rounds_by_source(far.active_buffs)["hex: evil_eye by other-witch"] == 3
|
||||
|
||||
|
||||
class TestCackleIsFreeAndCostless:
|
||||
"""Given: the cackle hex action
|
||||
When: budgeted and executed
|
||||
Then: it occupies the FREE slot (not the standard one), does not spend a
|
||||
hex use, and works without a resolved target."""
|
||||
|
||||
def test_cackle_slot_is_free_but_evil_eye_stays_standard(self) -> None:
|
||||
cackle = Action(kind="hex", hex_name="cackle")
|
||||
evil_eye = Action(kind="hex", target_id="target", hex_name="evil_eye")
|
||||
assert _effective_slot(cackle, _default_cast_time) == "free"
|
||||
assert _effective_slot(evil_eye, _default_cast_time) == "standard"
|
||||
|
||||
def test_cackle_spends_no_hex_resource(self) -> None:
|
||||
engine, c, t, _far = _make_engine()
|
||||
t.active_buffs = [_evil_eye(1)]
|
||||
|
||||
engine._execute(c, Action(kind="hex", hex_name="cackle"))
|
||||
|
||||
assert c.resources_left["hexes"] == 3
|
||||
assert _rounds_by_source(t.active_buffs)[_HEX_SOURCE] == 2
|
||||
|
||||
@@ -205,6 +205,31 @@ def test_charge_result_no_charge_path_too_far() -> None:
|
||||
assert result.distance_ft == 0
|
||||
|
||||
|
||||
def test_restricted_charge_halves_the_budget() -> None:
|
||||
"""SRD "Charge": a creature able to take only a standard action (here a
|
||||
disabled one at 0 hp) can still charge, but only up to its speed (1x,
|
||||
6 cells at 30 ft) instead of double speed — a target 6 cells away stays
|
||||
chargeable, one 10 cells away is not."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
near = make_state(make_combatant("near"), "monsters", (0, 6))
|
||||
engine = make_engine([a, near], make_grid(12))
|
||||
a.hp = 0 # disabled -> staggered -> restricted charge
|
||||
result = engine.can_charge(a, near)
|
||||
assert isinstance(result, ChargeResult)
|
||||
assert result.weapon is not None # end cell 5 steps away <= 1x budget
|
||||
a2 = make_state(make_combatant("a2"), "players", (0, 0))
|
||||
far = make_state(make_combatant("far"), "monsters", (0, 10))
|
||||
engine2 = make_engine([a2, far], make_grid(12))
|
||||
a2.hp = 0
|
||||
res2 = engine2.can_charge(a2, far)
|
||||
assert isinstance(res2, ChargeResult)
|
||||
assert res2.weapon is None # 9 steps to the closest attack cell needs 2x
|
||||
a2.hp = 20 # standing again: full 2x charge is legal
|
||||
res3 = engine2.can_charge(a2, far)
|
||||
assert isinstance(res3, ChargeResult)
|
||||
assert res3.weapon is not None
|
||||
|
||||
|
||||
# --- Test 5: no melee weapon -------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def _combatant(
|
||||
*,
|
||||
resources: dict[str, int] | None = None,
|
||||
spells: list[str] | None = None,
|
||||
spell_slots: dict[str, int] | None = None,
|
||||
hp_max: int = 20,
|
||||
creature_type: Literal["living", "undead", "construct"] = "living",
|
||||
) -> Combatant:
|
||||
@@ -59,6 +60,7 @@ def _combatant(
|
||||
attacks=[attack],
|
||||
resources=resources or {},
|
||||
spells=spells or [],
|
||||
spell_slots=spell_slots or {},
|
||||
creature_type=creature_type,
|
||||
)
|
||||
|
||||
@@ -253,6 +255,83 @@ def test_nairda_hex_spends_resource() -> None:
|
||||
assert me.resources_left["hexes"] == 4
|
||||
|
||||
|
||||
def test_evil_eye_failed_save_deals_minus_two_attack_three_rounds() -> None:
|
||||
"""APG Evil Eye: -2 on one chosen option, 3 rounds; Will does not negate.
|
||||
Given: a target that fails the Will save (d20 2 +4 < DC 14)
|
||||
When: the witch hexes it
|
||||
Then: a -2 attack modifier is applied for 3 rounds (no shaken condition)."""
|
||||
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
|
||||
|
||||
engine._execute(
|
||||
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
||||
)
|
||||
|
||||
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
|
||||
assert len(hex_buffs) == 1
|
||||
assert hex_buffs[0].rounds_left == 3
|
||||
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
|
||||
assert not any(ac.condition.name == "shaken" for ac in enemy[0].active_conditions)
|
||||
|
||||
|
||||
def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None:
|
||||
"""APG Evil Eye: "A Will save reduces this to just 1 round" — the penalty
|
||||
still lands. Given: a target that saves (natural 20)
|
||||
When: the witch hexes it
|
||||
Then: the -2 attack modifier is applied for exactly 1 round."""
|
||||
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
||||
|
||||
engine._execute(
|
||||
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
||||
)
|
||||
|
||||
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
|
||||
assert len(hex_buffs) == 1
|
||||
assert hex_buffs[0].rounds_left == 1
|
||||
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
|
||||
|
||||
|
||||
def test_slumber_failed_save_puts_target_asleep_for_witch_level() -> None:
|
||||
"""APG Slumber: the target "remains asleep for a number of rounds equal to
|
||||
the witch's level". Given: a level-5 witch and a target failing the Will
|
||||
save (d20 2 +4 < DC 14)
|
||||
When: the hex is cast
|
||||
Then: the asleep condition carries rounds_left = 5."""
|
||||
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
|
||||
|
||||
engine._execute(
|
||||
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
|
||||
)
|
||||
|
||||
asleep = [
|
||||
ac for ac in enemy[0].active_conditions if ac.condition.name == "asleep"
|
||||
]
|
||||
assert len(asleep) == 1
|
||||
assert asleep[0].rounds_left == 5
|
||||
|
||||
|
||||
def test_slumber_saved_leaves_target_awake() -> None:
|
||||
"""APG Slumber: Will negates. Given: a target saving (natural 20)
|
||||
When: the hex is cast
|
||||
Then: no asleep condition is applied."""
|
||||
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
||||
|
||||
engine._execute(
|
||||
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
|
||||
)
|
||||
|
||||
assert not any(
|
||||
ac.condition.name == "asleep" for ac in enemy[0].active_conditions
|
||||
)
|
||||
|
||||
|
||||
# --- tammara: deeds ---
|
||||
|
||||
|
||||
@@ -312,8 +391,10 @@ def test_tammara_deed_out_of_range_spends_no_grit() -> None:
|
||||
|
||||
|
||||
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
|
||||
# Shocking Grasp (not Burning Hands): the offensive-spell scan reads
|
||||
# top-level damage effects, and Burning Hands is a cone area spell now.
|
||||
misty = _combatant(
|
||||
"misty", resources={"bardic_rounds": 12}, spells=["Burning Hands"]
|
||||
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"]
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
@@ -378,6 +459,59 @@ def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_spell_combat_penalty_applies_to_spell_touch_attack() -> None:
|
||||
"""CRB Magus Spell Combat: "any attack roll made as part of this spell
|
||||
also takes this penalty" — Shocking Grasp's melee touch attack is at -2
|
||||
(bab 3 + str 2 - 2 = +3): d20 8 -> 11 misses touch AC 12, so the spell
|
||||
is wasted. Without the penalty the same roll (8+5=13) would have hit."""
|
||||
misty = _combatant(
|
||||
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
||||
spell_slots={1: 1},
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
# Queue: goblin AoO on the cast (nat 1 miss), touch d20=8, swing d20=10.
|
||||
engine, me, enemy = _engine(
|
||||
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 10]
|
||||
)
|
||||
engine._execute(
|
||||
me,
|
||||
Action(
|
||||
kind="spell_combat", target_id=enemy[0].combatant.id,
|
||||
spell_name="Shocking Grasp",
|
||||
),
|
||||
)
|
||||
assert any(
|
||||
"touch attack vs goblin d20=8+3=11 AC 12 -> MISS" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
assert enemy[0].hp == 6 # the missed touch delivers no electricity
|
||||
|
||||
|
||||
def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
|
||||
"""Control: the same Shocking Grasp cast through cast_spell keeps +5
|
||||
(bab 3 + str 2): d20 8 -> 13 hits touch AC 12 and deals damage."""
|
||||
misty = _combatant(
|
||||
"misty", spells=["Shocking Grasp"], spell_slots={1: 1}
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=60)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each
|
||||
# (SRD spells/shocking-grasp.md: 1d6 per caster level, max 5d6; lvl 5).
|
||||
engine, me, enemy = _engine(
|
||||
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4, 4, 4, 4, 4]
|
||||
)
|
||||
engine._execute(
|
||||
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
|
||||
spell_name="Shocking Grasp")
|
||||
)
|
||||
assert any(
|
||||
"touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
assert enemy[0].hp == 60 - 20
|
||||
|
||||
|
||||
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
"""Misty + one ally in 30ft, inspire courage executed on the ally."""
|
||||
misty = _combatant("misty", resources={"bardic_rounds": 12})
|
||||
|
||||
+39
-10
@@ -200,15 +200,38 @@ def test_dr_reduces_damage() -> None:
|
||||
assert b.hp == 4
|
||||
|
||||
|
||||
def test_dr_floors_damage_at_zero() -> None:
|
||||
def test_dr_floors_hit_at_one_minimum_damage() -> None:
|
||||
# PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage
|
||||
# result to less than 1, a hit still deals 1 point of nonlethal damage"
|
||||
# (no nonlethal track: floored to 1 point of normal damage).
|
||||
a_spec = make_combatant("a", attack_bonus=2)
|
||||
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 3], [a, b])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.damage == 0
|
||||
assert b.hp == 6
|
||||
assert result.hit is True
|
||||
assert result.damage == 1
|
||||
assert b.hp == 5
|
||||
|
||||
|
||||
def test_energy_resistance_floors_hit_at_one() -> None:
|
||||
a_spec = make_combatant("a", attack_bonus=2)
|
||||
b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}})
|
||||
fire = AttackSpec(
|
||||
id="a-fire",
|
||||
name="flame blade",
|
||||
kind="melee",
|
||||
attack_bonus=2,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["fire"])],
|
||||
)
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 5], [a, b])
|
||||
result = engine.resolve_attack(a, b, fire)
|
||||
assert result.hit is True
|
||||
assert result.damage == 1
|
||||
assert b.hp == 5
|
||||
|
||||
|
||||
def test_dr_bypass_ignores_reduction() -> None:
|
||||
@@ -558,19 +581,22 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 4, 12, 2, 8, 2, 14, 1])
|
||||
rng = ScriptedRng([10, 9, 5, 13, 6, 15, 4])
|
||||
engine = CombatEngine(rng, grid, states, round_cap=4)
|
||||
result = engine.run()
|
||||
assert result.winner == "players"
|
||||
# SRD Measuring Distance corner rule: the (0,1)->(1,2) diagonal is flank-
|
||||
# blocked by the wall at (0,2), so the auto step goes to (1,1) instead —
|
||||
# and the wall keeps granting cover (+4 AC) from that square.
|
||||
assert result.transcript == (
|
||||
"initiative: archer d20=10+0=10",
|
||||
"initiative: target d20=9+0=9",
|
||||
"round 1 archer: 5ft step (0,1)->(1,2)",
|
||||
"round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
|
||||
"round 1 archer: 5ft step (0,1)->(1,1)",
|
||||
"round 1 archer: short bow vs target d20=5+4=9 AC 17 -> MISS",
|
||||
"round 1 target: wait",
|
||||
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 2 archer: short bow vs target d20=13+4=17 AC 17 -> HIT 6 damage (6->0)",
|
||||
"round 2 target: wait",
|
||||
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 4 damage (3->-1)",
|
||||
"round 3 archer: short bow vs target d20=15+4=19 AC 17 -> HIT 4 damage (0->-4)",
|
||||
"round 3 archer: target down",
|
||||
"battle over: players win in 3 rounds",
|
||||
)
|
||||
@@ -813,7 +839,7 @@ def test_ranged_attack_ignores_flanking() -> None:
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 1 arc: short bow vs tgt d20=12+2-2(melee)=12 AC 15 -> MISS",
|
||||
"round 1 arc: short bow vs tgt d20=12+2-4(melee)=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",
|
||||
"battle over: draw after 1 rounds",
|
||||
@@ -1068,7 +1094,10 @@ def test_aoo_limit_one_per_round() -> None:
|
||||
"initiative: guard d20=9+0=9",
|
||||
"initiative: dest d20=8+0=8",
|
||||
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)",
|
||||
# 5-10-5 parity: the second diagonal (1,2)->(2,3) now costs 2, so the
|
||||
# greedy walk prefers the orthogonal (1,2)->(1,3) detour (both paths
|
||||
# total 5 squares; the new one avoids the expensive second diagonal).
|
||||
"round 1 runner: move (0,0)->(0,1)->(1,2)->(1,3)->(2,3)->(3,3)",
|
||||
"round 1 guard: wait",
|
||||
"round 1 dest: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.abilities import AbilitySpec
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.conditions import ENTANGLED
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
@@ -175,3 +176,46 @@ class TestCombatCastingPlacement:
|
||||
grappler = _combatant("grappler", cmb=3)
|
||||
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
|
||||
assert engine._check_concentration(c, 1, damage_taken=0) is True
|
||||
|
||||
|
||||
class TestEntangledCasting:
|
||||
"""SRD Table 9-1: "Entangled while casting" => DC 15 + spell level. The
|
||||
casting gate must force the check and lose the spell on a failure."""
|
||||
|
||||
def _entangled_engine(
|
||||
self, roll: int, *, combat_casting: bool = False
|
||||
) -> tuple[CombatEngine, CombatantState]:
|
||||
caster = _combatant("caster", level=5, combat_casting=combat_casting)
|
||||
foe = _combatant("foe")
|
||||
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||
f = CombatantState(combatant=foe, side="monsters", pos=(3, 3), hp=foe.hp_max)
|
||||
c.conditions.append(ENTANGLED)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 4] * 4), legend=legend)
|
||||
engine = CombatEngine(ScriptedRng([roll]), Grid.from_spec(spec), [c, f])
|
||||
return engine, c
|
||||
|
||||
def test_entangled_concentration_dc_is_15_plus_spell_level(self) -> None:
|
||||
caster = _combatant("caster", level=5)
|
||||
engine = _make_engine(caster, _combatant("grappler"), [])[0]
|
||||
assert engine._entangled_concentration_dc(2) == 15 + 2
|
||||
|
||||
def test_failed_check_loses_the_spell(self) -> None:
|
||||
# DC 16 (15 + level 1). d20 10 + level 5 + wis 0 = 15 < 16 -> spell lost.
|
||||
engine, c = self._entangled_engine(10)
|
||||
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is True
|
||||
|
||||
def test_successful_check_keeps_the_spell(self) -> None:
|
||||
# d20 11 + level 5 + wis 0 = 16 >= 16 -> pass (foe at (3,3) does not
|
||||
# threaten, so no AoO gate either).
|
||||
engine, c = self._entangled_engine(11)
|
||||
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
|
||||
|
||||
def test_combat_casting_helps_while_entangled(self) -> None:
|
||||
# d20 7 + level 5 + wis 0 + 4 = 16 >= 16 -> PASS with the feat.
|
||||
engine, c = self._entangled_engine(7, combat_casting=True)
|
||||
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
|
||||
|
||||
def test_entangled_carries_minus4_dex_skills(self) -> None:
|
||||
mods = {m.target: m.value for m in ENTANGLED.modifiers}
|
||||
assert mods["dex_skills"] == -4
|
||||
|
||||
@@ -7,6 +7,7 @@ from pf1e_simulator.conditions import (
|
||||
BLINDED,
|
||||
DAZZLED,
|
||||
ENTANGLED,
|
||||
EXHAUSTED,
|
||||
FATIGUED,
|
||||
FLAT_FOOTED,
|
||||
FRIGHTENED,
|
||||
@@ -123,9 +124,28 @@ class TestConditionData:
|
||||
assert ENTANGLED.cant_charge is True
|
||||
assert ENTANGLED.speed_mult == 0.5
|
||||
|
||||
def test_exhausted_minus6_dex_gives_minus3_ac_and_ref(self) -> None:
|
||||
# SRD: exhausted takes -6 to Dexterity => -3 AC and -3 Reflex
|
||||
# (each -2 Dex = -1 AC/Ref, same modelling as entangled's -4 Dex).
|
||||
mods = {m.target: m.value for m in EXHAUSTED.modifiers}
|
||||
assert mods["attack"] == -3
|
||||
assert mods["damage"] == -3
|
||||
assert mods["ac"] == -3
|
||||
assert mods["ref"] == -3
|
||||
assert "fort" not in mods
|
||||
assert "will" not in mods
|
||||
|
||||
def test_fatigued_flags(self) -> None:
|
||||
assert FATIGUED.cant_charge is True
|
||||
|
||||
def test_fatigued_minus2_dex_gives_minus1_ac_and_ref(self) -> None:
|
||||
# SRD: fatigued takes -2 to Dexterity => -1 AC and -1 Reflex.
|
||||
mods = {m.target: m.value for m in FATIGUED.modifiers}
|
||||
assert mods["attack"] == -1
|
||||
assert mods["damage"] == -1
|
||||
assert mods["ac"] == -1
|
||||
assert mods["ref"] == -1
|
||||
|
||||
def test_flat_footed_flags(self) -> None:
|
||||
assert FLAT_FOOTED.denies_dex_to_ac is True
|
||||
assert FLAT_FOOTED.cant_aoo is True
|
||||
|
||||
@@ -81,6 +81,50 @@ def test_blinded_attacker_d100_rolls_once_with_target_concealment() -> None:
|
||||
assert any("50%" in line for line in eng._transcript)
|
||||
|
||||
|
||||
def test_blinded_attacker_cannot_sneak_attack() -> None:
|
||||
# PF1e (CRB Conditions — Blinded): "All opponents are considered to have
|
||||
# total concealment (50% miss chance) against the blinded character" — and
|
||||
# total concealment denies sneak attack (CRB rogue "Sneak Attack").
|
||||
eng = _engine(
|
||||
[
|
||||
_combatant("atk", features=("Sneak Attack",)),
|
||||
_combatant("tgt"),
|
||||
],
|
||||
[(0, 0), (0, 1)],
|
||||
["players", "monsters"],
|
||||
[15, 51, 4],
|
||||
)
|
||||
a, t = eng._states
|
||||
_add_condition(a, "blinded")
|
||||
_add_condition(t, "flat_footed")
|
||||
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
|
||||
# d20 15 + 5 = 20 hits; blinded d100 51 > 50 lands; base 1d6 = 4 only —
|
||||
# no sneak d6 is rolled despite the target being denied its Dex bonus.
|
||||
assert result.hit is True
|
||||
assert result.damage == 4
|
||||
assert not any("sneak" in line for line in eng._transcript)
|
||||
|
||||
|
||||
def test_sighted_sneak_attack_control_when_not_blinded() -> None:
|
||||
eng = _engine(
|
||||
[
|
||||
_combatant("atk", features=("Sneak Attack",)),
|
||||
_combatant("tgt"),
|
||||
],
|
||||
[(0, 0), (0, 1)],
|
||||
["players", "monsters"],
|
||||
[15, 3, 4],
|
||||
)
|
||||
a, t = eng._states
|
||||
_add_condition(t, "flat_footed")
|
||||
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
|
||||
# Same swing without blinded: sneak applies (d20 15 hits, sneak d6 3,
|
||||
# base d6 4 -> 7 total).
|
||||
assert result.hit is True
|
||||
assert result.damage == 7
|
||||
assert any("sneak attack" in line for line in eng._transcript)
|
||||
|
||||
|
||||
# ── Rule B: invisible attacker (+2 to hit, target loses Dex to AC) ───────────
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user