Compare commits
7 Commits
9aee4f6f6b
...
9c6f5e277e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c6f5e277e | |||
| d6c8f88f77 | |||
| b7c80babfd | |||
| a4814b935b | |||
| 9fb1c2d75a | |||
| 1960b8f655 | |||
| 890b7959d4 |
@@ -82,7 +82,8 @@ Rounds moyens : 6.0
|
|||||||
|
|
||||||
```
|
```
|
||||||
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
||||||
[--seed SEED] [--round-cap ROUND_CAP]
|
[--seed SEED] [--round-cap ROUND_CAP] [--transcript]
|
||||||
|
[--simple-monster-death]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Rôle | Défaut |
|
| Option | Rôle | Défaut |
|
||||||
@@ -92,6 +93,31 @@ usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
|||||||
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
||||||
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
||||||
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
|
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
|
||||||
|
| `--transcript` | Imprimer le déroulé du premier combat **en tableau** avant le rapport (voir ci-dessous) | désactivé |
|
||||||
|
| `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` au lieu de `hp ≤ -CON` (les PJ ne sont jamais affectés) | désactivé |
|
||||||
|
|
||||||
|
Affichage du transcript (`--transcript`) : les lignes du moteur sont présentées
|
||||||
|
dans un tableau à trois colonnes alignées — `Round`, `Combattant`, `Message` —
|
||||||
|
précédé d'un en-tête et d'une ligne de séparation. Les largeurs de colonnes
|
||||||
|
sont calculées sur le contenu affiché et l'alignement tient compte de la
|
||||||
|
largeur d'affichage réelle des caractères (accents, CJK). Les lignes du
|
||||||
|
transcript qui ne suivent pas le format `round N id: message` (jets
|
||||||
|
d'initiative, fin de combat) restent affichées telles quelles, en ligne brute
|
||||||
|
intercalée à leur position d'origine — aucune entrée n'est jamais perdue ni
|
||||||
|
tronquée. Exemple :
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Déroulé du premier combat ===
|
||||||
|
Round Combattant Message
|
||||||
|
----- ---------- ---------------------------------------------------------
|
||||||
|
initiative: orc d20=19+0=19
|
||||||
|
1 orc move (1,15)->(2,14)->(3,13)->(3,12)->(3,11)->(3,10)
|
||||||
|
1 goblin short bow vs orc d20=9+4=13 AC 17 -> MISS
|
||||||
|
battle over: players win in 4 rounds
|
||||||
|
```
|
||||||
|
|
||||||
|
Les données du transcript (contenu et ordre des lignes du moteur) ne sont pas
|
||||||
|
modifiées par cette présentation.
|
||||||
|
|
||||||
Codes de sortie :
|
Codes de sortie :
|
||||||
|
|
||||||
@@ -241,7 +267,9 @@ Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
|
|||||||
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
|
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
|
||||||
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
|
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
|
||||||
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
|
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
|
||||||
`spell_resistance` (entier), `resistances` (dict type → montant) et
|
Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à
|
||||||
|
deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance
|
||||||
|
B16). `spell_resistance` (entier), `resistances` (dict type → montant) et
|
||||||
`immunities` (liste de types) sont optionnels.
|
`immunities` (liste de types) sont optionnels.
|
||||||
Deux monstres d'exemple sont fournis : gobelin et orc.
|
Deux monstres d'exemple sont fournis : gobelin et orc.
|
||||||
|
|
||||||
@@ -292,6 +320,22 @@ Règles modélisées :
|
|||||||
La mort survient quand les pv négatifs atteignent le score de CON
|
La mort survient quand les pv négatifs atteignent le score de CON
|
||||||
(`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas
|
(`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas
|
||||||
en PF1e et n'est plus modélisée.
|
en PF1e et n'est plus modélisée.
|
||||||
|
- 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
|
||||||
|
0 et `-CON`. Les **personnages joueurs ne sont jamais affectés**, quel que
|
||||||
|
soit le réglage : ils gardent la règle PF1e complète (`hp ≤ -CON` pour
|
||||||
|
mourir, invalidité à 0 pv, agonie et stabilisation en dessous). La
|
||||||
|
distinction monstre/PJ repose sur une seule marque fiable issue du
|
||||||
|
chargeur : le champ `source` des JSON de PJ pointe vers la fiche Foundry
|
||||||
|
d'origine sous `fiches_personnages/` (convention documentée plus bas),
|
||||||
|
alors que les JSON de monstres portent des sources « Bestiary », « summon
|
||||||
|
monster » ou « bestiary » ; le nom de camp (`--side`) ne peut pas servir
|
||||||
|
de critère puisqu'il est choisi par l'utilisateur et que la carte d'exemple
|
||||||
|
oppose des monstres déployés côté `players`. Sans l'option, le comportement
|
||||||
|
du moteur est bit-identique à celui d'aujourd'hui (transcripts dorés
|
||||||
|
intacts). Équivalent Python : `run_encounter(..., monsters_die_at_zero=True)`
|
||||||
|
et `CombatEngine(..., monsters_die_at_zero=True)`.
|
||||||
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
|
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
|
||||||
exactement, une créature est **invalide** — elle reste active mais
|
exactement, une créature est **invalide** — elle reste active mais
|
||||||
**staggered** (une action de mouvement OU une action standard par tour, pas
|
**staggered** (une action de mouvement OU une action standard par tour, pas
|
||||||
@@ -309,6 +353,45 @@ Règles modélisées :
|
|||||||
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
||||||
(hors-tour, consomment le prochain swift) sont modélisées via une politique
|
(hors-tour, consomment le prochain swift) sont modélisées via une politique
|
||||||
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
||||||
|
**Application du budget (conformance X4)** : le moteur applique désormais le
|
||||||
|
budget d'action PF1e (CRB « Actions in Combat ») à TOUTE séquence renvoyée par
|
||||||
|
une politique, y compris une future politique LLM, et non plus seulement aux
|
||||||
|
cas immédiat/accablé/nausée. Chaque kind d'action est classé dans un seul
|
||||||
|
compartiment du budget — standard, mouvement, swift, round complet, ou
|
||||||
|
libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget
|
||||||
|
(≤ 1 standard **ou** 1 round complet, ≤ 1 mouvement, ≤ 1 swift ; un round
|
||||||
|
complet consacre à la fois le standard ET le mouvement). La dégradation est
|
||||||
|
**déterministe et stable en ordre** : parcours glouton dans l'ordre, chaque
|
||||||
|
action est gardée tant que son compartiment est libre ; le résultat est donc le
|
||||||
|
plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et
|
||||||
|
rapportée comme `ActionResult(kind=…, ok=False, reason="downgraded:action_economy")`
|
||||||
|
(visible dans `last_action_results` et le snapshot `last_turn_results`). Exemples :
|
||||||
|
`(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`,
|
||||||
|
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
|
||||||
|
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
|
||||||
|
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
||||||
|
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
|
||||||
|
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||||
|
`repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une
|
||||||
|
mécanique de mouvement posant `moved_this_turn` et déjà exclu de
|
||||||
|
`_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`,
|
||||||
|
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||||
|
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ;
|
||||||
|
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
|
||||||
|
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
|
||||||
|
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
|
||||||
|
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
|
||||||
|
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
|
||||||
|
basculement Power Attack). Ordre de composition : le budget général est
|
||||||
|
appliqué EN PREMIER, puis les filtres spécialisés (immédiat/accablé/nausée)
|
||||||
|
s'exercent sur le reste déjà légal (leur sortie est une action unique, donc
|
||||||
|
jamais re-filtrée par le budget). L'ordre de dégradation est celui de la
|
||||||
|
politique : la première action légale d'un compartiment gagne, les suivantes
|
||||||
|
sont excédentaires.
|
||||||
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
|
- 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
|
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
|
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
|
||||||
@@ -393,8 +476,11 @@ Règles modélisées :
|
|||||||
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
||||||
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
||||||
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
||||||
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
par le budget général d'action (X4 — action excédentaire retirée de la
|
||||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
séquence de la politique) ou par les filtres staggered/nauseated (ou swift
|
||||||
|
consommé par une immédiate) →
|
||||||
|
`downgraded:action_economy` / `downgraded:staggered` / `downgraded:nauseated` /
|
||||||
|
`downgraded:immediate_swift` ;
|
||||||
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
||||||
`ok=False, reason="already_delayed"` ; préparation valide →
|
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||||
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
||||||
@@ -402,7 +488,9 @@ Règles modélisées :
|
|||||||
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable →
|
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable →
|
||||||
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
|
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
|
||||||
hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"`
|
hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"`
|
||||||
(U7b) ; course ou aide sans cible **et** sans destination →
|
(U7b) ; incantation sans emplacement disponible au niveau requis — niveau
|
||||||
|
de base ou majoré par la métamagie (B7) → `ok=False, reason="no_slot"` ;
|
||||||
|
course ou aide sans cible **et** sans destination →
|
||||||
`missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ;
|
`missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ;
|
||||||
course vers une `destination` hors du budget 4× →
|
course vers une `destination` hors du budget 4× →
|
||||||
`unreachable_destination` (U5e).
|
`unreachable_destination` (U5e).
|
||||||
@@ -491,7 +579,12 @@ Règles modélisées :
|
|||||||
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
|
(+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
|
confirmation de critique ; couvert mou : une créature active entre
|
||||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||||
attaques de mêlée ignorent les créatures).
|
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. Le côté cible reste
|
||||||
|
évalué par la règle RAW : toute ligne vers tout coin des cases de la cible
|
||||||
|
compte (maximum sur les coins de la cible).
|
||||||
- Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
|
- 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
|
« 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
|
bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
|
||||||
@@ -579,18 +672,41 @@ Règles modélisées :
|
|||||||
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
|
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
|
||||||
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
|
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
|
||||||
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
|
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
|
||||||
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, aucun
|
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
|
||||||
jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
|
`ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
|
||||||
sort + mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
|
sort (niveau d'emplacement effectif en cas de métamagie, voir ci-dessous) +
|
||||||
|
mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
|
||||||
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
|
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
|
||||||
(`test_dr_does_not_reduce_spell_damage`). Incanter dans une
|
(`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
|
||||||
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un
|
« Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via
|
||||||
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) :
|
`Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les
|
||||||
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé,
|
dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
|
||||||
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de
|
inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
|
||||||
la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB) ;
|
occupe alors un emplacement de niveau de base + ajustement (Empower +2,
|
||||||
le don Combat Casting n'accorde son +4 qu'en incantation défensive ou quand
|
Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans
|
||||||
on est agrippé, jamais pour les dégâts subis pendant l'incantation. Un sort ciblé avec sauvegarde fait pleins dégâts
|
emplacement au niveau requis, l'incantation est refusée (`no_slot`). Avec
|
||||||
|
`metamagic` vide (défaut), le comportement est inchangé. Incanter dans une
|
||||||
|
case menacée provoque des AoO. Incantation défensive (conformance B24, CRB
|
||||||
|
« Concentration ») : `Action(kind="cast_spell", cast_defensively=True)`
|
||||||
|
évite la provocation d'AoO mais impose un jet de concentration DD
|
||||||
|
`15 + 2×niveau` (+4 avec Combat Casting) ; un échec perd le sort
|
||||||
|
(emplacement brûlé, journalisé `casting defensively: concentration failed`).
|
||||||
|
Agrippé (`grappled_with`) : un jet de concentration DD
|
||||||
|
`10 + CMB de l'adversaire + niveau` (+4 Combat Casting) est requis pour
|
||||||
|
incanter, défensivement ou non — ce jet remplace la provocation (un
|
||||||
|
agrippé ne peut pas porter d'AoO). Tout dégât subi pendant l'incantation
|
||||||
|
(AoO) impose un jet DD `10 + dégâts + niveau` **sans** le bonus Combat
|
||||||
|
Casting (conformance P1). Jet de concentration = d20 + niveau d'incanteur +
|
||||||
|
mod de la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution,
|
||||||
|
CRB). Sorts de contact (conformance B14, CRB « Magic — Range: Touch ») :
|
||||||
|
délivrer un sort de portée `touch` à une créature **hostile** (camp
|
||||||
|
différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force
|
||||||
|
du lanceur contre la CA de contact de la cible (amputée du modificateur de
|
||||||
|
Dextérité si la cible perd son bonus de Dextérité, même chemin que
|
||||||
|
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
|
||||||
|
est brûlé). Simplifications documentées : Weapon Finesse et les autres
|
||||||
|
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
|
||||||
|
volontaire (soi ou un allié du même camp) ne demande aucun jet. 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
|
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
|
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
||||||
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
||||||
@@ -614,11 +730,18 @@ Règles modélisées :
|
|||||||
zone, la SR est résolue par créature touchée (pas seulement sur la cible
|
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). 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
|
annulés par l'immunité (`immunities`) ou réduits par la résistance du type
|
||||||
correspondant (`resistances` : soustraction du maximum parmi les types du dégât,
|
correspondant, **composante par composante** (conformance B21, CRB « Energy
|
||||||
plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le
|
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
|
||||||
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
|
uniquement par la résistance correspondant à SES types (maximum en cas de types
|
||||||
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
multiples — simplification documentée : une même formule de dés n'est pas
|
||||||
armes.
|
répartie entre les types listés), plancher 0 par composante ; une arme
|
||||||
|
1d6 tranchant + 1d6 feu contre résistance feu 5 ne perd que ses dés de feu, les
|
||||||
|
dés tranchants, les bonus plats et les dés de précision (attaque sournoise, non
|
||||||
|
typés) restent intacts ; l'immunité à un type annule uniquement les composantes
|
||||||
|
de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le
|
||||||
|
chemin de la RD, appliquée au total comme avant. La résistance énergétique
|
||||||
|
s'applique à l'étape du dégât final (même étape que la RD), y compris aux dégâts
|
||||||
|
de sorts (un `DamageEffect` est une composante unique — comportement inchangé).
|
||||||
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
|
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
|
||||||
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
|
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
|
||||||
portent un champ `miss_chance` ; quand la
|
portent un champ `miss_chance` ; quand la
|
||||||
@@ -669,7 +792,10 @@ Règles modélisées :
|
|||||||
Les attaques de mêlée ne sont pas concernées.
|
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
|
- 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
|
terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
|
||||||
couvert mou des créatures reste +4 (couvert standard).
|
couvert mou des créatures reste +4 (couvert standard). B20 (conformance) :
|
||||||
|
pour un attaquant multi-cases, le bonus effectif est le **meilleur** (plus
|
||||||
|
faible) couvert parmi ses cases occupées — l'attaquant choisit son meilleur
|
||||||
|
coin/case selon le CRB ; les créatures 1×1 sont inchangées.
|
||||||
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
|
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
|
||||||
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||||
(`_expire_summons` décrémente puis retire).
|
(`_expire_summons` décrémente puis retire).
|
||||||
@@ -727,7 +853,11 @@ Règles modélisées :
|
|||||||
de CMB ; le don « Improved <Maneuver> » correspondant (Improved Trip, …)
|
de CMB ; le don « Improved <Maneuver> » correspondant (Improved Trip, …)
|
||||||
annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la
|
annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la
|
||||||
manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de
|
manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de
|
||||||
la cible (Combat Reflexes inclus).
|
la cible (Combat Reflexes inclus). Conformance B16 (CRB « Power Attack ») :
|
||||||
|
la pénalité −X de Power Attack actif s'applique **au jet de CMB** de la
|
||||||
|
manœuvre (X = BAB//4 + 1) — la pénalité couvre « les jets d'attaque de mêlée
|
||||||
|
et les jets de manœuvres de combat » ; l'escape par compétence n'est pas
|
||||||
|
pénalisé (un test de compétence n'est pas un jet de manœuvre).
|
||||||
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
|
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
|
||||||
cible (direction sélectionnable via `Action.direction`) ;
|
cible (direction sélectionnable via `Action.direction`) ;
|
||||||
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
||||||
@@ -881,6 +1011,9 @@ Règles modélisées :
|
|||||||
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
||||||
Les API de décision exposées (items L1-L8)
|
Les API de décision exposées (items L1-L8)
|
||||||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||||||
|
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
|
||||||
|
B7) et `cast_defensively` (incantation défensive, B24) sur l'action
|
||||||
|
`cast_spell` ;
|
||||||
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
|
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
|
||||||
Le kind `activate_power_attack` (action libre) active le don Power Attack
|
Le kind `activate_power_attack` (action libre) active le don Power Attack
|
||||||
(`state.power_attack = True`) si le combattant possède le don ; sans le don,
|
(`state.power_attack = True`) si le combattant possède le don ; sans le don,
|
||||||
@@ -939,8 +1072,15 @@ la lecture mais non appliquée dans la résolution.
|
|||||||
catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé
|
catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé
|
||||||
par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X
|
par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X
|
||||||
dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le
|
dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le
|
||||||
kind `activate_power_attack` qui pose `state.power_attack`). Les
|
kind `activate_power_attack` qui pose `state.power_attack`). Conformance
|
||||||
modificateurs sont collectés par
|
B16 (CRB « Power Attack ») : le bonus de dégâts passe à **+3X (×1,5)** pour
|
||||||
|
une arme marquée `two_handed: true` dans le JSON de l'attaque (arme à deux
|
||||||
|
mains, arme à une main tenue à deux mains, ou attaque naturelle primaire à
|
||||||
|
×1,5 de Force) ; la pénalité −X s'applique aussi **aux jets de manœuvre de
|
||||||
|
combat** (CMB, hors escape par compétence) ; la division par deux (×0,5,
|
||||||
|
+1X) pour une arme tenue à la main secondaire ou une attaque naturelle
|
||||||
|
secondaire n'est **pas modélisée** (le moteur ne gère pas le combat à deux
|
||||||
|
armes). Les modificateurs sont collectés par
|
||||||
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
|
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
|
||||||
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
|
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
|
||||||
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"damage": [{ "formula": "2d4", "types": ["slashing"] }],
|
"damage": [{ "formula": "2d4", "types": ["slashing"] }],
|
||||||
"damage_bonus": 4,
|
"damage_bonus": 4,
|
||||||
"crit_range": 18,
|
"crit_range": 18,
|
||||||
"crit_mult": 2
|
"crit_mult": 2,
|
||||||
|
"two_handed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "javelin",
|
"name": "javelin",
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||||
|
|
||||||
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
||||||
@@ -26,6 +29,81 @@ class CliError(Exception):
|
|||||||
"""User-facing CLI error (bad arguments or unusable files)."""
|
"""User-facing CLI error (bad arguments or unusable files)."""
|
||||||
|
|
||||||
|
|
||||||
|
_TRANSCRIPT_COLUMNS = ("Round", "Combattant", "Message")
|
||||||
|
_TRANSCRIPT_GUTTER = " "
|
||||||
|
|
||||||
|
|
||||||
|
def _display_width(text: str) -> int:
|
||||||
|
"""Terminal columns used by ``text``: wide CJK counts 2, combining marks 0."""
|
||||||
|
width = 0
|
||||||
|
for char in text:
|
||||||
|
if unicodedata.combining(char):
|
||||||
|
continue
|
||||||
|
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||||
|
return width
|
||||||
|
|
||||||
|
|
||||||
|
def _pad_cell(text: str, width: int, *, right: bool = False) -> str:
|
||||||
|
"""Pad ``text`` with spaces to ``width`` display columns."""
|
||||||
|
filler = " " * (width - _display_width(text))
|
||||||
|
return filler + text if right else text + filler
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_transcript_line(line: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Split a ``round N id: message`` engine line into (round, id, message).
|
||||||
|
|
||||||
|
Returns None for lines that do not follow the pattern (initiative rolls,
|
||||||
|
battle-over notices); the renderer keeps them as raw fallback rows.
|
||||||
|
"""
|
||||||
|
if not line.startswith("round "):
|
||||||
|
return None
|
||||||
|
rest = line.removeprefix("round ")
|
||||||
|
space = rest.find(" ")
|
||||||
|
if space <= 0 or not rest[:space].isdigit():
|
||||||
|
return None
|
||||||
|
remainder = rest[space + 1 :]
|
||||||
|
colon = remainder.find(": ")
|
||||||
|
if colon <= 0 or " " in remainder[:colon]:
|
||||||
|
return None
|
||||||
|
return rest[:space], remainder[:colon], remainder[colon + 2 :]
|
||||||
|
|
||||||
|
|
||||||
|
def render_transcript_table(transcript: Sequence[str]) -> str:
|
||||||
|
"""Render transcript lines as an aligned three-column table (no truncation).
|
||||||
|
|
||||||
|
Entries shaped ``round N id: message`` fill the Round / Combattant /
|
||||||
|
Message columns (computed widths, display-width-aware padding so accents
|
||||||
|
and CJK stay aligned); any other entry is emitted verbatim as a raw
|
||||||
|
fallback row so no transcript line is ever dropped.
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str, str] | None] = [_parse_transcript_line(line) for line in transcript]
|
||||||
|
parsed = [row for row in rows if row is not None]
|
||||||
|
widths = [
|
||||||
|
max([_display_width(header)] + [_display_width(row[index]) for row in parsed])
|
||||||
|
for index, header in enumerate(_TRANSCRIPT_COLUMNS)
|
||||||
|
]
|
||||||
|
lines = [
|
||||||
|
_TRANSCRIPT_GUTTER.join(
|
||||||
|
_pad_cell(header, width)
|
||||||
|
for header, width in zip(_TRANSCRIPT_COLUMNS, widths, strict=True)
|
||||||
|
),
|
||||||
|
_TRANSCRIPT_GUTTER.join("-" * width for width in widths),
|
||||||
|
]
|
||||||
|
for row, line in zip(rows, transcript, strict=True):
|
||||||
|
if row is None:
|
||||||
|
lines.append(line)
|
||||||
|
continue
|
||||||
|
cells = [
|
||||||
|
_pad_cell(row[0], widths[0], right=True),
|
||||||
|
*(
|
||||||
|
_pad_cell(cell, width)
|
||||||
|
for cell, width in zip(row[1:], widths[1:], strict=True)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
lines.append(_TRANSCRIPT_GUTTER.join(cells))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
||||||
map_spec = load_map(args.map)
|
map_spec = load_map(args.map)
|
||||||
provided = [tuple(raw) for raw in args.side]
|
provided = [tuple(raw) for raw in args.side]
|
||||||
@@ -103,7 +181,15 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--transcript",
|
"--transcript",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="print the turn-by-turn log of the first battle before the report",
|
help="print the turn-by-turn log of the first battle as a table before the report",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--simple-monster-death",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"monsters die at hp <= 0 instead of the PF1e hp <= -CON rule "
|
||||||
|
"(player characters are never affected)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if not args.side:
|
if not args.side:
|
||||||
@@ -124,13 +210,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
spell_registry=registry,
|
spell_registry=registry,
|
||||||
capture_first_transcript=args.transcript,
|
capture_first_transcript=args.transcript,
|
||||||
|
monsters_die_at_zero=args.simple_monster_death,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if args.transcript and report.transcript is not None:
|
if args.transcript and report.transcript is not None:
|
||||||
print("=== Déroulé du premier combat ===")
|
print("=== Déroulé du premier combat ===")
|
||||||
print("\n".join(report.transcript))
|
print(render_transcript_table(report.transcript))
|
||||||
print()
|
print()
|
||||||
print(format_report(report, spec))
|
print(format_report(report, spec))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+450
-69
@@ -3,7 +3,12 @@
|
|||||||
Phase 0 documented deviations from PF1e (conventions):
|
Phase 0 documented deviations from PF1e (conventions):
|
||||||
- Action economy: a normal turn grants one standard action + one move action
|
- Action economy: a normal turn grants one standard action + one move action
|
||||||
(or one full-round action), plus swift/free/immediate. The policy returns an
|
(or one full-round action), plus swift/free/immediate. The policy returns an
|
||||||
ordered sequence of `Action` per turn; the engine executes them in order,
|
ordered sequence of `Action` per turn; the engine enforces the budget on it
|
||||||
|
(X4, `_action_economy_filter`): surplus standards/moves/swifts and a
|
||||||
|
full-round action conflicting with an already-spent standard/move are
|
||||||
|
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
||||||
|
`downgraded:action_economy`; the condition-specific filters then apply as
|
||||||
|
before. The engine executes the remaining actions in order,
|
||||||
stopping early if the actor drops or one side is wiped. `default_policy`
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||||
returns `(full_attack,)` when already in range, `(charge,)` when a
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||||
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
||||||
@@ -35,6 +40,9 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
has no -10 rule. At exactly 0 hp a living creature is disabled: active,
|
has no -10 rule. At exactly 0 hp a living creature is disabled: active,
|
||||||
staggered (one move OR standard action per round) at half speed, and a
|
staggered (one move OR standard action per round) at half speed, and a
|
||||||
non-movement action costs 1 hp at its end (hp -> -1, dying).
|
non-movement action costs 1 hp at its end (hp -> -1, dying).
|
||||||
|
Optional rule (``monsters_die_at_zero``, CLI ``--simple-monster-death``):
|
||||||
|
monster combatants die at hp <= 0 instead — player characters are never
|
||||||
|
affected and the default (flag off) is the PF1e rule above, bit-identical.
|
||||||
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
||||||
- Movement: a move action travels up to the combatant's speed in cells along
|
- Movement: a move action travels up to the combatant's speed in cells along
|
||||||
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
||||||
@@ -96,7 +104,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
from pf1e_simulator.dice import dice_to_str
|
from pf1e_simulator.dice import dice_to_str
|
||||||
@@ -118,8 +126,6 @@ from pf1e_simulator.spells import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import Pos, TerrainType
|
from pf1e_simulator.map import Pos, TerrainType
|
||||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||||
@@ -202,6 +208,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
|
|||||||
"reposition": "Improved Reposition",
|
"reposition": "Improved Reposition",
|
||||||
"drag": "Improved Drag",
|
"drag": "Improved Drag",
|
||||||
}
|
}
|
||||||
|
# PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
|
||||||
|
# level + the feat's adjustment. B7: applied per cast, never blanket-on.
|
||||||
|
_METAMAGIC_SLOT_ADJUST: dict[str, int] = {
|
||||||
|
"Empower Spell": 2,
|
||||||
|
"Maximize Spell": 3,
|
||||||
|
"Extend Spell": 1,
|
||||||
|
}
|
||||||
|
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
|
||||||
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
|
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
|
||||||
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
|
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
|
||||||
# Strength skills the conformance adjudication groups with the Dex skills.
|
# Strength skills the conformance adjudication groups with the Dex skills.
|
||||||
@@ -415,6 +429,23 @@ class CombatantStats:
|
|||||||
damage_taken: int = 0
|
damage_taken: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
_PC_SOURCE_PREFIX = "fiches_personnages/"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pc_combatant(combatant: Combatant) -> bool:
|
||||||
|
"""True for player characters, False for monsters (X5 classification).
|
||||||
|
|
||||||
|
The unified loader records each combatant's origin in ``source``: every
|
||||||
|
PC JSON (``data/pcs/*.json``) points at its Foundry sheet under
|
||||||
|
``fiches_personnages/`` (README convention), while monster JSONs carry
|
||||||
|
bestiary/summon sources. ``side`` is a user-chosen faction name and
|
||||||
|
``cr``/``xp`` are absent from some monster files, so ``source`` is the
|
||||||
|
only robust per-combatant marker that survives loading without a schema
|
||||||
|
change.
|
||||||
|
"""
|
||||||
|
return combatant.source.startswith(_PC_SOURCE_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CombatantState:
|
class CombatantState:
|
||||||
"""Mutable runtime state of one combatant on the grid."""
|
"""Mutable runtime state of one combatant on the grid."""
|
||||||
@@ -448,6 +479,10 @@ class CombatantState:
|
|||||||
# Dying & stabilization (not in the frozen data model): the hp at which
|
# Dying & stabilization (not in the frozen data model): the hp at which
|
||||||
# this combatant stabilized while dying; None = not stabilized.
|
# this combatant stabilized while dying; None = not stabilized.
|
||||||
stabilized_hp: int | None = None
|
stabilized_hp: int | None = None
|
||||||
|
# Simple monster death (X5, not in the frozen data model): set by the
|
||||||
|
# engine when ``monsters_die_at_zero`` is on, for monster combatants only
|
||||||
|
# — the combatant dies at hp <= 0 instead of the PF1e hp <= -CON rule.
|
||||||
|
dies_at_zero: bool = False
|
||||||
# Action feedback (not in the frozen data model): the ActionResults of the
|
# Action feedback (not in the frozen data model): the ActionResults of the
|
||||||
# PREVIOUS turn, assigned at the end of each turn so the Observation built
|
# PREVIOUS turn, assigned at the end of each turn so the Observation built
|
||||||
# at the next policy invocation can expose them.
|
# at the next policy invocation can expose them.
|
||||||
@@ -523,8 +558,10 @@ class CombatantState:
|
|||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
|
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
|
||||||
Death: a living creature at exactly 0 hp can still take a single move
|
Death: a living creature at exactly 0 hp can still take a single move
|
||||||
or standard action), so it remains active. hp < 0 is dying/dead."""
|
or standard action), so it remains active. hp < 0 is dying/dead.
|
||||||
return self.hp >= 0
|
Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is
|
||||||
|
dead, so the disabled band never applies."""
|
||||||
|
return self.hp >= 0 and not self.dead
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dead(self) -> bool:
|
def dead(self) -> bool:
|
||||||
@@ -532,7 +569,10 @@ class CombatantState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def death_threshold(self) -> int:
|
def death_threshold(self) -> int:
|
||||||
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule)."""
|
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule).
|
||||||
|
The optional simple monster-death rule raises the threshold to 0."""
|
||||||
|
if self.dies_at_zero:
|
||||||
|
return 0
|
||||||
return -self.combatant.abilities.con_score
|
return -self.combatant.abilities.con_score
|
||||||
|
|
||||||
|
|
||||||
@@ -686,6 +726,15 @@ class Action:
|
|||||||
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
||||||
``running``, which lasts until the runner's next turn and denies the
|
``running``, which lasts until the runner's next turn and denies the
|
||||||
runner its Dexterity bonus to AC.
|
runner its Dexterity bonus to AC.
|
||||||
|
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
|
||||||
|
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
|
||||||
|
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
|
||||||
|
apply; the spell then occupies a slot of base level + adjustment
|
||||||
|
(Empower +2, Maximize +3, Extend +1) and its save DC uses that slot
|
||||||
|
level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
|
||||||
|
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
|
||||||
|
but must pass a concentration check (DC 15 + 2 x spell level, +4 with
|
||||||
|
Combat Casting) or the spell fizzles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
@@ -713,6 +762,10 @@ class Action:
|
|||||||
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
|
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
|
||||||
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
|
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
|
||||||
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
|
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
|
||||||
|
# B7: metamagic feats to apply to THIS cast (only known ones apply)
|
||||||
|
metamagic: tuple[str, ...] = ()
|
||||||
|
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles
|
||||||
|
cast_defensively: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
||||||
@@ -727,11 +780,15 @@ class Action:
|
|||||||
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
||||||
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
||||||
# no_weapon (aid another without a usable melee weapon) and
|
# no_weapon (aid another without a usable melee weapon) and
|
||||||
# unreachable_destination (run to an unhonourable destination).
|
# unreachable_destination (run to an unhonourable destination). X4 adds
|
||||||
|
# downgraded:action_economy for actions the general per-turn budget filter
|
||||||
|
# drops from the policy sequence (second standard, second move, second
|
||||||
|
# swift, full-round conflicting with standard+move).
|
||||||
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||||
"downgraded:staggered", "downgraded:nauseated",
|
"downgraded:staggered", "downgraded:nauseated",
|
||||||
|
"downgraded:action_economy",
|
||||||
"delayed", "already_delayed", "ready",
|
"delayed", "already_delayed", "ready",
|
||||||
"not_prone", "already_prone", "unreachable_destination",
|
"not_prone", "already_prone", "unreachable_destination",
|
||||||
"weapon_unavailable", "invalid_destination",
|
"weapon_unavailable", "invalid_destination",
|
||||||
@@ -742,6 +799,7 @@ _REASON_MISSING_TARGET = "missing_target"
|
|||||||
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
||||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||||
|
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||||
_REASON_DELAYED = "delayed"
|
_REASON_DELAYED = "delayed"
|
||||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||||
_REASON_READY = "ready"
|
_REASON_READY = "ready"
|
||||||
@@ -845,6 +903,7 @@ class CombatEngine:
|
|||||||
llm_policy: LLMPolicy | None = None,
|
llm_policy: LLMPolicy | None = None,
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
reaction_policy: ReactionPolicy | None = None,
|
reaction_policy: ReactionPolicy | None = None,
|
||||||
|
monsters_die_at_zero: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if policy is not None and llm_policy is not None:
|
if policy is not None and llm_policy is not None:
|
||||||
msg = "policy and llm_policy are mutually exclusive"
|
msg = "policy and llm_policy are mutually exclusive"
|
||||||
@@ -859,6 +918,10 @@ class CombatEngine:
|
|||||||
self._policy = policy if policy is not None else default_policy
|
self._policy = policy if policy is not None else default_policy
|
||||||
self._spell_registry = spell_registry or {}
|
self._spell_registry = spell_registry or {}
|
||||||
self._reaction_policy = reaction_policy
|
self._reaction_policy = reaction_policy
|
||||||
|
self._monsters_die_at_zero = monsters_die_at_zero
|
||||||
|
if monsters_die_at_zero:
|
||||||
|
for st in states:
|
||||||
|
self._apply_simple_monster_death(st)
|
||||||
self._in_reaction = False
|
self._in_reaction = False
|
||||||
self._transcript: list[str] = []
|
self._transcript: list[str] = []
|
||||||
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
||||||
@@ -882,6 +945,12 @@ class CombatEngine:
|
|||||||
"""The spell registry used for spell lookups."""
|
"""The spell registry used for spell lookups."""
|
||||||
return self._spell_registry
|
return self._spell_registry
|
||||||
|
|
||||||
|
def _apply_simple_monster_death(self, state: CombatantState) -> None:
|
||||||
|
"""Opt-in X5 rule: with ``monsters_die_at_zero``, monster combatants
|
||||||
|
die at hp <= 0 (no dying/disabled band); PCs are never marked."""
|
||||||
|
if self._monsters_die_at_zero and not _is_pc_combatant(state.combatant):
|
||||||
|
state.dies_at_zero = True
|
||||||
|
|
||||||
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
||||||
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
||||||
|
|
||||||
@@ -1375,11 +1444,24 @@ class CombatEngine:
|
|||||||
) -> tuple[Action, ...]:
|
) -> tuple[Action, ...]:
|
||||||
"""Downgrade the policy's action sequence to the legal action economy.
|
"""Downgrade the policy's action sequence to the legal action economy.
|
||||||
|
|
||||||
Swift consumed by an immediate action; PF1e (CRB, Disabled): a
|
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
||||||
creature at exactly 0 hp is staggered — one move OR one standard
|
Combat": one standard + one move, or one full-round, plus one swift)
|
||||||
action per round, no full-round actions; nauseated: single move only.
|
is enforced first on every sequence; the condition-specific
|
||||||
Dropped kinds are reported into ``results``.
|
specializations then run on the budget-legal remainder (their output
|
||||||
|
is a single action, so the budget never re-drops it). Swift consumed
|
||||||
|
by an immediate action; PF1e (CRB, Disabled): a creature at exactly
|
||||||
|
0 hp is staggered — one move OR one standard action per round, no
|
||||||
|
full-round actions; nauseated: single move only. Dropped kinds are
|
||||||
|
reported into ``results``.
|
||||||
"""
|
"""
|
||||||
|
kept = _action_economy_filter(actions)
|
||||||
|
results.extend(
|
||||||
|
ActionResult(
|
||||||
|
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
||||||
|
)
|
||||||
|
for kind in _dropped_kinds_multiset(actions, kept)
|
||||||
|
)
|
||||||
|
actions = kept
|
||||||
if state.immediate_used:
|
if state.immediate_used:
|
||||||
kept = _without_swift_bonus(actions)
|
kept = _without_swift_bonus(actions)
|
||||||
results.extend(
|
results.extend(
|
||||||
@@ -2235,18 +2317,24 @@ class CombatEngine:
|
|||||||
dmg_mods = self._stat_modifiers(
|
dmg_mods = self._stat_modifiers(
|
||||||
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
)
|
)
|
||||||
damage = (
|
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
||||||
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
damage = sum(rolls) + weapon.damage_bonus
|
||||||
)
|
|
||||||
damage += resolve_modifiers(dmg_mods)
|
damage += resolve_modifiers(dmg_mods)
|
||||||
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
||||||
|
# bonus increased by half) with a two-handed weapon, a one-handed
|
||||||
|
# weapon used in two hands, or a primary natural with 1.5x Strength
|
||||||
|
# (modeled via the weapon's `two_handed` flag). The off-hand /
|
||||||
|
# secondary-natural halving (-50%, +1X) is NOT modeled: two-weapon
|
||||||
|
# fighting is not in the engine (documented simplification).
|
||||||
|
if weapon.kind == "melee":
|
||||||
|
damage += pa_amt * (3 if weapon.two_handed else 2)
|
||||||
if crit:
|
if crit:
|
||||||
damage *= weapon.crit_mult
|
damage *= weapon.crit_mult
|
||||||
damage += sneak_total
|
damage += sneak_total
|
||||||
damage = self._apply_dr(damage, weapon, target)
|
damage = self._apply_dr(damage, weapon, target)
|
||||||
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
|
return crit, False, self._apply_energy_resistance_per_component(
|
||||||
damage = self._apply_energy_resistance(damage, weapon_types, target)
|
damage, weapon, rolls, crit=crit, target=target
|
||||||
return crit, False, damage
|
)
|
||||||
|
|
||||||
def _concealment_chance(self, target: CombatantState) -> int:
|
def _concealment_chance(self, target: CombatantState) -> int:
|
||||||
"""Highest miss chance (%) among the target's active concealment conditions."""
|
"""Highest miss chance (%) among the target's active concealment conditions."""
|
||||||
@@ -2411,30 +2499,73 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _apply_energy_resistance_per_component(
|
||||||
|
self,
|
||||||
|
damage: int,
|
||||||
|
weapon: AttackSpec,
|
||||||
|
rolls: list[int],
|
||||||
|
*,
|
||||||
|
crit: bool,
|
||||||
|
target: CombatantState,
|
||||||
|
) -> int:
|
||||||
|
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
|
||||||
|
|
||||||
|
PF1e (CRB "Energy Resistance"/"Immunity to Energy Damage"): each
|
||||||
|
damage type's resistance reduces only the dice of that type — a
|
||||||
|
weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
|
||||||
|
loses only its fire portion; the slashing dice, the flat bonuses
|
||||||
|
(damage_bonus, ability/damage modifiers, Power Attack) and the
|
||||||
|
untyped sneak precision dice are never eaten by energy resistance.
|
||||||
|
Each component's share of the final damage is its own roll times
|
||||||
|
the crit multiplier (engine convention: every damage die is
|
||||||
|
multiplied on a crit) and its reduction is capped at that share.
|
||||||
|
A component carrying several energy types uses the largest matching
|
||||||
|
resistance (documented simplification: one die roll cannot be split
|
||||||
|
across the listed types). Physical-only components match nothing in
|
||||||
|
the target's resistance/immunity maps and stay on the DR path, which
|
||||||
|
was applied to the lumped total just before this step (unchanged);
|
||||||
|
single-component weapons keep the exact arithmetic of the old
|
||||||
|
lumped-total path.
|
||||||
|
"""
|
||||||
|
mult = weapon.crit_mult if crit else 1
|
||||||
|
for comp, roll in zip(weapon.damage, rolls, strict=True):
|
||||||
|
damage -= self._energy_reduction(roll * mult, frozenset(comp.types), target)
|
||||||
|
return max(0, damage)
|
||||||
|
|
||||||
|
def _energy_reduction(
|
||||||
|
self, damage: int, damage_types: frozenset[str], target: CombatantState
|
||||||
|
) -> int:
|
||||||
|
"""Reduction the target's immunity/resistance applies to one
|
||||||
|
damage portion of the given types, capped at the portion itself."""
|
||||||
|
immunities = target.combatant.immunities
|
||||||
|
if immunities and damage_types & frozenset(immunities):
|
||||||
|
return damage
|
||||||
|
resistances = target.combatant.resistances
|
||||||
|
if not resistances:
|
||||||
|
return 0
|
||||||
|
max_resist = max(
|
||||||
|
(resistances[t] for t in damage_types if t in resistances),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
return min(damage, max_resist)
|
||||||
|
|
||||||
def _apply_energy_resistance(
|
def _apply_energy_resistance(
|
||||||
self,
|
self,
|
||||||
damage: int,
|
damage: int,
|
||||||
damage_types: frozenset[str],
|
damage_types: frozenset[str],
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Energy resistance / immunity at the final-damage stage.
|
"""Energy resistance / immunity applied to one whole damage portion.
|
||||||
|
|
||||||
Immunity: if any damage type matches → 0. Resistance: subtract the
|
Used by the spell-damage path: a ``DamageEffect`` is a single
|
||||||
max resistance among matching energy types, floored at 0.
|
component (one formula, one type list), so the per-component rule
|
||||||
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
|
(see ``_apply_energy_resistance_per_component``) reduces to exactly
|
||||||
they stay on the DR path.
|
this. Immunity: if any damage type matches → 0. Resistance:
|
||||||
|
subtract the max resistance among matching energy types, floored
|
||||||
|
at 0. Physical types (slashing/piercing/bludgeoning/physical) are
|
||||||
|
ignored — they stay on the DR path.
|
||||||
"""
|
"""
|
||||||
immunities = target.combatant.immunities
|
return max(0, damage - self._energy_reduction(damage, damage_types, target))
|
||||||
if immunities and damage_types & frozenset(immunities):
|
|
||||||
return 0
|
|
||||||
resistances = target.combatant.resistances
|
|
||||||
if not resistances:
|
|
||||||
return damage
|
|
||||||
max_resist = max(
|
|
||||||
(resistances[t] for t in damage_types if t in resistances),
|
|
||||||
default=0,
|
|
||||||
)
|
|
||||||
return max(0, damage - max_resist)
|
|
||||||
|
|
||||||
def resolve_save(
|
def resolve_save(
|
||||||
self,
|
self,
|
||||||
@@ -2788,7 +2919,7 @@ class CombatEngine:
|
|||||||
"""
|
"""
|
||||||
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
||||||
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
|
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
|
||||||
has_combat_casting = self._has_feat(caster, "Combat Casting")
|
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
|
||||||
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
total = (
|
total = (
|
||||||
@@ -3306,7 +3437,8 @@ class CombatEngine:
|
|||||||
kind = action.kind
|
kind = action.kind
|
||||||
if kind == "cast_spell":
|
if kind == "cast_spell":
|
||||||
return self._execute_cast_spell(
|
return self._execute_cast_spell(
|
||||||
state, target, action.spell_name, target_pos=action.target_pos
|
state, target, action.spell_name, target_pos=action.target_pos,
|
||||||
|
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
||||||
)
|
)
|
||||||
if kind == "maneuver":
|
if kind == "maneuver":
|
||||||
self._execute_maneuver(
|
self._execute_maneuver(
|
||||||
@@ -3390,7 +3522,8 @@ class CombatEngine:
|
|||||||
state.immediate_used = True
|
state.immediate_used = True
|
||||||
if action.spell_name is not None:
|
if action.spell_name is not None:
|
||||||
self._execute_cast_spell(
|
self._execute_cast_spell(
|
||||||
state, target, action.spell_name, target_pos=action.target_pos
|
state, target, action.spell_name, target_pos=action.target_pos,
|
||||||
|
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
||||||
)
|
)
|
||||||
elif action.maneuver is not None:
|
elif action.maneuver is not None:
|
||||||
self._execute_maneuver(state, target, action.maneuver)
|
self._execute_maneuver(state, target, action.maneuver)
|
||||||
@@ -3507,10 +3640,14 @@ class CombatEngine:
|
|||||||
spell_name: str | None,
|
spell_name: str | None,
|
||||||
*,
|
*,
|
||||||
target_pos: tuple[int, int] | None = None,
|
target_pos: tuple[int, int] | None = None,
|
||||||
|
metamagic: tuple[str, ...] = (),
|
||||||
|
cast_defensively: bool = False,
|
||||||
) -> ActionResult | None:
|
) -> ActionResult | None:
|
||||||
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
|
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
|
||||||
validated first — an off-map point or one without line of effect
|
validated first — an off-map point or one without line of effect
|
||||||
from the caster is rejected without casting."""
|
from the caster is rejected without casting. ``metamagic`` (B7)
|
||||||
|
selects the feats applied to THIS cast; ``cast_defensively`` (B24)
|
||||||
|
trades the casting AoO for a concentration check."""
|
||||||
if self.has_condition_flag(state, "cant_attack"):
|
if self.has_condition_flag(state, "cant_attack"):
|
||||||
# PF1e (CRB): a nauseated creature cannot cast spells or
|
# PF1e (CRB): a nauseated creature cannot cast spells or
|
||||||
# concentrate; the cant_attack flag blocks the cast entirely.
|
# concentrate; the cant_attack flag blocks the cast entirely.
|
||||||
@@ -3525,8 +3662,10 @@ class CombatEngine:
|
|||||||
return rejection
|
return rejection
|
||||||
if spell_name is None:
|
if spell_name is None:
|
||||||
return None
|
return None
|
||||||
self._cast_spell(state, target, spell_name, center_pos=target_pos)
|
return self._cast_spell(
|
||||||
return None
|
state, target, spell_name, center_pos=target_pos,
|
||||||
|
metamagic=metamagic, cast_defensively=cast_defensively,
|
||||||
|
)
|
||||||
|
|
||||||
def _point_target_rejection(
|
def _point_target_rejection(
|
||||||
self, state: CombatantState, target_pos: tuple[int, int]
|
self, state: CombatantState, target_pos: tuple[int, int]
|
||||||
@@ -3578,7 +3717,13 @@ class CombatEngine:
|
|||||||
if not state.active:
|
if not state.active:
|
||||||
return
|
return
|
||||||
|
|
||||||
attacker_cmb = self._maneuver_cmb(state)
|
# PF1e (CRB Power Attack): the -X penalty applies to all melee attack
|
||||||
|
# rolls AND combat maneuver checks while the feat is active. The
|
||||||
|
# escape option that substitutes an Acrobatics/Escape Artist skill
|
||||||
|
# check is not penalized (a skill check is not a CMB check) — see
|
||||||
|
# _maneuver_escape.
|
||||||
|
pa_amt = self._power_attack_amt(state)
|
||||||
|
attacker_cmb = self._maneuver_cmb(state) - pa_amt
|
||||||
target_cmd = self._maneuver_cmd(target)
|
target_cmd = self._maneuver_cmd(target)
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
total = roll + attacker_cmb
|
total = roll + attacker_cmb
|
||||||
@@ -4361,9 +4506,16 @@ class CombatEngine:
|
|||||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
||||||
return (ability_score - 10) // 2
|
return (ability_score - 10) // 2
|
||||||
|
|
||||||
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
|
def _spell_dc(
|
||||||
"""Spell save DC: 10 + spell level + casting ability modifier."""
|
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
|
||||||
return 10 + spell.level + self._casting_ability_mod(caster)
|
) -> int:
|
||||||
|
"""Spell save DC: 10 + spell level + casting ability modifier.
|
||||||
|
|
||||||
|
PF1e (CRB, B7): a metamagic spell uses the level of the slot cast
|
||||||
|
from, so ``slot_level`` overrides the spell's base level when given.
|
||||||
|
"""
|
||||||
|
level = spell.level if slot_level is None else slot_level
|
||||||
|
return 10 + level + self._casting_ability_mod(caster)
|
||||||
|
|
||||||
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
|
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
|
||||||
"""One per-spell dict for the Observation, enriched from the registry."""
|
"""One per-spell dict for the Observation, enriched from the registry."""
|
||||||
@@ -4402,7 +4554,9 @@ class CombatEngine:
|
|||||||
spell_name: str,
|
spell_name: str,
|
||||||
*,
|
*,
|
||||||
center_pos: tuple[int, int] | None = None,
|
center_pos: tuple[int, int] | None = None,
|
||||||
) -> None:
|
metamagic: tuple[str, ...] = (),
|
||||||
|
cast_defensively: bool = False,
|
||||||
|
) -> ActionResult | None:
|
||||||
"""Cast ``spell_name``.
|
"""Cast ``spell_name``.
|
||||||
|
|
||||||
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell
|
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell
|
||||||
@@ -4410,7 +4564,12 @@ class CombatEngine:
|
|||||||
spells carrying an area; every other effect still resolves on the
|
spells carrying an area; every other effect still resolves on the
|
||||||
target. A cast with no resolved target requires ``center_pos`` and an
|
target. A cast with no resolved target requires ``center_pos`` and an
|
||||||
area effect — the primary save is skipped and each creature in the
|
area effect — the primary save is skipped and each creature in the
|
||||||
area rolls its own.
|
area rolls its own. ``metamagic`` (B7) lists the feats applied to
|
||||||
|
THIS cast (only feats the caster knows apply, and the spell then
|
||||||
|
occupies — and must have — a slot of base level + adjustment).
|
||||||
|
``cast_defensively`` (B24) skips the casting AoO for a concentration
|
||||||
|
check. Returns an explicit rejection (``no_slot``) when the required
|
||||||
|
slot is unavailable; None means the cast proceeded through the gates.
|
||||||
"""
|
"""
|
||||||
spell = self._spell_registry.get(spell_name)
|
spell = self._spell_registry.get(spell_name)
|
||||||
if spell is None:
|
if spell is None:
|
||||||
@@ -4418,31 +4577,128 @@ class CombatEngine:
|
|||||||
f"round {self._current_round} {caster.combatant.id}: "
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
f"cast {spell_name} -> UNKNOWN SPELL"
|
f"cast {spell_name} -> UNKNOWN SPELL"
|
||||||
)
|
)
|
||||||
return
|
return None
|
||||||
|
# PF1e (CRB, Metamagic Feats): metamagic is chosen per cast and only
|
||||||
|
# the feats the caster actually knows can be applied (B7).
|
||||||
|
known = set(caster.combatant.metamagic_feats)
|
||||||
|
applied = tuple(f for f in metamagic if f in known)
|
||||||
|
used_level = spell.level + sum(
|
||||||
|
_METAMAGIC_SLOT_ADJUST.get(f, 0) for f in applied
|
||||||
|
)
|
||||||
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
||||||
if self._spell_cast_rejected(
|
if self._spell_cast_rejected(
|
||||||
caster, target, spell, spell_name, center_pos, has_area=has_area
|
caster, target, spell, spell_name, center_pos, has_area=has_area
|
||||||
):
|
):
|
||||||
return
|
return None
|
||||||
# PF1e (CRB): a caster cannot cast without an available slot of the
|
# PF1e (CRB): a caster cannot cast without an available slot of the
|
||||||
# spell's level; cantrips (level 0) are cast freely. The slot is spent
|
# spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
|
||||||
# up-front — a later failed concentration check still burns it.
|
# freely. The slot is spent up-front — a later failed concentration
|
||||||
if spell.level > 0 and not caster.spend_spell_slot(spell.level):
|
# check or missed touch attack still burns it.
|
||||||
|
if used_level > 0 and not caster.spend_spell_slot(used_level):
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {caster.combatant.id}: "
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
f"cast {spell_name} -> NO SPELL SLOT (level {spell.level})"
|
f"cast {spell_name} -> NO SPELL SLOT (level {used_level})"
|
||||||
)
|
)
|
||||||
return
|
return ActionResult(
|
||||||
# Check for AoO from casting in threatened square (before resolving spell)
|
kind="cast_spell", ok=False, reason="no_slot",
|
||||||
if self._check_casting_aoo(caster, spell.level):
|
detail=f"level {used_level}",
|
||||||
return
|
)
|
||||||
|
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
|
||||||
|
# must clear their interruption gate before the spell is delivered.
|
||||||
|
if self._spell_cast_interrupted(
|
||||||
|
caster, used_level, cast_defensively=cast_defensively
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
|
||||||
|
# and spell resistance can still turn the spell away.
|
||||||
|
if self._spell_delivery_blocked(caster, target, spell, has_area=has_area):
|
||||||
|
return None
|
||||||
|
self._apply_and_log_cast(
|
||||||
|
caster, target, spell, spell_name, center_pos,
|
||||||
|
metamagic=applied, slot_level=used_level,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _spell_cast_interrupted(
|
||||||
|
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Pre-delivery interruption gate (B24).
|
||||||
|
|
||||||
|
PF1e (CRB, Concentration): while grappled the caster must pass
|
||||||
|
DC 10 + grappler's CMB + spell level to cast at all; casting
|
||||||
|
defensively forgoes the provocation but requires DC 15 + 2x spell
|
||||||
|
level (Combat Casting's +4 applies to both); otherwise casting in a
|
||||||
|
threatened square provokes the AoO (with the injured-casting check).
|
||||||
|
True = the spell is lost (the slot is already spent).
|
||||||
|
"""
|
||||||
|
if caster.grappled_with is not None or cast_defensively:
|
||||||
|
if not self._check_concentration(caster, spell_level, 0):
|
||||||
|
dc = self._concentration_dc(caster, spell_level, 0)
|
||||||
|
situation = (
|
||||||
|
"while grappled" if caster.grappled_with is not None else "defensively"
|
||||||
|
)
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
|
f"casting {situation}: concentration failed (DC {dc}), spell lost"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return self._check_casting_aoo(caster, spell_level)
|
||||||
|
|
||||||
|
def _spell_delivery_blocked(
|
||||||
|
self,
|
||||||
|
caster: CombatantState,
|
||||||
|
target: CombatantState | None,
|
||||||
|
spell: SpellSpec,
|
||||||
|
*,
|
||||||
|
has_area: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Delivery gates: a hostile touch spell must land its melee touch
|
||||||
|
attack (B14), then spell resistance must be overcome. True = no
|
||||||
|
effect (the slot is already spent)."""
|
||||||
if (
|
if (
|
||||||
|
target is not None
|
||||||
|
and spell.range == "touch"
|
||||||
|
and target.side != caster.side
|
||||||
|
and not self._spell_touch_attack_hits(caster, target)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
target is not None
|
target is not None
|
||||||
and not has_area
|
and not has_area
|
||||||
and not self._check_spell_resistance(caster, target)
|
and not self._check_spell_resistance(caster, target)
|
||||||
):
|
)
|
||||||
return
|
|
||||||
self._apply_and_log_cast(caster, target, spell, spell_name, center_pos)
|
def _spell_touch_attack_hits(
|
||||||
|
self, caster: CombatantState, target: CombatantState
|
||||||
|
) -> bool:
|
||||||
|
"""Melee touch attack to deliver a touch-range spell (B14).
|
||||||
|
|
||||||
|
PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch
|
||||||
|
AC. Engine simplification (documented): the bonus is BAB + Strength
|
||||||
|
modifier — Weapon Finesse and other situational touch bonuses are
|
||||||
|
not modeled; natural 20 hits and natural 1 misses per the engine's
|
||||||
|
attack-roll convention. The touch AC reuses the resolve_attack
|
||||||
|
denies-Dex path (flat-footed/running target, invisible attacker).
|
||||||
|
"""
|
||||||
|
roll = self._rng.d20()
|
||||||
|
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod
|
||||||
|
denies_dex = (
|
||||||
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
||||||
|
or target.running
|
||||||
|
or self._has_condition_named(caster, "invisible")
|
||||||
|
)
|
||||||
|
if denies_dex:
|
||||||
|
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||||
|
else:
|
||||||
|
ac = target.combatant.ac.touch
|
||||||
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {caster.combatant.id}: touch attack vs "
|
||||||
|
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
||||||
|
f"-> {'HIT' if hit else 'MISS'}"
|
||||||
|
)
|
||||||
|
return hit
|
||||||
|
|
||||||
def _spell_cast_rejected(
|
def _spell_cast_rejected(
|
||||||
self,
|
self,
|
||||||
@@ -4512,9 +4768,18 @@ class CombatEngine:
|
|||||||
spell: SpellSpec,
|
spell: SpellSpec,
|
||||||
spell_name: str,
|
spell_name: str,
|
||||||
center_pos: tuple[int, int] | None,
|
center_pos: tuple[int, int] | None,
|
||||||
|
*,
|
||||||
|
metamagic: tuple[str, ...] = (),
|
||||||
|
slot_level: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Resolve save, apply effects and log a spell cast (post-gates)."""
|
"""Resolve save, apply effects and log a spell cast (post-gates).
|
||||||
dc = self._spell_dc(caster, spell)
|
|
||||||
|
``metamagic`` holds the feats chosen for THIS cast (already filtered
|
||||||
|
to known ones, B7); ``slot_level`` is the spell's level in the slot
|
||||||
|
used — the save DC is computed from it (PF1e CRB: a metamagic spell
|
||||||
|
uses the DC of the higher slot level).
|
||||||
|
"""
|
||||||
|
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
||||||
save_result = (
|
save_result = (
|
||||||
self.resolve_save(
|
self.resolve_save(
|
||||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
@@ -4526,15 +4791,14 @@ class CombatEngine:
|
|||||||
target_live = (
|
target_live = (
|
||||||
self._stats[target.combatant.id] if target is not None else caster_live
|
self._stats[target.combatant.id] if target is not None else caster_live
|
||||||
)
|
)
|
||||||
# Apply metamagic feats to spell effects
|
# Apply only the metamagic feats chosen for this cast (B7)
|
||||||
metamagic_feats = caster.combatant.metamagic_feats
|
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
||||||
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
|
|
||||||
hp_before = target.hp if target is not None else caster.hp
|
hp_before = target.hp if target is not None else caster.hp
|
||||||
parts = self._apply_spell_effects_from_list(
|
parts = self._apply_spell_effects_from_list(
|
||||||
metamagic_effects, target, save_result, caster_live, target_live,
|
metamagic_effects, target, save_result, caster_live, target_live,
|
||||||
caster=caster, spell_name=spell.name,
|
caster=caster, spell_name=spell.name,
|
||||||
empower="Empower Spell" in metamagic_feats,
|
empower="Empower Spell" in metamagic,
|
||||||
maximize="Maximize Spell" in metamagic_feats,
|
maximize="Maximize Spell" in metamagic,
|
||||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
dc=dc, center_pos=center_pos, save_type=spell.save,
|
||||||
)
|
)
|
||||||
save_str = _save_label(save_result)
|
save_str = _save_label(save_result)
|
||||||
@@ -4856,9 +5120,9 @@ class CombatEngine:
|
|||||||
return save_result.success
|
return save_result.success
|
||||||
|
|
||||||
def _apply_metamagic(
|
def _apply_metamagic(
|
||||||
self, spell: SpellSpec, metamagic_feats: list[str]
|
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
||||||
) -> tuple[SpellEffect, ...]:
|
) -> tuple[SpellEffect, ...]:
|
||||||
"""Apply metamagic feats to spell effects on the fly."""
|
"""Apply the feats chosen for THIS cast to the spell's effects (B7)."""
|
||||||
if not metamagic_feats:
|
if not metamagic_feats:
|
||||||
return spell.effects
|
return spell.effects
|
||||||
has_extend = "Extend Spell" in metamagic_feats
|
has_extend = "Extend Spell" in metamagic_feats
|
||||||
@@ -4941,6 +5205,7 @@ class CombatEngine:
|
|||||||
# Set expiration
|
# Set expiration
|
||||||
summoned_state.summon_duration = effect.duration_rounds
|
summoned_state.summon_duration = effect.duration_rounds
|
||||||
summoned_state.summon_caster_id = state.combatant.id
|
summoned_state.summon_caster_id = state.combatant.id
|
||||||
|
self._apply_simple_monster_death(summoned_state)
|
||||||
|
|
||||||
self._stats[summoned_state.combatant.id] = _LiveStats()
|
self._stats[summoned_state.combatant.id] = _LiveStats()
|
||||||
self._pending_summons.append(summoned_state)
|
self._pending_summons.append(summoned_state)
|
||||||
@@ -5569,6 +5834,120 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
|
||||||
|
# action + one move action (or one full-round action in place of both), plus
|
||||||
|
# one swift action; free and conditional actions never consume the budget.
|
||||||
|
# Every Action kind is classified into exactly one slot (exhaustive over the
|
||||||
|
# Action.kind Literal, locked by tests/test_conformance_action_economy.py):
|
||||||
|
# - standard: attack, cast_spell (the engine models no spell casting times —
|
||||||
|
# SpellSpec has no cast_time field, so every spell casts as a standard),
|
||||||
|
# maneuver, channel_energy, hex, delay, ready, total_defense, aid_another
|
||||||
|
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
|
||||||
|
# repair_weapon, run (a full-round action in RAW, but this engine models it
|
||||||
|
# as move machinery that sets moved_this_turn and already excludes it from
|
||||||
|
# _FULL_ROUND_KINDS — kept consistent, see README)
|
||||||
|
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
||||||
|
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
||||||
|
# (spell + full attack in one round); consumes the standard AND the move slot
|
||||||
|
# - free/conditional (everything else, no budget): wait, free, immediate
|
||||||
|
# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's
|
||||||
|
# slots), 5foot_step (free step, mutually exclusive with other movement via
|
||||||
|
# moved_this_turn), drop_prone, activate_power_attack and fight_defensively
|
||||||
|
# (mode declarations applied to the attack that follows them, like the
|
||||||
|
# Power Attack toggle), summon is not a kind (summons arrive via cast_spell).
|
||||||
|
_ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
|
||||||
|
"attack", "cast_spell", "maneuver", "channel_energy", "hex",
|
||||||
|
"delay", "ready", "total_defense", "aid_another",
|
||||||
|
})
|
||||||
|
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
|
||||||
|
"move", "stand_up", "retrieve_weapon", "repair_weapon", "run",
|
||||||
|
})
|
||||||
|
_ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
|
||||||
|
{"swift", "deed", "inspire_courage"}
|
||||||
|
)
|
||||||
|
_ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({
|
||||||
|
"full_attack", "charge", "withdraw", "full_round", "spell_combat",
|
||||||
|
})
|
||||||
|
_ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({
|
||||||
|
"wait", "free", "immediate", "5foot_step", "drop_prone",
|
||||||
|
"activate_power_attack", "fight_defensively",
|
||||||
|
})
|
||||||
|
|
||||||
|
_ActionEconomySlot = Literal["standard", "move", "swift", "full_round", "free"]
|
||||||
|
|
||||||
|
|
||||||
|
def _action_economy_slot(kind: str) -> _ActionEconomySlot:
|
||||||
|
"""The budget slot one action kind consumes (free if none applies)."""
|
||||||
|
if kind in _ACTION_ECONOMY_STANDARD_KINDS:
|
||||||
|
return "standard"
|
||||||
|
if kind in _ACTION_ECONOMY_MOVE_KINDS:
|
||||||
|
return "move"
|
||||||
|
if kind in _ACTION_ECONOMY_SWIFT_KINDS:
|
||||||
|
return "swift"
|
||||||
|
if kind in _ACTION_ECONOMY_FULL_ROUND_KINDS:
|
||||||
|
return "full_round"
|
||||||
|
return "free"
|
||||||
|
|
||||||
|
|
||||||
|
def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||||
|
"""Degrade a policy sequence to the legal PF1e action economy.
|
||||||
|
|
||||||
|
Greedy in-order walk: each action is kept while its budget slot is free
|
||||||
|
(one standard, one move, one swift; a full-round action is only legal
|
||||||
|
while BOTH the standard and the move slot are still free and consumes
|
||||||
|
them all). The kept sequence is therefore the earliest order-stable legal
|
||||||
|
prefix-subsequence; every skipped action is deterministic.
|
||||||
|
"""
|
||||||
|
kept: list[Action] = []
|
||||||
|
standard_used = False
|
||||||
|
move_used = False
|
||||||
|
swift_used = False
|
||||||
|
for action in actions:
|
||||||
|
slot = _action_economy_slot(action.kind)
|
||||||
|
if slot == "standard":
|
||||||
|
if standard_used:
|
||||||
|
continue
|
||||||
|
standard_used = True
|
||||||
|
elif slot == "move":
|
||||||
|
if move_used:
|
||||||
|
continue
|
||||||
|
move_used = True
|
||||||
|
elif slot == "swift":
|
||||||
|
if swift_used:
|
||||||
|
continue
|
||||||
|
swift_used = True
|
||||||
|
elif slot == "full_round":
|
||||||
|
if standard_used or move_used:
|
||||||
|
continue
|
||||||
|
standard_used = True
|
||||||
|
move_used = True
|
||||||
|
kept.append(action)
|
||||||
|
return tuple(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def _dropped_kinds_multiset(
|
||||||
|
before: tuple[Action, ...], after: tuple[Action, ...]
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""One dropped-kind entry per dropped action instance (in order).
|
||||||
|
|
||||||
|
Unlike ``_dropped_kinds`` (set membership, which cannot report dropping
|
||||||
|
the second of two same-kind actions), this consumes the kept instances
|
||||||
|
first and reports every surplus — the budget filter regularly drops a
|
||||||
|
duplicate standard/move/swift action.
|
||||||
|
"""
|
||||||
|
remaining: dict[str, int] = {}
|
||||||
|
for action in after:
|
||||||
|
remaining[action.kind] = remaining.get(action.kind, 0) + 1
|
||||||
|
dropped: list[str] = []
|
||||||
|
for action in before:
|
||||||
|
count = remaining.get(action.kind, 0)
|
||||||
|
if count > 0:
|
||||||
|
remaining[action.kind] = count - 1
|
||||||
|
else:
|
||||||
|
dropped.append(action.kind)
|
||||||
|
return tuple(dropped)
|
||||||
|
|
||||||
|
|
||||||
def _ready_payload(action: Action) -> Action:
|
def _ready_payload(action: Action) -> Action:
|
||||||
"""Derive the concrete payload of a ready action from its carrier fields.
|
"""Derive the concrete payload of a ready action from its carrier fields.
|
||||||
|
|
||||||
@@ -5582,6 +5961,8 @@ def _ready_payload(action: Action) -> Action:
|
|||||||
target_id=action.target_id,
|
target_id=action.target_id,
|
||||||
spell_name=action.spell_name,
|
spell_name=action.spell_name,
|
||||||
target_pos=action.target_pos,
|
target_pos=action.target_pos,
|
||||||
|
metamagic=action.metamagic,
|
||||||
|
cast_defensively=action.cast_defensively,
|
||||||
)
|
)
|
||||||
if action.maneuver is not None:
|
if action.maneuver is not None:
|
||||||
return Action(
|
return Action(
|
||||||
|
|||||||
@@ -254,9 +254,12 @@ def cover_bonus_cells(
|
|||||||
"""Effective cover bonus (0/2/4/8) for an attack between two sets of cells.
|
"""Effective cover bonus (0/2/4/8) for an attack between two sets of cells.
|
||||||
|
|
||||||
0 when there is no cover (see has_cover_cells for the corner rule);
|
0 when there is no cover (see has_cover_cells for the corner rule);
|
||||||
otherwise the highest cover level among the cells blocking the
|
otherwise the cover level of the attacker's BEST occupied cell: each
|
||||||
attacker-corner to target-corner lines. Interposed creatures grant soft
|
attacker cell is scored by the per-cell corner rule against all of the
|
||||||
cover (+4) on ranged attacks only, exactly as in has_cover_cells.
|
target's cells, and the attacker picks the lowest-scoring (clearest)
|
||||||
|
cell. Interposed creatures grant soft cover (+4) on ranged attacks only,
|
||||||
|
exactly as in has_cover_cells. For a 1x1 attacker this is exactly the
|
||||||
|
single-cell value.
|
||||||
"""
|
"""
|
||||||
if not attacker_cells or not target_cells:
|
if not attacker_cells or not target_cells:
|
||||||
return 0
|
return 0
|
||||||
@@ -271,10 +274,18 @@ def cover_bonus_cells(
|
|||||||
occupied if ranged else frozenset(),
|
occupied if ranged else frozenset(),
|
||||||
bonus_of=terrain_cover_bonus,
|
bonus_of=terrain_cover_bonus,
|
||||||
)
|
)
|
||||||
return max(
|
# 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 is unchanged: RAW checks ANY line to ANY target corner, so
|
||||||
|
# each attacker cell is scored by the MAXIMUM over its corner lines.
|
||||||
|
per_cell_best = (
|
||||||
|
max(
|
||||||
lines.bonus(corner_a, corner_t)
|
lines.bonus(corner_a, corner_t)
|
||||||
for cell_a in attacker_cells
|
|
||||||
for corner_a in _corners(cell_a)
|
for corner_a in _corners(cell_a)
|
||||||
for cell_t in target_cells
|
for cell_t in target_cells
|
||||||
for corner_t in _corners(cell_t)
|
for corner_t in _corners(cell_t)
|
||||||
)
|
)
|
||||||
|
for cell_a in attacker_cells
|
||||||
|
)
|
||||||
|
return min(per_cell_best)
|
||||||
|
|||||||
@@ -239,7 +239,10 @@ class AttackSpec(_FrozenModel):
|
|||||||
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
||||||
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
|
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
|
||||||
weapon (gun) whose shots resolve against touch AC within the first range
|
weapon (gun) whose shots resolve against touch AC within the first range
|
||||||
increment and against full AC beyond it.
|
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").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
@@ -254,6 +257,7 @@ class AttackSpec(_FrozenModel):
|
|||||||
range_increment_ft: int | None = None
|
range_increment_ft: int | None = None
|
||||||
count: int = Field(default=1, ge=1)
|
count: int = Field(default=1, ge=1)
|
||||||
firearm: bool = False
|
firearm: bool = False
|
||||||
|
two_handed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DamageReduction(_FrozenModel):
|
class DamageReduction(_FrozenModel):
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ def run_encounter(
|
|||||||
policy: Policy | None = None,
|
policy: Policy | None = None,
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
capture_first_transcript: bool = False,
|
capture_first_transcript: bool = False,
|
||||||
|
monsters_die_at_zero: bool = False,
|
||||||
) -> EncounterReport:
|
) -> EncounterReport:
|
||||||
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
|
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
|
||||||
|
|
||||||
@@ -177,6 +178,10 @@ def run_encounter(
|
|||||||
|
|
||||||
When ``capture_first_transcript`` is True, the turn-by-turn log of the
|
When ``capture_first_transcript`` is True, the turn-by-turn log of the
|
||||||
first battle (``seed + 0``) is stored in ``report.transcript``.
|
first battle (``seed + 0``) is stored in ``report.transcript``.
|
||||||
|
|
||||||
|
``monsters_die_at_zero`` enables the optional simple monster-death rule
|
||||||
|
(CLI ``--simple-monster-death``): monsters die at hp <= 0; PCs keep the
|
||||||
|
PF1e hp <= -CON rule. Default False keeps behavior bit-identical.
|
||||||
"""
|
"""
|
||||||
if runs <= 0:
|
if runs <= 0:
|
||||||
msg = "runs must be positive"
|
msg = "runs must be positive"
|
||||||
@@ -202,6 +207,7 @@ def run_encounter(
|
|||||||
round_cap=spec.round_cap,
|
round_cap=spec.round_cap,
|
||||||
policy=policy,
|
policy=policy,
|
||||||
spell_registry=spell_registry,
|
spell_registry=spell_registry,
|
||||||
|
monsters_die_at_zero=monsters_die_at_zero,
|
||||||
).run()
|
).run()
|
||||||
if capture_first_transcript and index == 0:
|
if capture_first_transcript and index == 0:
|
||||||
transcript = result.transcript
|
transcript = result.transcript
|
||||||
|
|||||||
+7
-1
@@ -80,8 +80,14 @@ def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[st
|
|||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "Déroulé du premier combat" in out
|
assert "Déroulé du premier combat" in out
|
||||||
|
# X6: the transcript is rendered as a table (header + separator); engine
|
||||||
|
# lines outside the "round N id: message" shape (initiative rolls) stay
|
||||||
|
# visible as raw fallback rows.
|
||||||
|
assert "Round" in out
|
||||||
|
assert "Combattant" in out
|
||||||
|
assert "Message" in out
|
||||||
assert "initiative:" in out
|
assert "initiative:" in out
|
||||||
assert "round 1" in out
|
assert "goblin" in out
|
||||||
# The balance report must still be printed after the transcript.
|
# The balance report must still be printed after the transcript.
|
||||||
assert "Rapport d'équilibrage" in out
|
assert "Rapport d'équilibrage" in out
|
||||||
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Conformance X4: the general per-turn action-economy budget.
|
||||||
|
|
||||||
|
PF1e CRB "Actions in Combat": one standard action + one move action (or one
|
||||||
|
full-round action in place of both), plus one swift action per turn; free and
|
||||||
|
conditional actions never consume the budget. ``_action_economy_filter``
|
||||||
|
enforces this on EVERY policy-returned sequence (unconditionally, not only
|
||||||
|
for the immediate/staggered/nauseated specializations): surplus actions are
|
||||||
|
dropped in order (the earliest legal prefix-subsequence is kept) and reported
|
||||||
|
as ``downgraded:action_economy`` in ``last_action_results``. ``default_policy``
|
||||||
|
turns are legal by construction — verified here so the goldens stay frozen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
_ACTION_ECONOMY_FREE_KINDS,
|
||||||
|
_ACTION_ECONOMY_FULL_ROUND_KINDS,
|
||||||
|
_ACTION_ECONOMY_MOVE_KINDS,
|
||||||
|
_ACTION_ECONOMY_STANDARD_KINDS,
|
||||||
|
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||||
|
ACTION_RESULT_REASONS,
|
||||||
|
Action,
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
_action_economy_filter,
|
||||||
|
_action_economy_slot,
|
||||||
|
_dropped_kinds,
|
||||||
|
_dropped_kinds_multiset,
|
||||||
|
default_policy,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||||
|
from pf1e_simulator.effects import ActiveCondition
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from tests.test_combat import make_combatant, make_state
|
||||||
|
from tests.test_observation_enriched import make_grid
|
||||||
|
|
||||||
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
|
_Kind = Literal[
|
||||||
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
|
"activate_power_attack", "delay", "ready", "total_defense",
|
||||||
|
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run",
|
||||||
|
]
|
||||||
|
|
||||||
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
||||||
|
|
||||||
|
|
||||||
|
def _foe(kind: _Kind) -> Action:
|
||||||
|
return Action(kind=kind, target_id="foe")
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) the kind -> slot classification is exhaustive and disjoint ───────────
|
||||||
|
|
||||||
|
_ALL_KINDS: tuple[_Kind, ...] = (
|
||||||
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
|
"activate_power_attack", "delay", "ready", "total_defense",
|
||||||
|
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_action_kind_is_classified_exactly_once() -> None:
|
||||||
|
groups = (
|
||||||
|
_ACTION_ECONOMY_STANDARD_KINDS,
|
||||||
|
_ACTION_ECONOMY_MOVE_KINDS,
|
||||||
|
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||||
|
_ACTION_ECONOMY_FULL_ROUND_KINDS,
|
||||||
|
_ACTION_ECONOMY_FREE_KINDS,
|
||||||
|
)
|
||||||
|
covered = frozenset().union(*groups)
|
||||||
|
assert covered == frozenset(_ALL_KINDS)
|
||||||
|
# Pairwise disjoint: no kind consumes two slots.
|
||||||
|
total = sum(len(g) for g in groups)
|
||||||
|
assert total == len(covered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_per_kind_matches_pf1e_action_type() -> None:
|
||||||
|
expected: dict[str, str] = {
|
||||||
|
# standard actions
|
||||||
|
"attack": "standard", "cast_spell": "standard", "maneuver": "standard",
|
||||||
|
"channel_energy": "standard", "hex": "standard", "delay": "standard",
|
||||||
|
"ready": "standard", "total_defense": "standard",
|
||||||
|
"aid_another": "standard",
|
||||||
|
# move actions
|
||||||
|
"move": "move", "stand_up": "move", "retrieve_weapon": "move",
|
||||||
|
"repair_weapon": "move",
|
||||||
|
# run: RAW full-round, but the engine models it as move machinery
|
||||||
|
# (sets moved_this_turn, excluded from _FULL_ROUND_KINDS) — consistent.
|
||||||
|
"run": "move",
|
||||||
|
# swift actions
|
||||||
|
"swift": "swift", "deed": "swift", "inspire_courage": "swift",
|
||||||
|
# full-round actions
|
||||||
|
"full_attack": "full_round", "charge": "full_round",
|
||||||
|
"withdraw": "full_round", "full_round": "full_round",
|
||||||
|
"spell_combat": "full_round",
|
||||||
|
# free / conditional — no budget
|
||||||
|
"wait": "free", "free": "free", "immediate": "free",
|
||||||
|
"5foot_step": "free", "drop_prone": "free",
|
||||||
|
"activate_power_attack": "free", "fight_defensively": "free",
|
||||||
|
}
|
||||||
|
assert set(expected) == set(_ALL_KINDS)
|
||||||
|
for kind, slot in expected.items():
|
||||||
|
assert _action_economy_slot(kind) == slot, kind
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_reason_is_in_the_result_vocabulary() -> None:
|
||||||
|
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) legal sequences pass through untouched ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_legal_sequences_are_kept_unchanged() -> None:
|
||||||
|
legal: tuple[tuple[_Kind, ...], ...] = (
|
||||||
|
("attack", "move"),
|
||||||
|
("move", "attack"),
|
||||||
|
("deed", "attack"),
|
||||||
|
("swift", "attack"),
|
||||||
|
("full_attack",),
|
||||||
|
("charge",),
|
||||||
|
("withdraw",),
|
||||||
|
("run",),
|
||||||
|
("5foot_step", "full_attack"),
|
||||||
|
("move", "5foot_step"),
|
||||||
|
("5foot_step", "move"),
|
||||||
|
("inspire_courage", "spell_combat"),
|
||||||
|
("activate_power_attack", "full_attack"),
|
||||||
|
("drop_prone", "attack"),
|
||||||
|
("fight_defensively", "attack"),
|
||||||
|
("deed", "move", "attack"),
|
||||||
|
("wait",),
|
||||||
|
)
|
||||||
|
for kinds in legal:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
assert _action_economy_filter(actions) == actions, kinds
|
||||||
|
assert _dropped_kinds_multiset(actions, actions) == (), kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_moves_conflict_but_free_steps_do_not() -> None:
|
||||||
|
# stand_up is a move action: a second move is surplus.
|
||||||
|
actions = (_foe("move"), _foe("stand_up"))
|
||||||
|
assert _action_economy_filter(actions) == (_foe("move"),)
|
||||||
|
# 5foot_step is free: two steps do not consume the move slot.
|
||||||
|
steps = (_foe("5foot_step"), _foe("5foot_step"))
|
||||||
|
assert _action_economy_filter(steps) == steps
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c) illegal sequences degrade to the earliest legal prefix ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_illegal_sequences_degrade_deterministically() -> None:
|
||||||
|
cases: tuple[tuple[tuple[_Kind, ...], tuple[_Kind, ...]], ...] = (
|
||||||
|
# two standards — the second is surplus
|
||||||
|
(("attack", "attack"), ("attack",)),
|
||||||
|
# charge (full-round) then a standard attack
|
||||||
|
(("charge", "attack"), ("charge",)),
|
||||||
|
# standard spent, then a full-round conflicting with standard+move
|
||||||
|
(("attack", "full_attack", "move"), ("attack", "move")),
|
||||||
|
# two moves around a standard
|
||||||
|
(("move", "attack", "move"), ("move", "attack")),
|
||||||
|
# two swifts — the second is surplus
|
||||||
|
(("full_attack", "deed", "inspire_courage"), ("full_attack", "deed")),
|
||||||
|
# full-round then a move (charge consumes standard+move)
|
||||||
|
(("full_attack", "move"), ("full_attack",)),
|
||||||
|
# two full-round actions
|
||||||
|
(("charge", "withdraw"), ("charge",)),
|
||||||
|
# move first, then a full-round that needed the move slot
|
||||||
|
(("move", "charge"), ("move",)),
|
||||||
|
# three standards
|
||||||
|
(("attack", "cast_spell", "maneuver"), ("attack",)),
|
||||||
|
)
|
||||||
|
for kinds, kept_kinds in cases:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
expected = tuple(_foe(k) for k in kept_kinds)
|
||||||
|
assert _action_economy_filter(actions) == expected, kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiset_drop_reports_each_surplus_instance() -> None:
|
||||||
|
actions = (_foe("attack"), _foe("attack"))
|
||||||
|
assert _dropped_kinds_multiset(actions, (actions[0],)) == ("attack",)
|
||||||
|
# The set-based _dropped_kinds cannot see the duplicate; the multiset can.
|
||||||
|
assert _dropped_kinds(actions, (actions[0],)) == ()
|
||||||
|
deeds = (_foe("deed"), _foe("deed"), _foe("attack"))
|
||||||
|
kept = (deeds[0], deeds[2])
|
||||||
|
assert _dropped_kinds_multiset(deeds, kept) == ("deed",)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d) interplay with the condition-specific filters ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _duel(policy: PolicyFn, rng: list[int], round_cap: int = 1) -> CombatEngine:
|
||||||
|
hero = make_state(
|
||||||
|
make_combatant("hero", hp=100, initiative_mod=2), "players", (0, 0)
|
||||||
|
)
|
||||||
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 1))
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(rng), make_grid(), [hero, foe],
|
||||||
|
policy=policy, round_cap=round_cap,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_condition(state: CombatantState, name: str) -> None:
|
||||||
|
state.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_staggered_after_budget_keeps_single_legal_action() -> None:
|
||||||
|
# (charge, attack): the budget keeps the charge and drops the attack as
|
||||||
|
# surplus standard; the staggered filter then drops the full-round charge.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="charge", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5])
|
||||||
|
hero = eng._states[0]
|
||||||
|
hero.hp = 0 # disabled -> staggered
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert ("attack", "downgraded:action_economy") in reasons
|
||||||
|
assert ("charge", "downgraded:staggered") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_nauseated_after_budget_keeps_single_move() -> None:
|
||||||
|
# (move, attack, move): the budget drops the second move; the nauseated
|
||||||
|
# filter then keeps only the first move and drops the standard attack.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5])
|
||||||
|
hero = eng._states[0]
|
||||||
|
_add_condition(hero, "nauseated")
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert reasons.count(("move", "downgraded:action_economy")) == 1
|
||||||
|
assert ("attack", "downgraded:nauseated") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_immediate_swift_still_removes_swift_bonus_after_budget() -> None:
|
||||||
|
# (deed, deed, attack): the budget drops the second deed; the consumed
|
||||||
|
# swift (immediate_used) then removes the surviving deed too.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3])
|
||||||
|
hero = eng._states[0]
|
||||||
|
hero.immediate_used = True
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert ("deed", "downgraded:action_economy") in reasons
|
||||||
|
assert ("deed", "downgraded:immediate_swift") in reasons
|
||||||
|
assert ("attack", "ok") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
# ── (e) end-to-end: an illegal custom policy tuple is degraded by run() ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_degrades_illegal_policy_and_reports_results() -> None:
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
# two standards + two moves: only the first attack and the
|
||||||
|
# first move may execute.
|
||||||
|
return (
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3, 6, 4])
|
||||||
|
result = eng.run()
|
||||||
|
hero = eng._states[0]
|
||||||
|
reasons = [(r.kind, r.ok, r.reason) for r in hero.last_action_results]
|
||||||
|
assert reasons == [
|
||||||
|
("attack", False, "downgraded:action_economy"),
|
||||||
|
("move", False, "downgraded:action_economy"),
|
||||||
|
("attack", True, "ok"),
|
||||||
|
("move", True, "ok"),
|
||||||
|
]
|
||||||
|
# Only one hero swing is resolved in the transcript.
|
||||||
|
swings = [ln for ln in result.transcript if "hero: short sword vs foe" in ln]
|
||||||
|
assert len(swings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_keeps_legal_custom_policy_untouched() -> None:
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3, 6, 4])
|
||||||
|
eng.run()
|
||||||
|
hero = eng._states[0]
|
||||||
|
for res in hero.last_action_results:
|
||||||
|
assert res.reason != "downgraded:action_economy"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (f) default_policy turns are legal by construction (golden invariant) ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_representative_sequences_pass_the_validator() -> None:
|
||||||
|
shapes: tuple[tuple[_Kind, ...], ...] = (
|
||||||
|
("wait",),
|
||||||
|
("withdraw",),
|
||||||
|
("channel_energy",),
|
||||||
|
("hex",),
|
||||||
|
("cast_spell",),
|
||||||
|
("inspire_courage", "spell_combat"),
|
||||||
|
("deed", "full_attack"),
|
||||||
|
("deed", "charge"),
|
||||||
|
("deed", "5foot_step", "full_attack"),
|
||||||
|
("deed", "move", "attack"),
|
||||||
|
("inspire_courage", "cast_spell"),
|
||||||
|
("full_attack",),
|
||||||
|
("charge",),
|
||||||
|
("5foot_step", "full_attack"),
|
||||||
|
("move", "attack"),
|
||||||
|
)
|
||||||
|
for kinds in shapes:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
assert _action_economy_filter(actions) == actions, kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_combats_never_trigger_the_budget_downgrade() -> None:
|
||||||
|
# All d20 rolls are 1s after initiative: every swing auto-misses, so the
|
||||||
|
# scripted queue cannot exhaust before the round cap.
|
||||||
|
for rng in (
|
||||||
|
[10, 5] + [1] * 10,
|
||||||
|
[8, 6] + [1] * 10,
|
||||||
|
):
|
||||||
|
eng = _duel(default_policy, rng, round_cap=3)
|
||||||
|
eng.run()
|
||||||
|
for state in eng._states:
|
||||||
|
for res in state.last_action_results:
|
||||||
|
assert res.reason != "downgraded:action_economy", (rng, res)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Conformance B20: cover uses the ATTACKER's best corner/square.
|
||||||
|
|
||||||
|
PF1e CRB "Cover" (rule of corners): the attacker chooses the corner of its
|
||||||
|
square (and, for a multi-square creature, the occupied square) that gives it
|
||||||
|
the best shot, so a multi-cell attacker suffers the MINIMUM cover over its
|
||||||
|
own cells, not the maximum. The target side is unchanged: RAW checks ANY
|
||||||
|
line to ANY target corner, so each attacker cell is scored by the maximum
|
||||||
|
over its corner lines. 1x1 attackers are bit-identical to the old behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import CombatEngine
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.los import cover_bonus_cells
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from tests.test_combat import make_combatant, make_state
|
||||||
|
|
||||||
|
_FLOOR = TerrainType(type="floor", move_cost=1)
|
||||||
|
_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True)
|
||||||
|
_PARTIAL = TerrainType(type="ruin", move_cost=None, cover_bonus=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _grid(rows: tuple[str, ...]) -> Grid:
|
||||||
|
legend = {".": _FLOOR, "#": _WALL, "W": _PARTIAL}
|
||||||
|
return Grid.from_spec(MapSpec(name="b20", terrain=rows, legend=legend))
|
||||||
|
|
||||||
|
|
||||||
|
# Two-cell attacker {(0,0),(1,0)} shooting right at target (1,4):
|
||||||
|
# the +2 ruins at (0,2)/(1,2) sit on the top cell's lines, the +4 wall at
|
||||||
|
# (2,1) additionally sits on the bottom cell's low lines.
|
||||||
|
_CORNER_GRID = ("..W..", "..W..", ".#...", ".....")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (a) multi-cell attacker picks its best square: minimum, not maximum
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_cell_attacker_uses_best_cell_cover() -> None:
|
||||||
|
grid = _grid(_CORNER_GRID)
|
||||||
|
top = frozenset({(0, 0)})
|
||||||
|
bottom = frozenset({(1, 0)})
|
||||||
|
target = frozenset({(1, 4)})
|
||||||
|
# Per-cell scores pin the geometry: the top cell only sees +2 ruins,
|
||||||
|
# the bottom cell's low lines also graze the +4 wall.
|
||||||
|
assert cover_bonus_cells(grid, top, target, ranged=True) == 2
|
||||||
|
assert cover_bonus_cells(grid, bottom, target, ranged=True) == 4
|
||||||
|
# B20 fix: the attacker picks its best square -> min(2, 4) = 2.
|
||||||
|
assert (
|
||||||
|
cover_bonus_cells(grid, top | bottom, target, ranged=True) == 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_cell_attacker_best_cell_alone_is_unchanged() -> None:
|
||||||
|
grid = _grid(_CORNER_GRID)
|
||||||
|
target = frozenset({(1, 4)})
|
||||||
|
# A single-square attacker is the golden path: exactly its own score.
|
||||||
|
assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2
|
||||||
|
assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (b) 1x1 attacker vs 2x2 target: target side keeps the max over corners
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_attacker_vs_large_target_pins_cover_level() -> None:
|
||||||
|
grid = _grid(("......", "..W...", "......"))
|
||||||
|
attacker = frozenset({(1, 0)})
|
||||||
|
large_target = frozenset({(0, 4), (0, 5), (1, 4), (1, 5)})
|
||||||
|
# Cover is evaluated over ALL target corners: the big target keeps the
|
||||||
|
# +2 it gains from the ruin, exactly as before the B20 fix.
|
||||||
|
assert cover_bonus_cells(grid, attacker, large_target, ranged=True) == 2
|
||||||
|
assert cover_bonus_cells(grid, attacker, frozenset({(1, 4)}), ranged=True) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (c) regression: single-square attacker vs wall still gets exactly +4
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_square_attacker_wall_cover_unchanged() -> None:
|
||||||
|
grid = _grid((".....", "..#..", "....."))
|
||||||
|
assert (
|
||||||
|
cover_bonus_cells(
|
||||||
|
grid, frozenset({(1, 0)}), frozenset({(1, 4)}), ranged=True
|
||||||
|
)
|
||||||
|
== 4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (d) engine: Large attacker resolves with its best cell; snapshot agrees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _large_archer_engine() -> CombatEngine:
|
||||||
|
grid = _grid(_CORNER_GRID)
|
||||||
|
archer = make_combatant(
|
||||||
|
"ogre-archer", attack_bonus=4, kind="ranged", range_increment_ft=60
|
||||||
|
)
|
||||||
|
large = archer.model_copy(update={"size": "Large"})
|
||||||
|
target = make_combatant("target", ac=13)
|
||||||
|
states = [
|
||||||
|
make_state(large, "players", (0, 0)),
|
||||||
|
make_state(target, "monsters", (1, 4)),
|
||||||
|
]
|
||||||
|
return CombatEngine(ScriptedRng([12, 3, 2, 2]), grid, states)
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_attacker_ac_uses_best_cell_cover() -> None:
|
||||||
|
engine = _large_archer_engine()
|
||||||
|
archer, target = engine._states[0], engine._states[1]
|
||||||
|
assert len(archer.occupied_cells()) == 4
|
||||||
|
result = engine.resolve_attack(archer, target, archer.combatant.attacks[0])
|
||||||
|
# 13 base + 2 (best cell of the footprint), NOT 13 + 4 (worst cell).
|
||||||
|
assert result.ac == 15
|
||||||
|
assert result.hit is True # 12 + 4 = 16 >= 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_pair_cover_bonus_matches_attack() -> None:
|
||||||
|
engine = _large_archer_engine()
|
||||||
|
archer, target = engine._states[0], engine._states[1]
|
||||||
|
obs = engine._build_observation(archer).to_dict()
|
||||||
|
pair = next(p for p in obs["pairs"] if p["target_index"] == 1)
|
||||||
|
result = engine.resolve_attack(archer, target, archer.combatant.attacks[0])
|
||||||
|
assert pair["cover_bonus"] == 2
|
||||||
|
assert result.ac - target.combatant.ac.total == pair["cover_bonus"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (e) false-positive lock: cover still applies to touch attacks (Oracle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranged_touch_attack_still_gets_cover() -> None:
|
||||||
|
grid = _grid((".....", "..#..", "....."))
|
||||||
|
caster = make_combatant(
|
||||||
|
"sorcerer", attack_bonus=4, kind="touch", range_increment_ft=30
|
||||||
|
)
|
||||||
|
target = make_combatant("target", ac=13)
|
||||||
|
states = [
|
||||||
|
make_state(caster, "players", (1, 0)),
|
||||||
|
make_state(target, "monsters", (1, 4)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(ScriptedRng([12, 3]), grid, states)
|
||||||
|
result = engine.resolve_attack(states[0], states[1], caster.attacks[0])
|
||||||
|
# Touch AC 13 + 4 standard cover: cover is NOT removed for touch.
|
||||||
|
assert result.ac == 17
|
||||||
|
assert result.hit is False # 12 + 4 = 16 < 17
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Conformance B21: energy resistance/immunity apply per damage component.
|
||||||
|
|
||||||
|
PF1e (CRB "Energy Resistance", "Immunity to Energy Damage"): resistance to
|
||||||
|
an energy type reduces damage of THAT type only; immunity negates that type
|
||||||
|
only. A weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
|
||||||
|
keeps its slashing dice intact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
|
from pf1e_simulator.combat import CombatEngine
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec
|
||||||
|
from tests.test_observation_enriched import make_grid, make_state
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(
|
||||||
|
id_: str,
|
||||||
|
*,
|
||||||
|
level: int = 1,
|
||||||
|
attacks: tuple[AttackSpec, ...] = (),
|
||||||
|
resistances: dict[str, int] | None = None,
|
||||||
|
immunities: tuple[str, ...] = (),
|
||||||
|
features: tuple[str, ...] = (),
|
||||||
|
spell_slots: dict[int, int] | None = None,
|
||||||
|
) -> Combatant:
|
||||||
|
return Combatant(
|
||||||
|
id=id_,
|
||||||
|
name=id_,
|
||||||
|
level=level,
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
hp_max=100,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=list(attacks),
|
||||||
|
resistances=resistances or {},
|
||||||
|
immunities=immunities,
|
||||||
|
features=[AbilitySpec(name=f, category="feat") for f in features],
|
||||||
|
spell_slots=spell_slots or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attack(
|
||||||
|
aid: str,
|
||||||
|
components: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
|
*,
|
||||||
|
kind: Literal["melee", "ranged", "touch"] = "melee",
|
||||||
|
) -> AttackSpec:
|
||||||
|
return AttackSpec(
|
||||||
|
id=aid,
|
||||||
|
name=aid,
|
||||||
|
kind=kind,
|
||||||
|
attack_bonus=20,
|
||||||
|
damage=[
|
||||||
|
DamageComponent(formula=parse_dice(formula), types=list(types))
|
||||||
|
for formula, types in components
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
combatants: list[Combatant],
|
||||||
|
positions: list[tuple[int, int]],
|
||||||
|
sides: list[str],
|
||||||
|
rng: list[int],
|
||||||
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
|
) -> CombatEngine:
|
||||||
|
states = [
|
||||||
|
make_state(c, side, pos)
|
||||||
|
for c, side, pos in zip(combatants, sides, positions, strict=True)
|
||||||
|
]
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(rng), make_grid(), states, spell_registry=spell_registry
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MIXED = (
|
||||||
|
("1d6", ("slashing",)),
|
||||||
|
("1d6", ("fire",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_resistance_spares_slashing_component() -> None:
|
||||||
|
"""(a) 1d6 slashing + 1d6 fire vs fire resistance 5: only the fire
|
||||||
|
portion is reduced (2 - 5 floors to 0); the slashing dice are intact."""
|
||||||
|
weapon = _attack("fiery-blade", _MIXED)
|
||||||
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
|
foe = _combatant("foe", resistances={"fire": 5})
|
||||||
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
|
[15, 6, 2])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
|
# d20=15 hits (no threat); slashing 1d6=6, fire 1d6=2 → fire floored at 0.
|
||||||
|
assert result.damage == 6
|
||||||
|
assert eng._states[1].hp == 94
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_immunity_negates_only_fire_component() -> None:
|
||||||
|
"""(b) fire immunity zeroes the fire portion; the slashing portion lands."""
|
||||||
|
weapon = _attack("fiery-blade", _MIXED)
|
||||||
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
|
foe = _combatant("foe", immunities=("fire",))
|
||||||
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
|
[15, 6, 4])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
|
assert result.damage == 6
|
||||||
|
assert eng._states[1].hp == 94
|
||||||
|
|
||||||
|
|
||||||
|
def test_physical_only_weapon_ignores_energy_resistance() -> None:
|
||||||
|
"""(c) a physical-only weapon vs fire resistance is untouched."""
|
||||||
|
weapon = _attack("sword", (("1d6", ("slashing",)),))
|
||||||
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
|
foe = _combatant("foe", resistances={"fire": 5})
|
||||||
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
|
[15, 5])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
|
assert result.damage == 5
|
||||||
|
assert eng._states[1].hp == 95
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_energy_component_uses_largest_matching_resistance() -> None:
|
||||||
|
"""(d) one component with several energy types (fire+cold) vs resist
|
||||||
|
fire 5 / cold 10 → the largest matching resistance (10) applies."""
|
||||||
|
weapon = _attack("dual-energy", (("2d6", ("fire", "cold")),))
|
||||||
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
|
foe = _combatant("foe", resistances={"fire": 5, "cold": 10})
|
||||||
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
|
[15, 6, 6])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
|
# 2d6 = 12, reduced by cold 10 (largest match), not by fire 5.
|
||||||
|
assert result.damage == 2
|
||||||
|
assert eng._states[1].hp == 98
|
||||||
|
|
||||||
|
|
||||||
|
_FIREBALL = SpellSpec(
|
||||||
|
name="Fireball",
|
||||||
|
level=3,
|
||||||
|
school="evocation",
|
||||||
|
range="long",
|
||||||
|
save="ref",
|
||||||
|
base_dc=10,
|
||||||
|
effects=(
|
||||||
|
AreaEffect(
|
||||||
|
radius_ft=20,
|
||||||
|
effects=(
|
||||||
|
DamageEffect(
|
||||||
|
formula=parse_dice("8d6"),
|
||||||
|
types=("fire",),
|
||||||
|
half_on_save=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
save_for_half=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spell_fire_damage_resistance_regression() -> None:
|
||||||
|
"""(e) a fire spell vs fire resistance still reduces like before the
|
||||||
|
refactor: 8d6 = 24 (save failed on natural 1) - 5 = 19."""
|
||||||
|
caster = _combatant("caster", spell_slots={3: 9})
|
||||||
|
foe = _combatant("foe", resistances={"fire": 5})
|
||||||
|
eng = _engine(
|
||||||
|
[caster, foe], [(0, 0), (0, 5)], ["players", "monsters"],
|
||||||
|
[1, 3, 3, 3, 3, 3, 3, 3, 3],
|
||||||
|
spell_registry={"Fireball": _FIREBALL},
|
||||||
|
)
|
||||||
|
eng._cast_spell(eng._states[0], eng._states[1], "Fireball")
|
||||||
|
assert eng._states[1].hp == 81
|
||||||
|
|
||||||
|
|
||||||
|
def test_sneak_precision_not_reduced_by_energy_resistance() -> None:
|
||||||
|
"""(f) sneak attack d6s are untyped precision — energy resistance may
|
||||||
|
only eat the weapon's own energy dice, never the precision dice."""
|
||||||
|
weapon = _attack("flame-dagger", (("1d6", ("fire",)),))
|
||||||
|
rogue = _combatant(
|
||||||
|
"rogue", attacks=(weapon,), features=("Sneak Attack",)
|
||||||
|
)
|
||||||
|
ally = _combatant(
|
||||||
|
"ally", attacks=(_attack("club", (("1d6", ("bludgeoning",)),)),)
|
||||||
|
)
|
||||||
|
foe = _combatant("foe", resistances={"fire": 10})
|
||||||
|
# Rogue (0,0) and ally (0,2) flank the foe (0,1): the sneak attack lands.
|
||||||
|
eng = _engine([rogue, ally, foe], [(0, 0), (0, 2), (0, 1)],
|
||||||
|
["players", "players", "monsters"], [15, 6, 4])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[2], weapon)
|
||||||
|
# d20=15 hits, sneak 1d6=6, fire 1d6=4 → fire floored at 0 by resist 10;
|
||||||
|
# the 6 precision dice survive intact (the old lumped path dealt 0).
|
||||||
|
assert result.damage == 6
|
||||||
|
assert eng._states[2].hp == 94
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""B16 PF1e conformance: Power Attack two-handed scaling + CMB penalty.
|
||||||
|
|
||||||
|
CRB "Power Attack": a -X penalty on all melee attack rolls AND combat maneuver
|
||||||
|
checks buys +2X melee damage; that damage bonus is increased by half (+3X) with
|
||||||
|
a two-handed weapon, a one-handed weapon used in two hands, or a primary
|
||||||
|
natural weapon that adds 1.5x Strength, and halved (+1X) with an off-hand /
|
||||||
|
secondary natural weapon (NOT modeled — no two-weapon fighting in the engine).
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
(a) two-handed Power Attack deals +3X damage
|
||||||
|
(b) one-handed Power Attack still deals +2X damage (unchanged)
|
||||||
|
(c) the Power Attack -X penalty reduces the combat maneuver check (CMB)
|
||||||
|
(d) AttackSpec.two_handed validates True and defaults False
|
||||||
|
(e) the orc falchion loads with two_handed=True
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.loaders.combatant import load_combatant
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
|
MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters"
|
||||||
|
|
||||||
|
|
||||||
|
def _sword(cid: str, *, two_handed: bool = False) -> AttackSpec:
|
||||||
|
return AttackSpec(
|
||||||
|
id=f"{cid}-sword",
|
||||||
|
name="longsword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=5,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||||
|
two_handed=two_handed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
bab: int = 5,
|
||||||
|
ac: int = 10,
|
||||||
|
attacks: tuple[AttackSpec, ...] = (),
|
||||||
|
features: tuple[str, ...] = (),
|
||||||
|
cmd: int | None = None,
|
||||||
|
) -> Combatant:
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
creature_type="living",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=100,
|
||||||
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
||||||
|
bab=bab,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=list(attacks) or [_sword(cid)],
|
||||||
|
features=[AbilitySpec(name=f, category="feat") for f in features],
|
||||||
|
cmd=cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
combatants: list[Combatant],
|
||||||
|
positions: list[tuple[int, int]],
|
||||||
|
sides: list[str],
|
||||||
|
rng: list[int],
|
||||||
|
) -> CombatEngine:
|
||||||
|
states = [
|
||||||
|
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
|
||||||
|
for c, pos, side in zip(combatants, positions, sides, strict=True)
|
||||||
|
]
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
return CombatEngine(ScriptedRng(rng), grid, states)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a)+(b): two-handed Power Attack deals +3X, one-handed +2X ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_handed_power_attack_deals_3x_damage() -> None:
|
||||||
|
atk = _combatant(
|
||||||
|
"atk", bab=5, attacks=(_sword("atk", two_handed=True),),
|
||||||
|
features=("Power Attack",),
|
||||||
|
)
|
||||||
|
tgt = _combatant("tgt")
|
||||||
|
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4])
|
||||||
|
a = eng._states[0]
|
||||||
|
a.power_attack = True
|
||||||
|
# bab 5 -> X = 5//4 + 1 = 2. Attack roll 15, weapon 1d6 = 4.
|
||||||
|
result = eng.resolve_attack(a, eng._states[1], _sword("atk", two_handed=True))
|
||||||
|
assert result.hit is True
|
||||||
|
# 4 (weapon) + 3*2 (two-handed Power Attack) = 10.
|
||||||
|
assert result.damage == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_handed_power_attack_deals_2x_damage() -> None:
|
||||||
|
atk = _combatant("atk", bab=5, features=("Power Attack",))
|
||||||
|
tgt = _combatant("tgt")
|
||||||
|
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4])
|
||||||
|
a = eng._states[0]
|
||||||
|
a.power_attack = True
|
||||||
|
result = eng.resolve_attack(a, eng._states[1], _sword("atk"))
|
||||||
|
assert result.hit is True
|
||||||
|
# 4 (weapon) + 2*2 (one-handed Power Attack) = 8 — unchanged.
|
||||||
|
assert result.damage == 8
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c): the Power Attack penalty reduces the CMB check ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_power_attack_penalty_reduces_cmb_check() -> None:
|
||||||
|
# bab 5 -> CMB 5, X = 2. d20 = 10, CMD 14: without the penalty 15 >= 14
|
||||||
|
# (trip lands); with the -2 Power Attack penalty 13 < 14 (trip fails).
|
||||||
|
a = _combatant("a", bab=5, features=("Power Attack", "Improved Trip"))
|
||||||
|
tgt = _combatant("tgt", cmd=14)
|
||||||
|
eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [10])
|
||||||
|
s = eng._states[0]
|
||||||
|
s.power_attack = True
|
||||||
|
eng._execute_maneuver(s, eng._states[1], "trip")
|
||||||
|
assert not any(
|
||||||
|
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
|
||||||
|
)
|
||||||
|
assert any("d20=10+3=13" in line for line in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trip_lands_without_power_attack_penalty() -> None:
|
||||||
|
# Control: same geometry, Power Attack inactive -> 15 >= 14 succeeds.
|
||||||
|
a = _combatant("a", bab=5, features=("Power Attack", "Improved Trip"))
|
||||||
|
tgt = _combatant("tgt", cmd=14)
|
||||||
|
eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [10])
|
||||||
|
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
|
||||||
|
assert any(
|
||||||
|
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d): AttackSpec.two_handed validates and defaults False ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_spec_two_handed_validates_and_defaults() -> None:
|
||||||
|
assert _sword("x", two_handed=True).two_handed is True
|
||||||
|
assert _sword("x").two_handed is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── (e): the orc falchion loads with two_handed=True ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_orc_falchion_loads_two_handed() -> None:
|
||||||
|
orc = load_combatant(MONSTERS_DIR / "orc.json")
|
||||||
|
falchion = next(a for a in orc.attacks if a.name == "falchion")
|
||||||
|
assert falchion.two_handed is True
|
||||||
|
javelin = next(a for a in orc.attacks if a.name == "javelin")
|
||||||
|
assert javelin.two_handed is False
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Conformance X5: optional simple monster death (monsters die at hp <= 0).
|
||||||
|
|
||||||
|
PF1e death is ``hp <= -CON`` (CRB, Effects of Death) with a disabled band at
|
||||||
|
exactly 0 hp and a dying band below 0. The opt-in ``monsters_die_at_zero``
|
||||||
|
engine option (CLI ``--simple-monster-death``) raises the death threshold to
|
||||||
|
0 for MONSTER combatants only: a monster at hp <= 0 is dead outright (no
|
||||||
|
dying, no disabled band). Player characters are NEVER affected — they are
|
||||||
|
identified by the loader convention that a PC's ``source`` records its
|
||||||
|
Foundry sheet path under ``fiches_personnages/`` (README, "Fiches de
|
||||||
|
personnages"); monsters carry bestiary/summon sources. With the option off,
|
||||||
|
behavior is bit-identical to the PF1e rule (goldens frozen).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pf1e_simulator.models import Combatant
|
||||||
|
|
||||||
|
from pf1e_simulator import cli
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine, Policy
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.loaders.combatant import load_combatant
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType, load_map
|
||||||
|
from pf1e_simulator.rng import RngExhaustedError, ScriptedRng
|
||||||
|
from pf1e_simulator.runner import (
|
||||||
|
CombatantAttrition,
|
||||||
|
EncounterReport,
|
||||||
|
EncounterSpec,
|
||||||
|
Side,
|
||||||
|
build_states,
|
||||||
|
run_encounter,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.spells import SpellSpec, load_spell_registry
|
||||||
|
from tests.test_combat import make_combatant
|
||||||
|
from tests.test_pc_combat_golden import _build_queue
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DATA_DIR = ROOT / "data"
|
||||||
|
PCS_DIR = DATA_DIR / "pcs"
|
||||||
|
MAP = DATA_DIR / "maps" / "sample_arena.yaml"
|
||||||
|
GOBLIN = DATA_DIR / "monsters" / "goblin.json"
|
||||||
|
ORC = DATA_DIR / "monsters" / "orc.json"
|
||||||
|
|
||||||
|
_MONSTER_SOURCE = "Bestiary > Goblin"
|
||||||
|
_PC_SOURCE = "fiches_personnages/tammara_sheet.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _monster(cid: str = "goblin", con: int = 12) -> Combatant:
|
||||||
|
return make_combatant(cid, con=con).model_copy(update={"source": _MONSTER_SOURCE})
|
||||||
|
|
||||||
|
|
||||||
|
def _pc(cid: str = "hero", con: int = 12) -> Combatant:
|
||||||
|
return make_combatant(cid, con=con).model_copy(update={"source": _PC_SOURCE})
|
||||||
|
|
||||||
|
|
||||||
|
def _state(combatant: Combatant, side: str, hp: int) -> CombatantState:
|
||||||
|
return CombatantState(combatant=combatant, side=side, pos=(1, 1), hp=hp)
|
||||||
|
|
||||||
|
|
||||||
|
def _grid(width: int = 8) -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * width] * width), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
states: list[CombatantState], *, monsters_die_at_zero: bool = False
|
||||||
|
) -> CombatEngine:
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng([1] * 50),
|
||||||
|
_grid(),
|
||||||
|
states,
|
||||||
|
monsters_die_at_zero=monsters_die_at_zero,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) monster at hp 0: dead with the flag, disabled band without ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_monster_at_zero_hp_is_dead_with_flag_on() -> None:
|
||||||
|
st = _state(_monster(), "monsters", hp=0)
|
||||||
|
engine = _engine([st], monsters_die_at_zero=True)
|
||||||
|
assert engine.states[0].dies_at_zero is True
|
||||||
|
assert st.dead is True
|
||||||
|
assert st.active is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_monster_at_zero_hp_keeps_disabled_band_with_flag_off() -> None:
|
||||||
|
st = _state(_monster(), "monsters", hp=0)
|
||||||
|
_engine([st])
|
||||||
|
assert st.dies_at_zero is False
|
||||||
|
assert st.dead is False
|
||||||
|
assert st.active is True # CRB "Effects of Death": disabled, still acting
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) monster at/below -CON: dead in both modes; band only dies with flag ─
|
||||||
|
|
||||||
|
|
||||||
|
def test_monster_dead_at_minus_con_in_both_modes() -> None:
|
||||||
|
down = _state(_monster(con=12), "monsters", hp=-12)
|
||||||
|
up = _state(_monster(con=12), "monsters", hp=-12)
|
||||||
|
_engine([down])
|
||||||
|
_engine([up], monsters_die_at_zero=True)
|
||||||
|
assert down.dead is True
|
||||||
|
assert up.dead is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_monster_dying_band_is_dead_only_with_flag_on() -> None:
|
||||||
|
off = _state(_monster(con=12), "monsters", hp=-1)
|
||||||
|
on = _state(_monster(con=12), "monsters", hp=-1)
|
||||||
|
_engine([off])
|
||||||
|
_engine([on], monsters_die_at_zero=True)
|
||||||
|
assert off.dead is False # PF1e dying: -1 > -CON
|
||||||
|
assert on.dead is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c) PCs are never affected by the rule ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_pc_at_zero_hp_is_never_dead_even_with_flag_on() -> None:
|
||||||
|
st = _state(_pc(), "players", hp=0)
|
||||||
|
_engine([st], monsters_die_at_zero=True)
|
||||||
|
assert st.dies_at_zero is False
|
||||||
|
assert st.dead is False
|
||||||
|
assert st.active is True # disabled band preserved for PCs
|
||||||
|
|
||||||
|
|
||||||
|
def test_pc_death_still_requires_minus_con_with_flag_on() -> None:
|
||||||
|
dying = _state(_pc(con=12), "players", hp=-1)
|
||||||
|
dead = _state(_pc(con=12), "players", hp=-12)
|
||||||
|
_engine([dying, dead], monsters_die_at_zero=True)
|
||||||
|
assert dying.dies_at_zero is False
|
||||||
|
assert dying.dead is False # PC dying band untouched
|
||||||
|
assert dead.dead is True # PF1e hp <= -CON still kills the PC
|
||||||
|
|
||||||
|
|
||||||
|
# ── classification + dying machinery integration ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_off_marks_no_state() -> None:
|
||||||
|
m = _state(_monster(), "monsters", hp=6)
|
||||||
|
p = _state(_pc(), "players", hp=6)
|
||||||
|
_engine([m, p])
|
||||||
|
assert m.dies_at_zero is False
|
||||||
|
assert p.dies_at_zero is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_foundry_sourced_combatant_never_marked() -> None:
|
||||||
|
"""The classification helper keys on the ``fiches_personnages/`` source."""
|
||||||
|
st = _state(_pc(), "players", hp=6)
|
||||||
|
_engine([st], monsters_die_at_zero=True)
|
||||||
|
assert st.dies_at_zero is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_summon_style_source_is_a_monster() -> None:
|
||||||
|
summoned = make_combatant("wolf").model_copy(update={"source": "summon monster II"})
|
||||||
|
st = _state(summoned, "players", hp=6)
|
||||||
|
engine = _engine([st], monsters_die_at_zero=True)
|
||||||
|
engine._apply_simple_monster_death(st) # the hook used at the summon site
|
||||||
|
assert st.dies_at_zero is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dying_stabilization_check_skipped_for_dead_monster() -> None:
|
||||||
|
"""With the flag on, a monster below 0 hp is dead: no CON check, no RNG."""
|
||||||
|
st = _state(_monster(con=12), "monsters", hp=-2)
|
||||||
|
engine = CombatEngine(ScriptedRng([]), _grid(), [st], monsters_die_at_zero=True)
|
||||||
|
engine._process_dying(st) # would raise RngExhaustedError if it rolled
|
||||||
|
|
||||||
|
off = _state(_monster(con=12), "monsters", hp=-2)
|
||||||
|
off_engine = CombatEngine(ScriptedRng([]), _grid(), [off])
|
||||||
|
with pytest.raises(RngExhaustedError):
|
||||||
|
off_engine._process_dying(off) # dying CON check consumes the d20
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d) flag OFF end-to-end: bit-identical transcripts when no monster
|
||||||
|
# enters the hp 0..-CON band (tammara vs lich: the PC bleeds, the lich does not)
|
||||||
|
|
||||||
|
|
||||||
|
def _golden_harness_engine(*, monsters_die_at_zero: bool) -> CombatEngine:
|
||||||
|
pc = load_combatant(PCS_DIR / "tammara.json")
|
||||||
|
monster = load_combatant(DATA_DIR / "monsters" / "lich.json")
|
||||||
|
registry = load_spell_registry(DATA_DIR / "spells")
|
||||||
|
pc_state = CombatantState(combatant=pc, side="players", pos=(0, 0), hp=pc.hp_max)
|
||||||
|
monster_state = CombatantState(
|
||||||
|
combatant=monster, side="monsters", pos=(10, 10), hp=monster.hp_max
|
||||||
|
)
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(list(_build_queue())),
|
||||||
|
_grid(width=20),
|
||||||
|
[pc_state, monster_state],
|
||||||
|
round_cap=10,
|
||||||
|
spell_registry=registry,
|
||||||
|
monsters_die_at_zero=monsters_die_at_zero,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_off_equivalence_when_band_never_entered() -> None:
|
||||||
|
plain = _golden_harness_engine(monsters_die_at_zero=False)
|
||||||
|
result_plain = plain.run()
|
||||||
|
simple = _golden_harness_engine(monsters_die_at_zero=True)
|
||||||
|
result_simple = simple.run()
|
||||||
|
assert result_plain.transcript == result_simple.transcript
|
||||||
|
assert result_plain.winner == result_simple.winner == "monsters"
|
||||||
|
tammara = plain.states[0]
|
||||||
|
assert tammara.hp < 0 # the PC bled into the dying band…
|
||||||
|
assert tammara.dies_at_zero is False # …without dying at 0, flag or not
|
||||||
|
assert tammara.dead is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── runner + CLI wiring ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _spec() -> EncounterSpec:
|
||||||
|
goblins = tuple(load_combatant(GOBLIN) for _ in range(3))
|
||||||
|
orcs = tuple(load_combatant(ORC) for _ in range(2))
|
||||||
|
return EncounterSpec(
|
||||||
|
map=load_map(MAP),
|
||||||
|
sides=(
|
||||||
|
Side(name="players", combatants=goblins, zone="A"),
|
||||||
|
Side(name="monsters", combatants=orcs, zone="B"),
|
||||||
|
),
|
||||||
|
round_cap=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_encounter_accepts_the_flag() -> None:
|
||||||
|
report = run_encounter(_spec(), runs=5, seed=1, monsters_die_at_zero=True)
|
||||||
|
assert report.runs == 5
|
||||||
|
assert sum(report.wins.values()) + report.draws == 5
|
||||||
|
states = build_states(_spec())
|
||||||
|
assert all(not s.dies_at_zero for s in states) # build_states stays neutral
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_help_lists_the_option(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
cli.main(["--help"])
|
||||||
|
assert excinfo.value.code == 0
|
||||||
|
assert "--simple-monster-death" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_wires_the_flag_to_the_runner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
seen: list[bool] = []
|
||||||
|
|
||||||
|
def spy(
|
||||||
|
spec: EncounterSpec,
|
||||||
|
runs: int,
|
||||||
|
seed: int,
|
||||||
|
*,
|
||||||
|
policy: Policy | None = None,
|
||||||
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
|
capture_first_transcript: bool = False,
|
||||||
|
monsters_die_at_zero: bool = False,
|
||||||
|
) -> EncounterReport:
|
||||||
|
seen.append(monsters_die_at_zero)
|
||||||
|
return EncounterReport(
|
||||||
|
runs=runs,
|
||||||
|
seed=seed,
|
||||||
|
wins={side.name: runs for side in spec.sides},
|
||||||
|
draws=0,
|
||||||
|
avg_rounds=1.0,
|
||||||
|
attrition={
|
||||||
|
combatant.id: CombatantAttrition(0.0, 0.0, 0.0, 0.0)
|
||||||
|
for side in spec.sides
|
||||||
|
for combatant in side.combatants
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "run_encounter", spy)
|
||||||
|
base = [
|
||||||
|
"--map", str(MAP),
|
||||||
|
"--side", "players", str(GOBLIN),
|
||||||
|
"--side", "monsters", str(ORC),
|
||||||
|
"--runs", "5",
|
||||||
|
]
|
||||||
|
assert cli.main(base) == 0
|
||||||
|
assert cli.main([*base, "--simple-monster-death"]) == 0
|
||||||
|
assert seen == [False, True]
|
||||||
|
assert "Rapport d'équilibrage" in capsys.readouterr().out
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"""PF1e conformance: spell-casting flow fixes B7 / B14 / B24.
|
||||||
|
|
||||||
|
B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
|
||||||
|
(Empower +2, Maximize +3, Extend +1), the DC uses the slot level, and
|
||||||
|
only feats the caster knows can apply.
|
||||||
|
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
|
||||||
|
creature requires a melee touch attack (miss = spell wasted); a willing
|
||||||
|
target (self/ally) needs no roll.
|
||||||
|
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
|
||||||
|
a DC 15 + 2 x spell level check; casting while grappled requires
|
||||||
|
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
|
||||||
|
both, never to the injured-while-casting check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
|
from pf1e_simulator.combat import Action, ActionResult, CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.effects import ActiveCondition
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import (
|
||||||
|
ConditionEffect,
|
||||||
|
DamageEffect,
|
||||||
|
SpellSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
|
||||||
|
# touch condition grace — enough to exercise slot levels, DCs and delivery.
|
||||||
|
_BOLT = SpellSpec(
|
||||||
|
name="Bolt",
|
||||||
|
level=1,
|
||||||
|
school="evocation",
|
||||||
|
range="medium",
|
||||||
|
save="none",
|
||||||
|
effects=(DamageEffect(formula=parse_dice("2d4"), types=("force",)),),
|
||||||
|
)
|
||||||
|
_ZAP = SpellSpec(
|
||||||
|
name="Zap",
|
||||||
|
level=1,
|
||||||
|
school="evocation",
|
||||||
|
range="touch",
|
||||||
|
save="none",
|
||||||
|
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
|
||||||
|
)
|
||||||
|
_GRACE = SpellSpec(
|
||||||
|
name="Grace",
|
||||||
|
level=1,
|
||||||
|
school="abjuration",
|
||||||
|
range="touch",
|
||||||
|
save="none",
|
||||||
|
effects=(
|
||||||
|
ConditionEffect(
|
||||||
|
condition_name="dazzled", duration_rounds=2, save_negates=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE}
|
||||||
|
|
||||||
|
|
||||||
|
def _caster(
|
||||||
|
cid: str = "caster",
|
||||||
|
*,
|
||||||
|
level: int = 5,
|
||||||
|
bab: int = 1,
|
||||||
|
casting_ability: str = "int",
|
||||||
|
int_score: int = 18,
|
||||||
|
wis_score: int = 10,
|
||||||
|
metamagic: list[str] | None = None,
|
||||||
|
spell_slots: dict[int, int] | None = None,
|
||||||
|
combat_casting: bool = False,
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="dagger",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=2,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||||
|
)
|
||||||
|
features = (
|
||||||
|
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=level,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=12,
|
||||||
|
int_score=int_score, wis_score=wis_score, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=30,
|
||||||
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||||
|
bab=bab,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=3, will=3),
|
||||||
|
attacks=[attack],
|
||||||
|
casting_ability=casting_ability,
|
||||||
|
spell_slots=spell_slots if spell_slots is not None else {1: 1, 2: 1, 3: 1, 4: 1},
|
||||||
|
metamagic_feats=metamagic or [],
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _foe(
|
||||||
|
cid: str = "foe",
|
||||||
|
*,
|
||||||
|
touch_ac: int = 10,
|
||||||
|
dex_score: int = 10,
|
||||||
|
cmb: int | None = None,
|
||||||
|
melee: bool = True,
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="club" if melee else "short bow",
|
||||||
|
kind="melee" if melee else "ranged",
|
||||||
|
attack_bonus=0,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||||
|
range_increment_ft=None if melee else 60,
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=dex_score, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=100,
|
||||||
|
ac=ACProfile(total=12, touch=touch_ac, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=2, will=2),
|
||||||
|
attacks=[attack],
|
||||||
|
cmb=cmb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(queue), Grid.from_spec(spec), states, spell_registry=_REGISTRY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||||
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetamagicPerCast:
|
||||||
|
"""B7: metamagic is chosen per cast, costs slot levels, and the DC uses
|
||||||
|
the slot level (CRB, Metamagic Feats)."""
|
||||||
|
|
||||||
|
def test_empower_costs_slot_two_levels_higher_and_dc_uses_it(self) -> None:
|
||||||
|
# Given: a caster who knows Empower Spell with a level-3 slot
|
||||||
|
# When: casting Bolt (lvl 1) with Empower Spell
|
||||||
|
# Then: the level-3 slot burns and the DC is 10 + 3 + int 4 = 17.
|
||||||
|
caster = _caster(metamagic=["Empower Spell"])
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [3, 3]) # 2d4 -> 6, empowered -> 9
|
||||||
|
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
||||||
|
assert c.spell_slots_left[3] == 0
|
||||||
|
assert c.spell_slots_left[1] == 1
|
||||||
|
assert f.hp == 100 - 9
|
||||||
|
assert any("DC17" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_maximize_costs_slot_three_levels_higher(self) -> None:
|
||||||
|
# Given: a caster who knows Maximize Spell
|
||||||
|
# When: casting Bolt maximized (2d4 max = 8)
|
||||||
|
# Then: the level-4 slot burns and the DC is 10 + 4 + 4 = 18.
|
||||||
|
caster = _caster(metamagic=["Maximize Spell"])
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [1, 1]) # dice overwritten by maximize
|
||||||
|
engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",))
|
||||||
|
assert c.spell_slots_left[4] == 0
|
||||||
|
assert f.hp == 100 - 8
|
||||||
|
assert any("DC18" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_extend_costs_one_slot_level_and_doubles_duration(self) -> None:
|
||||||
|
# Given: a caster who knows Extend Spell, self-targeting a touch buff
|
||||||
|
# When: casting Grace (2r) extended
|
||||||
|
# Then: the level-2 slot burns and the condition lasts 4 rounds.
|
||||||
|
caster = _caster(metamagic=["Extend Spell"])
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
engine = _engine([c], []) # self touch: no attack roll, no save
|
||||||
|
engine._cast_spell(c, c, "Grace", metamagic=("Extend Spell",))
|
||||||
|
assert c.spell_slots_left[2] == 0
|
||||||
|
assert c.spell_slots_left[1] == 1
|
||||||
|
assert c.active_conditions[0].rounds_left == 4
|
||||||
|
|
||||||
|
def test_unknown_metamagic_feat_is_never_applied(self) -> None:
|
||||||
|
# Given: a caster who knows NO metamagic
|
||||||
|
# When: the action requests Empower Spell anyway
|
||||||
|
# Then: the feat is ignored — base slot, base DC, base damage.
|
||||||
|
caster = _caster(metamagic=[])
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [3, 3])
|
||||||
|
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert c.spell_slots_left[3] == 1
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
assert any("DC15" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_known_but_unrequested_feat_is_not_applied(self) -> None:
|
||||||
|
# Given: a caster who knows Empower AND Maximize
|
||||||
|
# When: casting Bolt with no metamagic requested
|
||||||
|
# Then: the base slot burns, base DC, dice are rolled (not maxed).
|
||||||
|
caster = _caster(metamagic=["Empower Spell", "Maximize Spell"])
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [3, 3])
|
||||||
|
engine._cast_spell(c, f, "Bolt")
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert c.spell_slots_left[3] == 1
|
||||||
|
assert c.spell_slots_left[4] == 1
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_cast_rejected_without_a_slot_at_the_used_level(self) -> None:
|
||||||
|
# Given: a caster with only a level-1 slot who knows Empower
|
||||||
|
# When: an empowered cast needs a level-3 slot
|
||||||
|
# Then: the cast is rejected (no_slot), nothing is spent or rolled.
|
||||||
|
caster = _caster(metamagic=["Empower Spell"], spell_slots={1: 1})
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [])
|
||||||
|
result = engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
||||||
|
assert isinstance(result, ActionResult)
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.reason == "no_slot"
|
||||||
|
assert c.spell_slots_left[1] == 1
|
||||||
|
assert f.hp == 100
|
||||||
|
assert any("NO SPELL SLOT (level 3)" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_action_metamagic_field_threads_through_execution(self) -> None:
|
||||||
|
# Given: a caster with slots who knows Empower
|
||||||
|
# When: the cast is issued as an Action carrying metamagic
|
||||||
|
# Then: _execute dispatches the empowered cast (level-3 slot burns).
|
||||||
|
caster = _caster(metamagic=["Empower Spell"])
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
||||||
|
engine = _engine([c, f], [3, 3])
|
||||||
|
result = engine._execute(
|
||||||
|
c,
|
||||||
|
Action(
|
||||||
|
kind="cast_spell", target_id="foe", spell_name="Bolt",
|
||||||
|
metamagic=("Empower Spell",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.ok is True
|
||||||
|
assert c.spell_slots_left[3] == 0
|
||||||
|
assert f.hp == 100 - 9
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostileTouchDelivery:
|
||||||
|
"""B14: a touch spell delivered to an unwilling creature requires a
|
||||||
|
melee touch attack (CRB, Magic — Range: Touch)."""
|
||||||
|
|
||||||
|
def test_missed_touch_attack_wastes_the_spell(self) -> None:
|
||||||
|
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10)
|
||||||
|
# When: Zap is cast with a touch attack roll of 5 (total 6)
|
||||||
|
# Then: the spell misses, deals no damage, and the slot is spent.
|
||||||
|
caster = _caster()
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
|
||||||
|
engine._cast_spell(c, f, "Zap")
|
||||||
|
assert f.hp == 100
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_successful_touch_attack_delivers_the_spell(self) -> None:
|
||||||
|
# Given: the same enemy
|
||||||
|
# When: the touch attack roll is 10 (total 11 >= 10)
|
||||||
|
# Then: the zap lands for its 1d6.
|
||||||
|
caster = _caster()
|
||||||
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [10, 4])
|
||||||
|
engine._cast_spell(c, f, "Zap")
|
||||||
|
assert f.hp == 100 - 4
|
||||||
|
assert any("HIT" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_touch_attack_strips_dex_when_target_denies_dex(self) -> None:
|
||||||
|
# Given: a flat-footed enemy (Dex 14, touch 13 -> effective AC 11)
|
||||||
|
# When: the caster (bonus +1) rolls a 9 (total 10)
|
||||||
|
# Then: the attack misses the reduced touch AC.
|
||||||
|
caster = _caster()
|
||||||
|
foe = _state(_foe(touch_ac=13, dex_score=14, melee=False), "monsters", (0, 1))
|
||||||
|
foe.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME["flat_footed"],
|
||||||
|
rounds_left=1,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
engine = _engine([c, foe], [9])
|
||||||
|
engine._cast_spell(c, foe, "Zap")
|
||||||
|
assert foe.hp == 100
|
||||||
|
assert any("AC 11" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_touch_spell_on_ally_needs_no_attack_roll(self) -> None:
|
||||||
|
# Given: an ally on the caster's side
|
||||||
|
# When: a touch spell is cast on them with an EMPTY die queue
|
||||||
|
# Then: no attack roll is consumed and the effect lands.
|
||||||
|
caster = _caster()
|
||||||
|
ally = _state(_foe(), "players", (0, 1))
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
engine = _engine([c, ally], [])
|
||||||
|
engine._cast_spell(c, ally, "Grace")
|
||||||
|
assert ally.active_conditions[0].condition.name == "dazzled"
|
||||||
|
|
||||||
|
def test_self_touch_spell_needs_no_attack_roll(self) -> None:
|
||||||
|
# Given: a caster targeting self
|
||||||
|
# When: a touch spell is cast with an EMPTY die queue
|
||||||
|
# Then: it lands without any attack roll (unchanged behaviour).
|
||||||
|
c = _state(_caster(), "players", (0, 0))
|
||||||
|
engine = _engine([c], [])
|
||||||
|
engine._cast_spell(c, c, "Grace")
|
||||||
|
assert c.active_conditions[0].condition.name == "dazzled"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefensiveAndGrappleCasting:
|
||||||
|
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
|
||||||
|
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
|
||||||
|
Concentration). Combat Casting's +4 applies to both."""
|
||||||
|
|
||||||
|
def test_defensive_cast_skips_aoo_and_lands_on_passed_check(self) -> None:
|
||||||
|
# Given: a threatened caster (adjacent armed enemy)
|
||||||
|
# When: Bolt is cast defensively (DC 17) with roll 8 + lvl 5 + int 4
|
||||||
|
# Then: no AoO is provoked and the spell resolves.
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [8, 3, 3]) # concentration, then 2d4
|
||||||
|
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
|
||||||
|
assert engine._aoo_used == {}
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_defensive_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
|
||||||
|
# Given: the same threatened caster
|
||||||
|
# When: the defensive check fails (roll 1 + 5 + 4 = 10 < 17)
|
||||||
|
# Then: the spell is lost, the slot spent, no damage roll happens.
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [1])
|
||||||
|
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
|
||||||
|
assert f.hp == 100
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert any(
|
||||||
|
"casting defensively: concentration failed (DC 17), spell lost" in line
|
||||||
|
for line in engine._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_combat_casting_helps_the_defensive_check(self) -> None:
|
||||||
|
# Given: a wis-based caster (mod 0) with and without Combat Casting
|
||||||
|
# When: the defensive check roll is 8 (DC 17)
|
||||||
|
# Then: with the feat 8+5+4 = 17 passes; without, 13 fails.
|
||||||
|
plain = _caster(casting_ability="wis", int_score=10, combat_casting=False)
|
||||||
|
pc = _state(plain, "players", (0, 0))
|
||||||
|
pf = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine_fail = _engine([pc, pf], [8])
|
||||||
|
engine_fail._cast_spell(pc, pf, "Bolt", cast_defensively=True)
|
||||||
|
assert pf.hp == 100
|
||||||
|
|
||||||
|
feat = _caster(casting_ability="wis", int_score=10, combat_casting=True)
|
||||||
|
fc = _state(feat, "players", (0, 0))
|
||||||
|
ff = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine_pass = _engine([fc, ff], [8, 3, 3])
|
||||||
|
engine_pass._cast_spell(fc, ff, "Bolt", cast_defensively=True)
|
||||||
|
assert ff.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_grappled_caster_must_pass_the_grapple_check(self) -> None:
|
||||||
|
# Given: a caster grappled by an enemy with CMB 7 (DC 10+7+1 = 18)
|
||||||
|
# When: the check roll is 9 (9 + lvl 5 + int 4 = 18)
|
||||||
|
# Then: the cast succeeds — and no AoO path is taken.
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
||||||
|
c.grappled_with = "foe"
|
||||||
|
engine = _engine([c, f], [9, 3, 3])
|
||||||
|
engine._cast_spell(c, f, "Bolt")
|
||||||
|
assert engine._aoo_used == {}
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_grappled_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
|
||||||
|
# Given: the same grappled caster
|
||||||
|
# When: the check roll is 8 (17 < 18)
|
||||||
|
# Then: the spell is lost with the grapple fizzle log.
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
||||||
|
c.grappled_with = "foe"
|
||||||
|
engine = _engine([c, f], [8])
|
||||||
|
engine._cast_spell(c, f, "Bolt")
|
||||||
|
assert f.hp == 100
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert any(
|
||||||
|
"casting while grappled: concentration failed (DC 18), spell lost" in line
|
||||||
|
for line in engine._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_combat_casting_helps_the_grapple_check(self) -> None:
|
||||||
|
# Given: a grappled wis-based caster (mod 0) with Combat Casting
|
||||||
|
# When: the check roll is 9 (9 + 5 + 4 = 18 >= DC 18)
|
||||||
|
# Then: the cast lands.
|
||||||
|
caster = _caster(casting_ability="wis", int_score=10, combat_casting=True)
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
||||||
|
c.grappled_with = "foe"
|
||||||
|
engine = _engine([c, f], [9, 3, 3])
|
||||||
|
engine._cast_spell(c, f, "Bolt")
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_normal_cast_still_provokes_the_aoo(self) -> None:
|
||||||
|
# Given: a threatened caster NOT casting defensively
|
||||||
|
# When: Bolt is cast (enemy AoO roll 2 misses AC 12)
|
||||||
|
# Then: the AoO is provoked and the spell still resolves.
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [2, 3, 3]) # AoO d20, then 2d4
|
||||||
|
engine._cast_spell(c, f, "Bolt")
|
||||||
|
assert engine._aoo_used.get("foe") == 1
|
||||||
|
assert f.hp == 100 - 6
|
||||||
|
|
||||||
|
def test_action_cast_defensively_threads_through_execution(self) -> None:
|
||||||
|
# Given: a threatened caster
|
||||||
|
# When: the cast is issued as an Action with cast_defensively
|
||||||
|
# Then: _execute dispatches the defensive cast (no AoO, check made).
|
||||||
|
caster = _caster()
|
||||||
|
c = _state(caster, "players", (0, 0))
|
||||||
|
f = _state(_foe(), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [8, 3, 3])
|
||||||
|
result = engine._execute(
|
||||||
|
c,
|
||||||
|
Action(
|
||||||
|
kind="cast_spell", target_id="foe", spell_name="Bolt",
|
||||||
|
cast_defensively=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.ok is True
|
||||||
|
assert engine._aoo_used == {}
|
||||||
|
assert f.hp == 100 - 6
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Conformance X6: the CLI renders the --transcript output as an aligned table.
|
||||||
|
|
||||||
|
The engine transcript is a list of strings, mostly shaped
|
||||||
|
``round {n} {combatant_id}: {message}`` (see ``CombatEngine._log``), with a few
|
||||||
|
non-conforming lines (initiative rolls, battle-over notices). The CLI must
|
||||||
|
present these entries as a three-column table (Round / Combattant / Message)
|
||||||
|
with a header row, a separator line and display-width-aware padding, while
|
||||||
|
non-conforming lines fall back to their raw text — never dropped. The
|
||||||
|
transcript DATA (content and order) is untouched: this is presentation only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pf1e_simulator.cli import main, render_transcript_table
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
||||||
|
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
|
||||||
|
ORC = ROOT / "data" / "monsters" / "orc.json"
|
||||||
|
|
||||||
|
_SAMPLE = [
|
||||||
|
"initiative: goblin d20=17+6=23",
|
||||||
|
"round 1 goblin: short sword vs orc -> HIT (4 dmg)",
|
||||||
|
"round 1 orc: falchion vs goblin -> MISS",
|
||||||
|
"round 2 goblin: confused: hits self for 3 (hp 6->3)",
|
||||||
|
"battle over: goblin win in 2 rounds",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _display_width(text: str) -> int:
|
||||||
|
"""Terminal columns used by text (wide CJK counts 2, combining marks 0)."""
|
||||||
|
width = 0
|
||||||
|
for char in text:
|
||||||
|
if unicodedata.combining(char):
|
||||||
|
continue
|
||||||
|
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||||
|
return width
|
||||||
|
|
||||||
|
|
||||||
|
def _table_lines(transcript: Sequence[str]) -> list[str]:
|
||||||
|
return render_transcript_table(transcript).splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed_rows(lines: list[str]) -> list[str]:
|
||||||
|
"""Data rows built from parsed entries (the round column pads them left)."""
|
||||||
|
return [line for line in lines[2:] if line.startswith(" ")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_headers_and_separator() -> None:
|
||||||
|
lines = _table_lines(_SAMPLE)
|
||||||
|
assert lines[0].split() == ["Round", "Combattant", "Message"]
|
||||||
|
assert set(lines[1]) == {"-", " "}
|
||||||
|
# One dash run per column, columns separated by two spaces.
|
||||||
|
assert len(lines[1].split(" ")) == 3
|
||||||
|
# Header, separator, then one row per transcript entry.
|
||||||
|
assert len(lines) == len(_SAMPLE) + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exact_small_table() -> None:
|
||||||
|
out = render_transcript_table(["round 3 goblin-2: move (1,2)"])
|
||||||
|
assert out == (
|
||||||
|
"Round Combattant Message \n"
|
||||||
|
"----- ---------- ----------\n"
|
||||||
|
" 3 goblin-2 move (1,2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_round_column_right_aligned() -> None:
|
||||||
|
lines = _table_lines(["round 1 a: x", "round 10 b: y"])
|
||||||
|
# Header "Round" is 5 wide, so round numbers are right-aligned inside it.
|
||||||
|
assert lines[2].startswith(" 1 ")
|
||||||
|
assert lines[3].startswith(" 10 ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_alignment_uniform_display_width() -> None:
|
||||||
|
lines = _table_lines(_SAMPLE)
|
||||||
|
widths = {_display_width(line) for line in [lines[0], lines[1], *_parsed_rows(lines)]}
|
||||||
|
assert len(widths) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_non_ascii_alignment_accented_and_cjk() -> None:
|
||||||
|
transcript = [
|
||||||
|
"round 1 éowyn: attaque l'orc → 6 dégâts",
|
||||||
|
"round 1 oni: 攻撃 vs orc -> HIT",
|
||||||
|
"round 2 goblin: wait",
|
||||||
|
]
|
||||||
|
lines = _table_lines(transcript)
|
||||||
|
widths = {_display_width(line) for line in lines}
|
||||||
|
assert len(widths) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_raw_fallback_preserved_verbatim() -> None:
|
||||||
|
lines = _table_lines(_SAMPLE)
|
||||||
|
assert "initiative: goblin d20=17+6=23" in lines
|
||||||
|
assert "battle over: goblin win in 2 rounds" in lines
|
||||||
|
# Fallback rows keep the original order between parsed rows.
|
||||||
|
assert lines.index("initiative: goblin d20=17+6=23") == 2
|
||||||
|
assert lines[-1] == "battle over: goblin win in 2 rounds"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_empty_transcript_is_header_only() -> None:
|
||||||
|
lines = _table_lines([])
|
||||||
|
assert lines[0].split() == ["Round", "Combattant", "Message"]
|
||||||
|
assert set(lines[1]) == {"-", " "}
|
||||||
|
assert len(lines) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_long_transcript_stays_aligned() -> None:
|
||||||
|
transcript = [f"round {n} combatant-{n}: message number {n}" for n in range(1, 301)]
|
||||||
|
lines = _table_lines(transcript)
|
||||||
|
widths = {_display_width(line) for line in lines}
|
||||||
|
assert len(widths) == 1
|
||||||
|
assert len(lines) == 302
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_transcript_prints_table(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
rc = main(
|
||||||
|
[
|
||||||
|
"--map",
|
||||||
|
str(MAP),
|
||||||
|
"--side",
|
||||||
|
"players",
|
||||||
|
str(GOBLIN),
|
||||||
|
"--side",
|
||||||
|
"monsters",
|
||||||
|
str(ORC),
|
||||||
|
"--runs",
|
||||||
|
"5",
|
||||||
|
"--seed",
|
||||||
|
"1",
|
||||||
|
"--transcript",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
assert "Déroulé du premier combat" in out
|
||||||
|
# Table markers: header row and separator line.
|
||||||
|
assert "Round" in out
|
||||||
|
assert "Combattant" in out
|
||||||
|
assert "Message" in out
|
||||||
|
assert any(set(line) <= {"-", " "} and "-" in line for line in out.splitlines())
|
||||||
|
# Non-conforming engine lines still show up (raw fallback).
|
||||||
|
assert "initiative:" in out
|
||||||
|
# The balance report must still follow the transcript table.
|
||||||
|
assert "Rapport d'équilibrage" in out
|
||||||
|
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
||||||
+12
-6
@@ -114,9 +114,10 @@ def _make_engine(
|
|||||||
caster_pos: tuple[int, int] = (0, 0),
|
caster_pos: tuple[int, int] = (0, 0),
|
||||||
target_pos: tuple[int, int] = (3, 0),
|
target_pos: tuple[int, int] = (3, 0),
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
|
target_side: str = "monsters",
|
||||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||||
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
|
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
|
||||||
t = CombatantState(combatant=target, side="monsters", pos=target_pos, hp=target.hp_max)
|
t = CombatantState(combatant=target, side=target_side, pos=target_pos, hp=target.hp_max)
|
||||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
grid = Grid.from_spec(spec)
|
grid = Grid.from_spec(spec)
|
||||||
@@ -435,7 +436,8 @@ class TestCureLightWounds:
|
|||||||
def test_heals_damage(self) -> None:
|
def test_heals_damage(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
target = _make_target(hp_max=30)
|
||||||
engine, c, t = _make_engine(caster, target, [3, 3, 5], target_pos=(1, 0))
|
engine, c, t = _make_engine(caster, target, [3, 5], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
t.hp = 10
|
t.hp = 10
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
assert t.hp == 10 + (5 + 1)
|
assert t.hp == 10 + (5 + 1)
|
||||||
@@ -443,7 +445,8 @@ class TestCureLightWounds:
|
|||||||
def test_heal_capped_at_max(self) -> None:
|
def test_heal_capped_at_max(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
target = _make_target(hp_max=30)
|
||||||
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0))
|
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
t.hp = 28
|
t.hp = 28
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
assert t.hp == 30
|
assert t.hp == 30
|
||||||
@@ -457,7 +460,8 @@ class TestBullsStrength:
|
|||||||
def test_applies_buff_effects(self) -> None:
|
def test_applies_buff_effects(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
target = _make_target()
|
||||||
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0))
|
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
engine._cast_spell(c, t, "Bull's Strength")
|
engine._cast_spell(c, t, "Bull's Strength")
|
||||||
buff_mods = [m for b in t.active_buffs for m in b.modifiers]
|
buff_mods = [m for b in t.active_buffs for m in b.modifiers]
|
||||||
atk_mods = [e for e in buff_mods if e.target == "attack"]
|
atk_mods = [e for e in buff_mods if e.target == "attack"]
|
||||||
@@ -470,7 +474,8 @@ class TestBullsStrength:
|
|||||||
def test_transcript_shows_buff(self) -> None:
|
def test_transcript_shows_buff(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
target = _make_target()
|
||||||
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0))
|
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
engine._cast_spell(c, t, "Bull's Strength")
|
engine._cast_spell(c, t, "Bull's Strength")
|
||||||
assert any("buff" in line for line in engine._transcript)
|
assert any("buff" in line for line in engine._transcript)
|
||||||
|
|
||||||
@@ -614,7 +619,8 @@ class TestActiveIsSingleSource:
|
|||||||
def test_legacy_lists_stay_empty_active_lists_populated(self) -> None:
|
def test_legacy_lists_stay_empty_active_lists_populated(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
target = _make_target()
|
||||||
engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0))
|
engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
engine._cast_spell(c, t, "Hold Person")
|
engine._cast_spell(c, t, "Hold Person")
|
||||||
engine._cast_spell(c, t, "Bull's Strength")
|
engine._cast_spell(c, t, "Bull's Strength")
|
||||||
assert t.conditions == []
|
assert t.conditions == []
|
||||||
|
|||||||
Reference in New Issue
Block a user