Compare commits

...

7 Commits

Author SHA1 Message Date
ctan 9c6f5e277e feat(cli): render --transcript output as a table 2026-09-11 13:02:31 +02:00
ctan d6c8f88f77 feat(cli): add --simple-monster-death option to kill monsters at hp <= 0 2026-09-11 12:48:43 +02:00
ctan b7c80babfd fix(rules): enforce the action-economy budget on policy output
Apply the PF1e per-turn action budget (CRB Actions in Combat: one
standard + one move, or one full-round in place of both, plus one
swift) unconditionally to every sequence a policy returns, not only
the immediate/staggered/nauseated specializations.

- _action_economy_filter: greedy in-order walk keeping the earliest
  legal prefix-subsequence; a full-round action is only kept while
  both the standard and move slots are still free.
- Exhaustive kind->slot classification (standard / move / swift /
  full_round / free-conditional) over the Action.kind Literal,
  documented and locked by tests.
- Surplus actions dropped and reported as
  ActionResult(ok=False, reason="downgraded:action_economy") via a
  multiset drop diff (reports duplicate-kind surplus the set-based
  _dropped_kinds cannot).
- Composition order: general budget first, then the existing
  immediate/staggered/nauseated filters (unchanged).
- default_policy untouched and its turns verified legal by the
  validator; golden transcripts byte-identical.
2026-09-11 12:26:49 +02:00
ctan a4814b935b fix(rules): apply cover by attacker's best corner for multi-square attackers 2026-09-11 11:43:10 +02:00
ctan 9fb1c2d75a fix(rules): apply energy resistance per damage component 2026-09-11 11:24:11 +02:00
ctan 1960b8f655 fix(rules): scale Power Attack for two-handed weapons and apply its CMB penalty 2026-09-11 00:19:06 +02:00
ctan 890b7959d4 fix(rules): correct metamagic slots, touch-spell delivery, and defensive/grapple casting 2026-09-11 00:06:06 +02:00
16 changed files with 2569 additions and 119 deletions
+170 -30
View File
@@ -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
+2 -1
View File
@@ -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",
+89 -2
View File
@@ -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
View File
@@ -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(
+19 -8
View File
@@ -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)
+5 -1
View File
@@ -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):
+6
View File
@@ -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
View File
@@ -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")
+378
View File
@@ -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)
+152
View File
@@ -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
+208
View File
@@ -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
+174
View File
@@ -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
+455
View File
@@ -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
+155
View File
@@ -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
View File
@@ -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 == []