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]
|
||||
[--seed SEED] [--round-cap ROUND_CAP]
|
||||
[--seed SEED] [--round-cap ROUND_CAP] [--transcript]
|
||||
[--simple-monster-death]
|
||||
```
|
||||
|
||||
| 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` |
|
||||
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
||||
| `--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 :
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
`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.
|
||||
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
|
||||
(`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.
|
||||
- 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`
|
||||
exactement, une créature est **invalide** — elle reste active mais
|
||||
**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
|
||||
(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.
|
||||
**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 à
|
||||
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
|
||||
@@ -393,8 +476,11 @@ Règles modélisées :
|
||||
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
||||
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
||||
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
||||
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
||||
par le budget général d'action (X4 — action excédentaire retirée de la
|
||||
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 →
|
||||
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||
`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 →
|
||||
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
|
||||
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` ;
|
||||
course vers une `destination` hors du budget 4× →
|
||||
`unreachable_destination` (U5e).
|
||||
@@ -487,11 +575,16 @@ Règles modélisées :
|
||||
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
||||
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||||
visée.
|
||||
- Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain
|
||||
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
|
||||
confirmation de critique ; couvert mou : une créature active entre
|
||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||
attaques de mêlée ignorent les créatures).
|
||||
- Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain
|
||||
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
|
||||
confirmation de critique ; couvert mou : une créature active entre
|
||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
|
||||
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
|
||||
une créature multi-cases (Large +) ne subit que le couvert de SA meilleure
|
||||
case occupée (minimum sur ses cases), pas le pire. 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
|
||||
« 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
|
||||
@@ -579,18 +672,41 @@ Règles modélisées :
|
||||
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
|
||||
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
|
||||
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
|
||||
disponible, l'incantation est refusée** (journalisée `NO SPELL 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
|
||||
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
|
||||
`ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
|
||||
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
|
||||
(`test_dr_does_not_reduce_spell_damage`). Incanter dans une
|
||||
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un
|
||||
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) :
|
||||
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé,
|
||||
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de
|
||||
la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB) ;
|
||||
le don Combat Casting n'accorde son +4 qu'en incantation défensive ou quand
|
||||
on est agrippé, jamais pour les dégâts subis pendant l'incantation. Un sort ciblé avec sauvegarde fait pleins dégâts
|
||||
(`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
|
||||
« Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via
|
||||
`Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les
|
||||
dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
|
||||
inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
|
||||
occupe alors un emplacement de niveau de base + ajustement (Empower +2,
|
||||
Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans
|
||||
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
|
||||
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`
|
||||
@@ -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
|
||||
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
|
||||
correspondant (`resistances` : soustraction du maximum parmi les types du dégât,
|
||||
plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le
|
||||
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
|
||||
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
||||
armes.
|
||||
correspondant, **composante par composante** (conformance B21, CRB « Energy
|
||||
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
|
||||
uniquement par la résistance correspondant à SES types (maximum en cas de types
|
||||
multiples — simplification documentée : une même formule de dés n'est pas
|
||||
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),
|
||||
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
|
||||
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.
|
||||
- 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
|
||||
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
|
||||
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||
(`_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, …)
|
||||
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
|
||||
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
|
||||
cible (direction sélectionnable via `Action.direction`) ;
|
||||
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).
|
||||
Les API de décision exposées (items L1-L8)
|
||||
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`.
|
||||
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,
|
||||
@@ -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é
|
||||
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
|
||||
kind `activate_power_attack` qui pose `state.power_attack`). Les
|
||||
modificateurs sont collectés par
|
||||
kind `activate_power_attack` qui pose `state.power_attack`). Conformance
|
||||
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
|
||||
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
|
||||
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"damage": [{ "formula": "2d4", "types": ["slashing"] }],
|
||||
"damage_bonus": 4,
|
||||
"crit_range": 18,
|
||||
"crit_mult": 2
|
||||
"crit_mult": 2,
|
||||
"two_handed": true
|
||||
},
|
||||
{
|
||||
"name": "javelin",
|
||||
|
||||
@@ -10,10 +10,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||
|
||||
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)."""
|
||||
|
||||
|
||||
_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:
|
||||
map_spec = load_map(args.map)
|
||||
provided = [tuple(raw) for raw in args.side]
|
||||
@@ -103,7 +181,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument(
|
||||
"--transcript",
|
||||
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)
|
||||
if not args.side:
|
||||
@@ -124,13 +210,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
seed=args.seed,
|
||||
spell_registry=registry,
|
||||
capture_first_transcript=args.transcript,
|
||||
monsters_die_at_zero=args.simple_monster_death,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if args.transcript and report.transcript is not None:
|
||||
print("=== Déroulé du premier combat ===")
|
||||
print("\n".join(report.transcript))
|
||||
print(render_transcript_table(report.transcript))
|
||||
print()
|
||||
print(format_report(report, spec))
|
||||
return 0
|
||||
|
||||
+451
-70
@@ -3,7 +3,12 @@
|
||||
Phase 0 documented deviations from PF1e (conventions):
|
||||
- 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
|
||||
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`
|
||||
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||
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,
|
||||
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).
|
||||
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).
|
||||
- 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
|
||||
@@ -96,7 +104,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
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.dice import dice_to_str
|
||||
@@ -118,8 +126,6 @@ from pf1e_simulator.spells import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import Pos, TerrainType
|
||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||
@@ -202,6 +208,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
|
||||
"reposition": "Improved Reposition",
|
||||
"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"
|
||||
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
|
||||
# Strength skills the conformance adjudication groups with the Dex skills.
|
||||
@@ -415,6 +429,23 @@ class CombatantStats:
|
||||
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
|
||||
class CombatantState:
|
||||
"""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
|
||||
# this combatant stabilized while dying; None = not stabilized.
|
||||
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
|
||||
# PREVIOUS turn, assigned at the end of each turn so the Observation built
|
||||
# at the next policy invocation can expose them.
|
||||
@@ -523,8 +558,10 @@ class CombatantState:
|
||||
def active(self) -> bool:
|
||||
"""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
|
||||
or standard action), so it remains active. hp < 0 is dying/dead."""
|
||||
return self.hp >= 0
|
||||
or standard action), so it remains active. hp < 0 is dying/dead.
|
||||
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
|
||||
def dead(self) -> bool:
|
||||
@@ -532,7 +569,10 @@ class CombatantState:
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
|
||||
@@ -677,7 +717,7 @@ class Action:
|
||||
outside the map or without line of effect from the caster is rejected
|
||||
(``invalid_destination``); with ``target_pos`` and no resolved target the
|
||||
spell is cast on the point and every creature in the area rolls its own
|
||||
save. ``aid_another`` is a standard action helping a resolved ally: the
|
||||
save. ``aid_another`` is a standard action helping a resolved ally: the
|
||||
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
|
||||
against AC 10; on success the ally gains +2 on its next attack roll
|
||||
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
||||
@@ -686,6 +726,15 @@ class Action:
|
||||
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
||||
``running``, which lasts until the runner's next turn and denies the
|
||||
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[
|
||||
@@ -713,6 +762,10 @@ class Action:
|
||||
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
|
||||
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,
|
||||
@@ -727,11 +780,15 @@ class Action:
|
||||
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
||||
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
||||
# 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({
|
||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||
"downgraded:staggered", "downgraded:nauseated",
|
||||
"downgraded:action_economy",
|
||||
"delayed", "already_delayed", "ready",
|
||||
"not_prone", "already_prone", "unreachable_destination",
|
||||
"weapon_unavailable", "invalid_destination",
|
||||
@@ -742,6 +799,7 @@ _REASON_MISSING_TARGET = "missing_target"
|
||||
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||
_REASON_DELAYED = "delayed"
|
||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||
_REASON_READY = "ready"
|
||||
@@ -845,6 +903,7 @@ class CombatEngine:
|
||||
llm_policy: LLMPolicy | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
reaction_policy: ReactionPolicy | None = None,
|
||||
monsters_die_at_zero: bool = False,
|
||||
) -> None:
|
||||
if policy is not None and llm_policy is not None:
|
||||
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._spell_registry = spell_registry or {}
|
||||
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._transcript: list[str] = []
|
||||
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."""
|
||||
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:
|
||||
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
||||
|
||||
@@ -1375,11 +1444,24 @@ class CombatEngine:
|
||||
) -> tuple[Action, ...]:
|
||||
"""Downgrade the policy's action sequence to the legal action economy.
|
||||
|
||||
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``.
|
||||
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
||||
Combat": one standard + one move, or one full-round, plus one swift)
|
||||
is enforced first on every sequence; the condition-specific
|
||||
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:
|
||||
kept = _without_swift_bonus(actions)
|
||||
results.extend(
|
||||
@@ -2235,18 +2317,24 @@ class CombatEngine:
|
||||
dmg_mods = self._stat_modifiers(
|
||||
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||
)
|
||||
damage = (
|
||||
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||
)
|
||||
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
||||
damage = sum(rolls) + weapon.damage_bonus
|
||||
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:
|
||||
damage *= weapon.crit_mult
|
||||
damage += sneak_total
|
||||
damage = self._apply_dr(damage, weapon, target)
|
||||
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
|
||||
damage = self._apply_energy_resistance(damage, weapon_types, target)
|
||||
return crit, False, damage
|
||||
return crit, False, self._apply_energy_resistance_per_component(
|
||||
damage, weapon, rolls, crit=crit, target=target
|
||||
)
|
||||
|
||||
def _concealment_chance(self, target: CombatantState) -> int:
|
||||
"""Highest miss chance (%) among the target's active concealment conditions."""
|
||||
@@ -2411,30 +2499,73 @@ class CombatEngine:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
damage: int,
|
||||
damage_types: frozenset[str],
|
||||
target: CombatantState,
|
||||
) -> 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
|
||||
max resistance among matching energy types, floored at 0.
|
||||
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
|
||||
they stay on the DR path.
|
||||
Used by the spell-damage path: a ``DamageEffect`` is a single
|
||||
component (one formula, one type list), so the per-component rule
|
||||
(see ``_apply_energy_resistance_per_component``) reduces to exactly
|
||||
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
|
||||
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)
|
||||
return max(0, damage - self._energy_reduction(damage, damage_types, target))
|
||||
|
||||
def resolve_save(
|
||||
self,
|
||||
@@ -2788,7 +2919,7 @@ class CombatEngine:
|
||||
"""
|
||||
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
||||
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)
|
||||
roll = self._rng.d20()
|
||||
total = (
|
||||
@@ -3306,7 +3437,8 @@ class CombatEngine:
|
||||
kind = action.kind
|
||||
if kind == "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":
|
||||
self._execute_maneuver(
|
||||
@@ -3390,7 +3522,8 @@ class CombatEngine:
|
||||
state.immediate_used = True
|
||||
if action.spell_name is not None:
|
||||
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:
|
||||
self._execute_maneuver(state, target, action.maneuver)
|
||||
@@ -3507,10 +3640,14 @@ class CombatEngine:
|
||||
spell_name: str | None,
|
||||
*,
|
||||
target_pos: tuple[int, int] | None = None,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
cast_defensively: bool = False,
|
||||
) -> ActionResult | None:
|
||||
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
|
||||
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"):
|
||||
# PF1e (CRB): a nauseated creature cannot cast spells or
|
||||
# concentrate; the cant_attack flag blocks the cast entirely.
|
||||
@@ -3525,8 +3662,10 @@ class CombatEngine:
|
||||
return rejection
|
||||
if spell_name is None:
|
||||
return None
|
||||
self._cast_spell(state, target, spell_name, center_pos=target_pos)
|
||||
return None
|
||||
return self._cast_spell(
|
||||
state, target, spell_name, center_pos=target_pos,
|
||||
metamagic=metamagic, cast_defensively=cast_defensively,
|
||||
)
|
||||
|
||||
def _point_target_rejection(
|
||||
self, state: CombatantState, target_pos: tuple[int, int]
|
||||
@@ -3578,7 +3717,13 @@ class CombatEngine:
|
||||
if not state.active:
|
||||
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)
|
||||
roll = self._rng.d20()
|
||||
total = roll + attacker_cmb
|
||||
@@ -4361,9 +4506,16 @@ class CombatEngine:
|
||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
||||
return (ability_score - 10) // 2
|
||||
|
||||
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
|
||||
"""Spell save DC: 10 + spell level + casting ability modifier."""
|
||||
return 10 + spell.level + self._casting_ability_mod(caster)
|
||||
def _spell_dc(
|
||||
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
|
||||
) -> 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]:
|
||||
"""One per-spell dict for the Observation, enriched from the registry."""
|
||||
@@ -4402,7 +4554,9 @@ class CombatEngine:
|
||||
spell_name: str,
|
||||
*,
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
metamagic: tuple[str, ...] = (),
|
||||
cast_defensively: bool = False,
|
||||
) -> ActionResult | None:
|
||||
"""Cast ``spell_name``.
|
||||
|
||||
``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
|
||||
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 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)
|
||||
if spell is None:
|
||||
@@ -4418,31 +4577,128 @@ class CombatEngine:
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
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)
|
||||
if self._spell_cast_rejected(
|
||||
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
|
||||
# spell's level; cantrips (level 0) are cast freely. The slot is spent
|
||||
# up-front — a later failed concentration check still burns it.
|
||||
if spell.level > 0 and not caster.spend_spell_slot(spell.level):
|
||||
# spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
|
||||
# freely. The slot is spent up-front — a later failed concentration
|
||||
# check or missed touch attack still burns it.
|
||||
if used_level > 0 and not caster.spend_spell_slot(used_level):
|
||||
self._log(
|
||||
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
|
||||
# Check for AoO from casting in threatened square (before resolving spell)
|
||||
if self._check_casting_aoo(caster, spell.level):
|
||||
return
|
||||
return ActionResult(
|
||||
kind="cast_spell", ok=False, reason="no_slot",
|
||||
detail=f"level {used_level}",
|
||||
)
|
||||
# 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 (
|
||||
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
|
||||
and not has_area
|
||||
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(
|
||||
self,
|
||||
@@ -4512,9 +4768,18 @@ class CombatEngine:
|
||||
spell: SpellSpec,
|
||||
spell_name: str,
|
||||
center_pos: tuple[int, int] | None,
|
||||
*,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
slot_level: int | None = None,
|
||||
) -> None:
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates)."""
|
||||
dc = self._spell_dc(caster, spell)
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates).
|
||||
|
||||
``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 = (
|
||||
self.resolve_save(
|
||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||
@@ -4526,15 +4791,14 @@ class CombatEngine:
|
||||
target_live = (
|
||||
self._stats[target.combatant.id] if target is not None else caster_live
|
||||
)
|
||||
# Apply metamagic feats to spell effects
|
||||
metamagic_feats = caster.combatant.metamagic_feats
|
||||
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
|
||||
# Apply only the metamagic feats chosen for this cast (B7)
|
||||
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
||||
hp_before = target.hp if target is not None else caster.hp
|
||||
parts = self._apply_spell_effects_from_list(
|
||||
metamagic_effects, target, save_result, caster_live, target_live,
|
||||
caster=caster, spell_name=spell.name,
|
||||
empower="Empower Spell" in metamagic_feats,
|
||||
maximize="Maximize Spell" in metamagic_feats,
|
||||
empower="Empower Spell" in metamagic,
|
||||
maximize="Maximize Spell" in metamagic,
|
||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
||||
)
|
||||
save_str = _save_label(save_result)
|
||||
@@ -4856,9 +5120,9 @@ class CombatEngine:
|
||||
return save_result.success
|
||||
|
||||
def _apply_metamagic(
|
||||
self, spell: SpellSpec, metamagic_feats: list[str]
|
||||
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
||||
) -> 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:
|
||||
return spell.effects
|
||||
has_extend = "Extend Spell" in metamagic_feats
|
||||
@@ -4941,6 +5205,7 @@ class CombatEngine:
|
||||
# Set expiration
|
||||
summoned_state.summon_duration = effect.duration_rounds
|
||||
summoned_state.summon_caster_id = state.combatant.id
|
||||
self._apply_simple_monster_death(summoned_state)
|
||||
|
||||
self._stats[summoned_state.combatant.id] = _LiveStats()
|
||||
self._pending_summons.append(summoned_state)
|
||||
@@ -5569,6 +5834,120 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
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:
|
||||
"""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,
|
||||
spell_name=action.spell_name,
|
||||
target_pos=action.target_pos,
|
||||
metamagic=action.metamagic,
|
||||
cast_defensively=action.cast_defensively,
|
||||
)
|
||||
if action.maneuver is not None:
|
||||
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.
|
||||
|
||||
0 when there is no cover (see has_cover_cells for the corner rule);
|
||||
otherwise the highest cover level among the cells blocking the
|
||||
attacker-corner to target-corner lines. Interposed creatures grant soft
|
||||
cover (+4) on ranged attacks only, exactly as in has_cover_cells.
|
||||
otherwise the cover level of the attacker's BEST occupied cell: each
|
||||
attacker cell is scored by the per-cell corner rule against all of the
|
||||
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:
|
||||
return 0
|
||||
@@ -271,10 +274,18 @@ def cover_bonus_cells(
|
||||
occupied if ranged else frozenset(),
|
||||
bonus_of=terrain_cover_bonus,
|
||||
)
|
||||
return max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
# 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)
|
||||
for corner_a in _corners(cell_a)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
for cell_a in attacker_cells
|
||||
for corner_a in _corners(cell_a)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
return min(per_cell_best)
|
||||
|
||||
@@ -239,7 +239,10 @@ class AttackSpec(_FrozenModel):
|
||||
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
||||
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
|
||||
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
|
||||
@@ -254,6 +257,7 @@ class AttackSpec(_FrozenModel):
|
||||
range_increment_ft: int | None = None
|
||||
count: int = Field(default=1, ge=1)
|
||||
firearm: bool = False
|
||||
two_handed: bool = False
|
||||
|
||||
|
||||
class DamageReduction(_FrozenModel):
|
||||
|
||||
@@ -169,6 +169,7 @@ def run_encounter(
|
||||
policy: Policy | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
capture_first_transcript: bool = False,
|
||||
monsters_die_at_zero: bool = False,
|
||||
) -> EncounterReport:
|
||||
"""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
|
||||
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:
|
||||
msg = "runs must be positive"
|
||||
@@ -202,6 +207,7 @@ def run_encounter(
|
||||
round_cap=spec.round_cap,
|
||||
policy=policy,
|
||||
spell_registry=spell_registry,
|
||||
monsters_die_at_zero=monsters_die_at_zero,
|
||||
).run()
|
||||
if capture_first_transcript and index == 0:
|
||||
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
|
||||
assert rc == 0
|
||||
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 "round 1" in out
|
||||
assert "goblin" in out
|
||||
# The balance report must still be printed after the transcript.
|
||||
assert "Rapport d'équilibrage" in out
|
||||
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),
|
||||
target_pos: tuple[int, int] = (3, 0),
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
target_side: str = "monsters",
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
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)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
@@ -435,7 +436,8 @@ class TestCureLightWounds:
|
||||
def test_heals_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
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
|
||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||
assert t.hp == 10 + (5 + 1)
|
||||
@@ -443,7 +445,8 @@ class TestCureLightWounds:
|
||||
def test_heal_capped_at_max(self) -> None:
|
||||
caster = _make_caster()
|
||||
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
|
||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||
assert t.hp == 30
|
||||
@@ -457,7 +460,8 @@ class TestBullsStrength:
|
||||
def test_applies_buff_effects(self) -> None:
|
||||
caster = _make_caster()
|
||||
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")
|
||||
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"]
|
||||
@@ -470,7 +474,8 @@ class TestBullsStrength:
|
||||
def test_transcript_shows_buff(self) -> None:
|
||||
caster = _make_caster()
|
||||
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")
|
||||
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:
|
||||
caster = _make_caster()
|
||||
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, "Bull's Strength")
|
||||
assert t.conditions == []
|
||||
|
||||
Reference in New Issue
Block a user