Compare commits

...

35 Commits

Author SHA1 Message Date
ctan 9819c3bda9 fix(combat): deed range gating 2026-09-04 22:56:05 +02:00
ctan fc62a19c9e fix(combat): summon spawns only on passable cells 2026-09-04 22:56:05 +02:00
ctan 114ec20866 docs: align README with fixed behavior 2026-09-04 22:56:04 +02:00
ctan 9ffae1a7a2 test(combat): pin ScriptedRng transcripts, unique per scenario 2026-09-04 22:56:03 +02:00
ctan f155146225 feat(combat): class abilities from default_policy 2026-09-04 22:56:02 +02:00
ctan 9f9bd27ca8 fix(data): lich fear aura, paralyzing touch, slot keys 2026-09-04 22:56:01 +02:00
ctan 364e28e2c0 feat(data): wire all 55 PC feats 2026-09-04 22:56:00 +02:00
ctan 70d253f05b fix(combat): single roll to escape grapple 2026-09-04 22:55:59 +02:00
ctan c01a3c1e89 fix(combat): sunder damages weapon not HP 2026-09-04 22:55:59 +02:00
ctan 4921db7b28 feat(combat): Combat Reflexes extra AoOs 2026-09-04 22:55:58 +02:00
ctan 57fcd2ac12 feat(combat): enforce prone/staggered/nauseated/paralyzed 2026-09-04 22:55:57 +02:00
ctan 7a00911c55 fix(combat): bound cackle hex extension 2026-09-04 22:55:56 +02:00
ctan 293f20c98a fix(combat): summoned creatures act and expire 2026-09-04 22:55:55 +02:00
ctan d99cd30309 refactor(combat): single source active_buffs/active_conditions 2026-09-04 22:55:55 +02:00
ctan fb413d76a8 feat(combat): class-ability action kinds, engine RNG only 2026-09-04 22:55:54 +02:00
ctan 90b253185c fix(combat): correct hex and AoE DCs, DR bypassed by spells 2026-09-04 22:55:53 +02:00
ctan febfb76e63 fix(combat): correct concentration DCs by situation 2026-09-04 22:55:52 +02:00
ctan 7065d1f990 feat(runner): wire spell registry in CLI and Monte Carlo 2026-09-04 22:55:51 +02:00
ctan aa711c4023 feat(data): per-level spell slots for 8 PCs 2026-09-04 22:55:50 +02:00
ctan 7336109ab2 chore(lint): restore green gate 2026-09-04 22:55:49 +02:00
ctan bc5bf918b5 feat(data): lich monster 2026-09-04 22:55:48 +02:00
ctan 46ac13a91e feat(combat): class action patterns 2026-09-04 22:55:48 +02:00
ctan ba2167526a feat(combat): channel energy and hexes 2026-09-04 22:55:47 +02:00
ctan b300a413be feat(data): companions and mounts 2026-09-04 22:55:46 +02:00
ctan df624cc041 feat(loaders): map 8-PC feats 2026-09-04 22:55:45 +02:00
ctan b5c15ae60b feat(combat): summoning 2026-09-04 22:55:44 +02:00
ctan 03e0d6ecb6 feat(combat): combat maneuvers CMB/CMD 2026-09-04 22:55:43 +02:00
ctan 7e1aa25bc1 feat(combat): area-of-effect spells 2026-09-04 22:55:43 +02:00
ctan cd09aa1087 feat(combat): spellcasting default policy 2026-09-04 22:55:42 +02:00
ctan 842d5aee7e feat(combat): skills system 2026-09-04 22:55:41 +02:00
ctan 223e10b88e feat(combat): AoO and concentration on casting 2026-09-04 22:55:40 +02:00
ctan 67df26e042 feat(combat): enforce behavioral condition flags 2026-09-04 22:55:40 +02:00
ctan d3f7f27c51 feat(combat): metamagic 2026-09-04 22:55:39 +02:00
ctan 1c039ff4f1 feat(models): combat resources 2026-09-04 22:55:38 +02:00
ctan 6e27a2bba1 fix(combat): correct spell DC formula 2026-09-04 22:55:37 +02:00
43 changed files with 5050 additions and 302 deletions
+94 -29
View File
@@ -335,14 +335,75 @@ Règles modélisées :
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
appliqués via `resolve_modifiers` (mêmes règles d'empilement que les
attaques). Renvoie un `SaveResult` (success, roll, total, dc).
- 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`). Le DD vaut `10 + niveau du
sort + 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 CON
(+4 avec le don Combat Casting). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- 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).
- Conditions effectives : 18 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple et escape sont
modélisées, chacune comme une tentative unique (1d20 + CMB contre CMD) :
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
`disarmed` ; une arme brisée est exclue de `usable_attacks()`. `default_policy` ne
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
politique personnalisée).
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1).
- Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence`
(evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et, sur une sauvegarde réussie,
ne font que la moitié des dégâts.
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré
(`tests/test_feats.py`).
- Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, allié < 50 % ou
ennemis groupés à 30 ft), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty)
— couvertes par `tests/test_class_abilities.py`.
- Le Liche (`data/monsters/lich.json`) : aura de terreur (30 ft, Volonté DD 22 ou
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
`paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire
`spell_slots` sans clés dupliquées.
Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) :
- Manœuvres de combat.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft
et actions immédiates hors-tour.
- Effets mécaniques de hauteur/élévation (les hauteurs sont validées mais non
appliquées au déplacement, à la portée ou à la ligne d'effet).
- Tailles Large+ (2×2), allonge > 5 ft (la mêlée reste limitée à une case) et
actions immédiates hors-tour (consommeraient le prochain swift).
- Parmi les manœuvres, seules trip / disarm / bull_rush / sunder / grapple / escape
sont câblées ; les autres (overrun, dirty trick, steal, reposition, drag) ne le
sont pas. Une arme brisée ne peut pas être réparée en cours de combat.
- Les modificateurs d'effets conditionnés par un `condition=` au runtime ne sont pas
évalués (`_modifier_condition_holds` renvoie toujours False, le modificateur est
alors exclu) ; la distribution des dégâts par type sur un sort n'envoie que des
dégâts simples (sans plage « half on save » pour les effets ciblés).
## Architecture
@@ -360,7 +421,9 @@ dans la résolution) :
des créatures) dans `resolve_attack`.
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
transcripts, politique par défaut.
incantation (emplacements, concentration par situation, AoO d'incantation),
invocation, manœuvres, capacités de classe, transcripts, politique par
défaut.
- `effects.py` — système d'effets : `StatModifier` (modificateur de
caractéristique avec type de bonus) et `resolve_modifiers` (application des
règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait,
@@ -373,25 +436,28 @@ dans la résolution) :
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions.
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les
drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered,
etc.) sont appliqués par le moteur pendant le tour.
- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec
catégorie feat/class/racial/trait), dons passifs prédéfinis (Weapon Focus,
Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness,
Point-Blank Shot, Dodge) et don actif Power Attack (−X attaque / +2X dégâts,
X = BAB//4 + 1, mêlée uniquement, declared as free action at turn start via
`state.power_attack`). Les modificateurs sont collectés par
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, declared as free action at turn
start via `state.power_attack`). Les modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes.
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`. Le moteur intègre les sorts via
`Action(kind="cast_spell")` et `_cast_spell` : résolution de sauvegarde,
application des dégâts (avec RD et demi-dégâts sur sauvegarde réussie),
conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de
`Combatant` contient les noms de sorts connus ; le `spell_registry` est
passé au `CombatEngine`.
application des dégâts (demi-dégâts sur sauvegarde réussie ; la RD ne
s'applique pas aux dégâts de sorts), conditions, soins (plafonnés à hp_max)
et buffs. Le champ `spells` de `Combatant` contient les noms de sorts connus ;
le `spell_registry` est passé au `CombatEngine`.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
@@ -408,7 +474,7 @@ dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 377 tests
uv run pytest -q # 472 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
@@ -421,17 +487,16 @@ uv run basedpyright src # mode strict
## Feuille de route
- **Phase 1** — magie et états : sorts modélisés comme effets paramétrés,
conditions, manœuvres de combat, dons et capacités de classe. Flanquement,
attaques à outrance, attaques d'opportunité, charge, retraite et pas de
placement sont déjà modélisés. Le système d'effets (`effects.py`), les
règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le
système de conditions (`conditions.py`), les dons passifs et actifs
(`abilities.py`) et les sorts (`spells.py` + base JSON `data/spells/`)
sont en place. Le chargeur Foundry extrait automatiquement les dons
(feats, traits, racial) vers des `AbilitySpec` pré-définis et les noms
de sorts vers `Combatant.spells`.
- **Phase 1** — magie et états : largement réalisée. Sorts par emplacements
(`spells.py` + base `data/spells/`, 11 sorts), DD et sauvegardes, RD non
appliquée aux dégâts de sorts, concentration par situation, invocation,
conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
hex, deeds, inspire courage / spell combat) sont en place et documentés dans
« Règles implémentées ». Restent hors du socle (voir « Non modélisé ») : les
hauteurs/élévations, tailles Large+, allonges > 5 ft, actions immédiates
hors-tour, et le reste des manœuvres.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement.
en politiques, balayage de matrices de positionnement. (Non implémentée.)
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
rédaction du rapport d'équilibrage en français.
rédaction du rapport d'équilibrage en français. (Non implémentée.)
+53
View File
@@ -0,0 +1,53 @@
{
"name": "Celestial Wolf",
"level": 2,
"size": "Medium",
"abilities": {
"str_score": 13,
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 15,
"ac": {
"total": 14,
"touch": 13,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 50,
"saves": {
"fort": 4,
"ref": 5,
"will": 2
},
"attacks": [
{
"id": "celestial_wolf:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d6+1",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"source": "summon monster II",
"features": [],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+81
View File
@@ -0,0 +1,81 @@
{
"name": "Lich",
"level": 11,
"size": "Medium",
"abilities": {
"str_score": 12,
"dex_score": 14,
"con_score": 14,
"int_score": 20,
"wis_score": 14,
"cha_score": 16
},
"hp_max": 100,
"ac": {
"total": 25,
"touch": 14,
"flat_footed": 23
},
"bab": 8,
"initiative_mod": 6,
"speed_land_ft": 30,
"saves": {
"fort": 10,
"ref": 10,
"will": 14
},
"casting_ability": "int",
"spell_slots": {
"1": 4,
"2": 4,
"3": 4,
"4": 4,
"5": 4,
"6": 3
},
"attacks": [
{
"id": "lich:touch",
"name": "Paralyzing Touch",
"kind": "touch",
"attack_bonus": 10,
"damage": [
{
"formula": "1d8+5",
"types": ["negative"]
}
],
"damage_bonus": 5,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"dr": {
"amount": 15,
"bypass": ["bludgeoning", "magic"]
},
"source": "bestiary",
"features": [
"Fear Aura",
"Paralyzing Touch",
"Undead Traits"
],
"spells": [
"Mage Armor",
"Shield",
"Magic Missile",
"Scorching Ray",
"Fireball",
"Lightning Bolt",
"Hold Person",
"Wall of Fire",
"Cloudkill",
"Disintegrate",
"Horrid Wilting",
"Power Word Kill"
],
"metamagic_feats": [],
"skills": {}
}
+3
View File
@@ -24,6 +24,9 @@
"ref": 10,
"will": 5
},
"casting_ability": "int",
"spell_slots": {},
"resources": {},
"attacks": [
{
"name": "Lance",
+60
View File
@@ -0,0 +1,60 @@
{
"name": "Esha's Mount",
"level": 4,
"size": "Large",
"abilities": {
"str_score": 18,
"dex_score": 14,
"con_score": 15,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 31,
"ac": {
"total": 23,
"touch": 11,
"flat_footed": 21
},
"bab": 3,
"initiative_mod": 2,
"speed_land_ft": 40,
"saves": {
"fort": 6,
"ref": 6,
"will": 2
},
"attacks": [
{
"id": "esha_mount:hooves",
"name": "Hooves",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{
"formula": "1d6",
"types": ["bludgeoning"]
}
],
"damage_bonus": 4,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 10,
"count": 2
}
],
"source": "fiches_personnages/esha_sheet.json (mount)",
"features": [
"Combat Reflexes",
"Alertness",
"Armor Proficiency (Light)",
"Armor Proficiency (Medium)",
"Thoroughbred",
"Animal (race)"
],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+18 -15
View File
@@ -7,7 +7,7 @@
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 14,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 15,
@@ -19,6 +19,7 @@
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 10,
"speed_fly_ft": 80,
"saves": {
"fort": 4,
"ref": 5,
@@ -26,33 +27,31 @@
},
"attacks": [
{
"name": "Talons (attaque naturelle)",
"id": "harvie:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 2,
"attack_bonus": 3,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
"types": ["piercing"]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 2
"count": 1
},
{
"name": "Bite (attaque naturelle)",
"id": "harvie:claw",
"name": "Claw",
"kind": "melee",
"attack_bonus": -3,
"attack_bonus": 3,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
"formula": "1d3",
"types": ["slashing"]
}
],
"damage_bonus": 0,
@@ -63,11 +62,15 @@
}
],
"source": "fiches_personnages/harvie_sheet.json",
"speed_fly_ft": 80,
"features": [
"Friendly Face",
"Fey-touched",
"Animal (race)"
],
"spells": []
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+13 -26
View File
@@ -19,6 +19,7 @@
"bab": 3,
"initiative_mod": 3,
"speed_land_ft": 15,
"speed_fly_ft": 40,
"saves": {
"fort": 3,
"ref": 6,
@@ -26,36 +27,17 @@
},
"attacks": [
{
"name": "Bite (attaque naturelle)",
"id": "ierlieth:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
"types": ["piercing"]
}
],
"damage_bonus": -1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Tail (attaque naturelle)",
"kind": "melee",
"attack_bonus": -1,
"damage": [
{
"formula": "1d4",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 0,
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
@@ -64,9 +46,14 @@
],
"source": "fiches_personnages/ierlieth_sheet.json",
"features": [
"Drake (race)",
"Toughness",
"Power Attack",
"Drake (race)"
"Power Attack"
],
"spells": []
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+12 -26
View File
@@ -26,40 +26,21 @@
},
"attacks": [
{
"name": "Hooves (attaque naturelle)",
"id": "jeanne:hooves",
"name": "Hooves",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{
"formula": "1d4",
"types": [
"bludgeoning"
]
"formula": "1d6",
"types": ["bludgeoning"]
}
],
"damage_bonus": 4,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"reach_ft": 10,
"count": 2
},
{
"name": "Bite (attaque naturelle)",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d3",
"types": [
"piercing"
]
}
],
"damage_bonus": 2,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"source": "fiches_personnages/jeanne_sheet.json",
@@ -68,8 +49,13 @@
"Alertness",
"Armor Proficiency (Light)",
"Armor Proficiency (Medium)",
"Thoroughbred (Human; Keleshite)",
"Thoroughbred",
"Animal (race)"
],
"spells": []
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+8
View File
@@ -24,6 +24,14 @@
"ref": 9,
"will": 3
},
"casting_ability": "cha",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"bardic_rounds": 12
},
"attacks": [
{
"name": "MWK Rapier",
+8
View File
@@ -24,6 +24,14 @@
"ref": 5,
"will": 7
},
"casting_ability": "int",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"hexes": 5
},
"attacks": [
{
"name": "Coup à mains nues (aucune arme encodée)",
+8
View File
@@ -24,6 +24,14 @@
"ref": 7,
"will": 9
},
"casting_ability": "wis",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"channel_energy": 6
},
"attacks": [
{
"name": "Longbow",
+8
View File
@@ -24,6 +24,14 @@
"ref": 10,
"will": 8
},
"casting_ability": "wis",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"grit": 3
},
"attacks": [
{
"name": "Rapier (masterwork)",
+7
View File
@@ -5,6 +5,11 @@
"range": "long",
"save": "ref",
"base_dc": 10,
"effects": [
{
"type": "area",
"radius_ft": 20,
"save_for_half": true,
"effects": [
{
"type": "damage",
@@ -12,6 +17,8 @@
"types": ["fire"],
"half_on_save": true
}
]
}
],
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half."
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Summon Monster II",
"level": 2,
"school": "conjuration",
"range": "close",
"save": "none",
"base_dc": 10,
"effects": [
{
"type": "summon",
"creature": "data/monsters/celestial_wolf.json",
"duration_rounds": 5
}
],
"description": "Summons a celestial wolf to fight for you."
}
+264
View File
@@ -147,3 +147,267 @@ POWER_ATTACK = AbilitySpec(
category="feat",
source_detail="feat lv1",
)
# ── Additional feats from 8 PCs ────────────────────────────────────────────────
# Mounted Combat: +1 attack and damage while mounted, no penalty for charging (CRB)
MOUNTED_COMBAT = AbilitySpec(
name="Mounted Combat",
category="feat",
effects=(
StatModifier(target="attack", value=1, condition="mounted"),
StatModifier(target="damage", value=1, condition="mounted"),
),
)
# Stealth Synergy: +2 bonus on Stealth checks when adjacent to ally with feat
STEALTH_SYNERGY = AbilitySpec(
name="Stealth Synergy",
category="feat",
effects=(StatModifier(target="stealth", value=2, condition="adjacent_ally_stealth_synergy"),),
)
# Adopted: Gain a trait from another race
ADOPTED = AbilitySpec(name="Adopted", category="trait")
# Thoroughbred: +1 trait bonus on Ride and Handle Animal checks
THOROUGHBRED = AbilitySpec(
name="Thoroughbred",
category="trait",
effects=(
StatModifier(target="ride", value=1),
StatModifier(target="handle_animal", value=1),
),
)
# Vagabond Child: +1 trait bonus on Sleight of Hand, always a class skill
VAGABOND_CHILD = AbilitySpec(
name="Vagabond Child",
category="trait",
effects=(StatModifier(target="sleight_of_hand", value=1),),
)
# Whispering Wind: +1 trait bonus on Fly checks
WHISPERING_WIND = AbilitySpec(
name="Whispering Wind",
category="trait",
effects=(StatModifier(target="fly", value=1),),
)
# Breeze-Kissed: +2 trait bonus on saves vs. cold and electricity
BREEZE_KISSED = AbilitySpec(
name="Breeze-Kissed",
category="trait",
effects=(
StatModifier(target="fort", value=2, condition="cold"),
StatModifier(target="ref", value=2, condition="electricity"),
),
)
# Like the Wind: +10 ft movement speed
LIKE_THE_WIND = AbilitySpec(
name="Like the Wind",
category="trait",
effects=(StatModifier(target="speed_ft", value=10),),
)
# Friendly Face: +1 trait bonus on Diplomacy checks
FRIENDLY_FACE = AbilitySpec(
name="Friendly Face",
category="trait",
effects=(StatModifier(target="diplomacy", value=1),),
)
# Fey-touched: +1 trait bonus on saves vs. fey effects
FEY_TOUCHED = AbilitySpec(
name="Fey-touched",
category="trait",
effects=(
StatModifier(target="will", value=1, condition="fey"),
),
)
# Combat Reflexes: additional AoO per round equal to Dex mod
COMBAT_REFLEXES = AbilitySpec(
name="Combat Reflexes",
category="feat",
source_detail="feat lv1",
)
# Armor Proficiency (Light/Medium): no passive modifiers
ARMOR_PROFICIENCY_LIGHT = AbilitySpec(name="Armor Proficiency (Light)", category="feat")
ARMOR_PROFICIENCY_MEDIUM = AbilitySpec(name="Armor Proficiency (Medium)", category="feat")
# Harmonic Spell: +1 caster level for one spell
HARMONIC_SPELL = AbilitySpec(name="Harmonic Spell", category="feat")
# Weapon Finesse: use Dex instead of Str for attack rolls with light weapons
WEAPON_FINESSE = AbilitySpec(
name="Weapon Finesse",
category="feat",
effects=(StatModifier(target="attack", value=0, condition="weapon_finesse"),),
)
# Axe to Grind: +1 trait bonus on damage with axes
AXE_TO_GRIND = AbilitySpec(
name="Axe to Grind",
category="trait",
effects=(StatModifier(target="damage", value=1, condition="axe"),),
)
# Spell Vulnerability (divination): -2 penalty on saves vs divination
SPELL_VULNERABILITY_DIVINATION = AbilitySpec(
name="Spell Vulnerability (divination; drawback)",
category="trait",
effects=(
StatModifier(target="will", value=-2, condition="divination"),
StatModifier(target="ref", value=-2, condition="divination"),
),
)
# Born Damned (tiefling): darkness 1/day, +2 on saves vs poison
BORN_DAMNED = AbilitySpec(
name="Born Damned (race: tiefling)",
category="racial",
effects=(
StatModifier(target="fort", value=2, condition="poison"),
),
)
# Gifted Adept: +1 caster level for one spell
GIFTED_ADEPT = AbilitySpec(
name="Gifted Adept (dispel magic; magic)",
category="trait",
)
# Prehensile Tail: can retrieve small items as swift action
PREHENSILE_TAIL = AbilitySpec(
name="Prehensile Tail (tiefling daemon-spawn)",
category="racial",
)
# Scaled Skin (electricity): resist electricity 5
SCALED_SKIN_ELECTRICITY = AbilitySpec(
name="Scaled Skin (electricity) (tiefling daemon-spawn)",
category="racial",
)
# Maw or Claw: bite attack 1d6
MAW_OR_CLAW = AbilitySpec(
name="Maw or Claw (Bite) (tiefling daemon-spawn)",
category="racial",
)
# Brew Potion: can create potions
BREW_POTION = AbilitySpec(name="Brew Potion", category="feat")
# Craft Wondrous Item: can create wondrous items
CRAFT_WONDROUS_ITEM = AbilitySpec(name="Craft Wondrous Item", category="feat")
# Extra Hex: gain one additional hex
EXTRA_HEX = AbilitySpec(name="Extra Hex", category="feat")
# Eschew Materials: cast spells without material components
ESCHEW_MATERIALS = AbilitySpec(name="Eschew Materials", category="feat")
# Half-Forgotten Secrets (Dhampir): +2 on saves vs disease/mind-affecting
HALF_FORGOTTEN_SECRETS = AbilitySpec(
name="Half-Forgotten Secrets (Dhampir; Ajibachana)",
category="trait",
effects=(
StatModifier(target="fort", value=2, condition="disease"),
StatModifier(target="will", value=2, condition="mind_affecting"),
),
)
# Pingre: +1 trait bonus on Appraise and saves vs poison
PINGRE = AbilitySpec(
name="Pingre",
category="trait",
effects=(
StatModifier(target="appraise", value=1),
StatModifier(target="fort", value=1, condition="poison"),
),
)
# Summon Good Monster: expanded summon list
SUMMON_GOOD_MONSTER = AbilitySpec(name="Summon Good Monster", category="feat")
# Sacred Summons: summon good monsters as standard action
SACRED_SUMS = AbilitySpec(name="Sacred Summons", category="feat")
# Selective Channeling: exclude targets from channel energy
SELECTIVE_CHANNELING = AbilitySpec(name="Selective Channeling", category="feat")
# Extremely Fashionable: +4 trait bonus on Diplomacy checks
EXTREMELY_FASHIONABLE = AbilitySpec(
name="Extremely Fashionable",
category="trait",
effects=(StatModifier(target="diplomacy", value=4),),
)
# Exalted of the Society: +1 on Knowledge (local) and (nobility)
EXALTED_SOCIETY = AbilitySpec(
name="Exalted of the Society (Cleric, Pathfinder Society)",
category="trait",
effects=(
StatModifier(target="knowledge_local", value=1),
StatModifier(target="knowledge_nobility", value=1),
),
)
# Bitter: +1 trait bonus on saves vs fear
BITTER = AbilitySpec(
name="Bitter",
category="trait",
effects=(StatModifier(target="will", value=1, condition="fear"),),
)
# Ambitious: +1 trait bonus on one Craft or Profession skill
AMBITIOUS = AbilitySpec(name="Ambitious", category="trait")
# Rapid Reload: reload firearms as free action
RAPID_RELOAD = AbilitySpec(name="Rapid Reload (Pistol)", category="feat")
# Gunsmithing: craft firearms and ammunition
GUNSMITHING = AbilitySpec(name="Gunsmithing", category="feat")
# Skill Focus (Perception): +3 Perception
SKILL_FOCUS_PERCEPTION = AbilitySpec(
name="Skill Focus (Perception)",
category="feat",
effects=(StatModifier(target="perception", value=3),),
)
# Dazzling Display: intimidate as standard action
DAZZLING_DISPLAY = AbilitySpec(name="Dazzling Display", category="feat")
# Fortified Drinker: +2 on saves vs poison when drinking alcohol
FORTIFIED_DRINKER = AbilitySpec(
name="Fortified Drinker (Cayden Cailean)",
category="trait",
effects=(
StatModifier(target="fort", value=2, condition="alcohol"),
),
)
# Fate's Favored: +1 luck bonus on all luck bonuses
FATES_FAVORED = AbilitySpec(name="Fate's Favored", category="trait")
# Lonely: +1 trait bonus on Will saves vs fear
LONELY = AbilitySpec(
name="Lonely",
category="trait",
effects=(StatModifier(target="will", value=1, condition="fear"),),
)
# Larger Than Life: +1 trait bonus on Intimidate
LARGER_THAN_LIFE = AbilitySpec(
name="Larger Than Life",
category="trait",
effects=(StatModifier(target="intimidate", value=1),),
)
# Focused Study: +2 on one skill
FOCUSED_STUDY = AbilitySpec(name="Focused Study", category="trait")
+3 -1
View File
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
from pf1e_simulator.map import MapValidationError, load_map
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
class CliError(Exception):
@@ -111,7 +112,8 @@ def main(argv: list[str] | None = None) -> int:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
try:
report = run_encounter(spec, runs=args.runs, seed=args.seed)
registry = load_spell_registry(SPELLS_DIR)
report = run_encounter(spec, runs=args.runs, seed=args.seed, spell_registry=registry)
except ValueError as exc:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
File diff suppressed because it is too large Load Diff
+45
View File
@@ -204,6 +204,50 @@ DAZED = Condition(
cant_act=True,
)
# Asleep/Unconscious: helpless, can't act, -4 AC vs melee, no Dex to AC (CRB)
ASLEEP = Condition(
name="asleep",
modifiers=(
StatModifier(target="ac", value=-4),
),
denies_dex_to_ac=True,
cant_act=True,
cant_move=True,
)
# Grappled: -4 Dex, -2 attack, no movement, no AoO, can't cast without concentration (CRB)
GRAPPLED = Condition(
name="grappled",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="ac", value=-2), # -4 Dex = -2 AC
StatModifier(target="ref", value=-2), # -4 Dex = -2 Ref
StatModifier(target="dex_skills", value=-4), # -4 to Dex skills
),
cant_move=True,
cant_charge=True,
cant_aoo=True,
denies_dex_to_ac=False, # Doesn't lose Dex to AC, but -4 Dex penalty applies
)
# Pinned: -4 Dex, -2 attack, no movement, can't cast, -4 AC vs melee (CRB)
PINNED = Condition(
name="pinned",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="ac", value=-4),
StatModifier(target="ref", value=-2),
StatModifier(target="dex_skills", value=-4),
),
cant_move=True,
cant_charge=True,
cant_aoo=True,
denies_dex_to_ac=True,
)
# Staggered: only single move or standard action, no full-round (CRB)
STAGGERED = Condition(
name="staggered",
@@ -216,5 +260,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED,
]
}
+4 -2
View File
@@ -80,10 +80,12 @@ class DiceExpr(BaseModel):
def mean(self) -> float:
"""Expected value: count * (sides + 1) / 2 + bonus."""
if self.count == 0:
return float(self.bonus)
return self.count * (self.sides + 1) / 2 + self.bonus
def max_value(self) -> int:
"""Maximum possible value: count * sides + bonus."""
return self.count * self.sides + self.bonus
def parse_dice(text: str) -> DiceExpr:
"""Parse a dice notation string into a DiceExpr.
+11
View File
@@ -43,6 +43,17 @@ StatTarget = Literal[
"attack", "damage", "ac", "touch_ac", "flat_footed_ac",
"fort", "ref", "will", "initiative",
"cmb", "cmd", "speed_ft", "hp", "concentration", "cl",
# Skills (37 PF1e skills)
"acrobatics", "appraise", "bluff", "climb", "craft",
"diplomacy", "disable_device", "disguise", "escape_artist", "fly",
"handle_animal", "heal", "intimidate", "knowledge_arcana",
"knowledge_dungeoneering", "knowledge_engineering", "knowledge_geography",
"knowledge_history", "knowledge_local", "knowledge_nature",
"knowledge_nobility", "knowledge_planes", "knowledge_religion",
"linguistics", "perception", "perform", "profession", "ride",
"sense_motive", "sleight_of_hand", "spellcraft", "stealth",
"survival", "swim", "use_magic_device",
"dex_skills",
]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
+116 -12
View File
@@ -8,13 +8,14 @@ as-is.
Feat names are matched case-insensitively. Known feats (Power Attack,
Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec``
constants. Unknown feat names produce a minimal ``AbilitySpec`` with no
mechanical effects — the data is preserved for future implementation.
constants. Unmatched feat names produce a traced no-op (debug log +
minimal ``AbilitySpec``) so no name silently disappears.
"""
from __future__ import annotations
import json
import logging
import re
from typing import TYPE_CHECKING
@@ -26,20 +27,66 @@ if TYPE_CHECKING:
from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.abilities import (
ADOPTED,
ALERTNESS,
AMBITIOUS,
ARMOR_PROFICIENCY_LIGHT,
ARMOR_PROFICIENCY_MEDIUM,
AXE_TO_GRIND,
BITTER,
BORN_DAMNED,
BREEZE_KISSED,
BREW_POTION,
COMBAT_REFLEXES,
CRAFT_WONDROUS_ITEM,
DAZZLING_DISPLAY,
DODGE,
ESCHEW_MATERIALS,
EXALTED_SOCIETY,
EXTRA_HEX,
EXTREMELY_FASHIONABLE,
FATES_FAVORED,
FEY_TOUCHED,
FOCUSED_STUDY,
FORTIFIED_DRINKER,
FRIENDLY_FACE,
GIFTED_ADEPT,
GREAT_FORTITUDE,
GUNSMITHING,
HALF_FORGOTTEN_SECRETS,
HARMONIC_SPELL,
IRON_WILL,
LARGER_THAN_LIFE,
LIGHTNING_REFLEXES,
LIKE_THE_WIND,
LONELY,
MAW_OR_CLAW,
MOUNTED_COMBAT,
PINGRE,
POINT_BLANK_SHOT,
POWER_ATTACK,
PRECISE_SHOT,
PREHENSILE_TAIL,
RAPID_RELOAD,
SACRED_SUMS,
SCALED_SKIN_ELECTRICITY,
SELECTIVE_CHANNELING,
SKILL_FOCUS_PERCEPTION,
SPELL_VULNERABILITY_DIVINATION,
STEALTH_SYNERGY,
SUMMON_GOOD_MONSTER,
THOROUGHBRED,
VAGABOND_CHILD,
WEAPON_FINESSE,
WHISPERING_WIND,
AbilitySpec,
toughness,
weapon_focus,
)
from pf1e_simulator.models import Combatant
logger = logging.getLogger(__name__)
# ── Feat lookup ────────────────────────────────────────────────────────────────
_WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE)
@@ -53,16 +100,64 @@ _KNOWN_FEATS: dict[str, AbilitySpec] = {
"point-blank shot": POINT_BLANK_SHOT,
"precise shot": PRECISE_SHOT,
"dodge": DODGE,
"mounted combat": MOUNTED_COMBAT,
"stealth synergy": STEALTH_SYNERGY,
"adopted": ADOPTED,
"thoroughbred": THOROUGHBRED,
"thoroughbred (human; keleshite)": THOROUGHBRED,
"vagabond child (urban)": VAGABOND_CHILD,
"whispering wind": WHISPERING_WIND,
"breeze-kissed": BREEZE_KISSED,
"like the wind": LIKE_THE_WIND,
"friendly face": FRIENDLY_FACE,
"fey-touched": FEY_TOUCHED,
"combat reflexes": COMBAT_REFLEXES,
"armor proficiency (light)": ARMOR_PROFICIENCY_LIGHT,
"armor proficiency (medium)": ARMOR_PROFICIENCY_MEDIUM,
"harmonic spell": HARMONIC_SPELL,
"weapon finesse": WEAPON_FINESSE,
"axe to grind (combat)": AXE_TO_GRIND,
"spell vulnerability (divination; drawback)": SPELL_VULNERABILITY_DIVINATION,
"born damned (race: tiefling)": BORN_DAMNED,
"gifted adept (dispel magic; magic)": GIFTED_ADEPT,
"prehensile tail (tiefling daemon-spawn)": PREHENSILE_TAIL,
"scaled skin (electricity) (tiefling daemon-spawn)": SCALED_SKIN_ELECTRICITY,
"maw or claw (bite) (tiefling daemon-spawn)": MAW_OR_CLAW,
"brew potion": BREW_POTION,
"craft wondrous item": CRAFT_WONDROUS_ITEM,
"extra hex": EXTRA_HEX,
"eschew materials": ESCHEW_MATERIALS,
"half-forgotten secrets (dhampir; ajibachana)": HALF_FORGOTTEN_SECRETS,
"pingre": PINGRE,
"summon good monster": SUMMON_GOOD_MONSTER,
"sacred summons": SACRED_SUMS,
"selective channeling": SELECTIVE_CHANNELING,
"extremely fashionable": EXTREMELY_FASHIONABLE,
"exalted of the society (cleric, pathfinder society)": EXALTED_SOCIETY,
"bitter": BITTER,
"ambitious": AMBITIOUS,
"rapid reload (pistol)": RAPID_RELOAD,
"gunsmithing": GUNSMITHING,
"skill focus (perception)": SKILL_FOCUS_PERCEPTION,
"dazzling display": DAZZLING_DISPLAY,
"fortified drinker (cayden cailean)": FORTIFIED_DRINKER,
"fate's favored": FATES_FAVORED,
"lonely": LONELY,
"larger than life": LARGER_THAN_LIFE,
"focused study (trait racial humain \u2014 remplace le bonus feat)": FOCUSED_STUDY,
}
# Feat names with no combat-mechanical definition (flavor / race descriptors / jokes).
# These are resolved to traced no-ops so the loader never silently drops a name.
_NOOP_FEATS: frozenset[str] = frozenset({
"6. vert - yes mistress",
"animal (race)",
"drake (race)",
"orange - lazy ass",
})
def _resolve_feat(name: str, level: int) -> AbilitySpec:
"""Resolve a feat name string to an AbilitySpec.
Known feats → their constant. Toughness → factory with level.
Weapon Focus (X) → factory with weapon name. Unknown feats → minimal
AbilitySpec with no effects (data preserved for future implementation).
"""
stripped = name.strip()
lower = stripped.lower()
if lower in _KNOWN_FEATS:
@@ -72,6 +167,10 @@ def _resolve_feat(name: str, level: int) -> AbilitySpec:
wf_match = _WEAPON_FOCUS_RE.match(stripped)
if wf_match:
return weapon_focus(wf_match.group(1))
if lower in _NOOP_FEATS:
logger.debug("feat traced-noop: %r", stripped)
return AbilitySpec(name=stripped, category="feat")
logger.debug("feat unmatched (no definition): %r", stripped)
return AbilitySpec(name=stripped, category="feat")
@@ -133,16 +232,21 @@ def load_combatant(path: Path) -> Combatant:
_auto_attack_ids(data, monster_id)
# Resolve features (list of strings → list of AbilitySpec)
level = data.get("level", 1) if isinstance(data.get("level"), int) else 1
raw_level = data.get("level")
level = raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else 1
raw_features = data.pop("features", None)
validate_payload: dict[str, object] = dict(data)
if isinstance(raw_features, list):
# Type ignore: we assign AbilitySpec list into a Json dict before Pydantic validation
data["features"] = _resolve_features(raw_features, level) # type: ignore[assignment]
validate_payload["features"] = _resolve_features(raw_features, level)
# spells stays as list[str] — Pydantic validates it directly
raw_slots = data.pop("spell_slots", None)
if isinstance(raw_slots, dict):
validate_payload["spell_slots"] = {int(k): v for k, v in raw_slots.items()}
try:
return Combatant.model_validate(data)
return Combatant.model_validate(validate_payload)
except ValidationError as exc:
raise CombatantLoadError(path, str(exc)) from exc
+28 -1
View File
@@ -36,6 +36,7 @@ from pf1e_simulator.models import (
Combatant,
DamageComponent,
Saves,
SkillRank,
)
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None
@@ -108,6 +109,30 @@ def _extract_spells(spellcasting: Json) -> list[str]:
return names
def _extract_skills(skills_section: Json) -> dict[str, SkillRank]:
"""Extract skills from Foundry sheet skills section."""
if not isinstance(skills_section, dict):
return {}
skills: dict[str, SkillRank] = {}
for name, data in skills_section.items():
if not isinstance(data, dict):
continue
key = name.lower().replace(" ", "_").replace("(", "").replace(")", "")
# Handle special cases
key = key.replace("knowledge_", "knowledge_")
rank = data.get("rank", 0)
class_skill = data.get("classSkill", False)
armor_penalty = data.get("armorPenalty", 0)
if isinstance(armor_penalty, bool):
armor_penalty = 0
if isinstance(rank, bool):
rank = 0
skills[key] = SkillRank(
rank=int(rank) if isinstance(rank, (int, float)) else 0,
class_skill=bool(class_skill),
armor_penalty=int(armor_penalty) if isinstance(armor_penalty, (int, float)) else 0,
)
return skills
class SheetLoadError(Exception):
"""Raised when a sheet cannot be loaded; carries path and reason."""
@@ -300,7 +325,8 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
speed_raw = reader.as_dict(combat.get("speed"), "combat.speed")
ac_raw = reader.as_dict(combat.get("ac"), "combat.ac")
hp_raw = reader.as_dict(combat.get("hp"), "combat.hp")
skills_raw = root.get("skills")
skills = _extract_skills(skills_raw)
sheet_id = reader.req_str(meta, "id", "meta.id")
land_speed = reader.speed(speed_raw.get("land"), "combat.speed.land")
if land_speed is None:
@@ -350,6 +376,7 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
attacks=kept,
features=_extract_features(root.get("feats")),
spells=_extract_spells(root.get("spellcasting")),
skills=skills,
)
report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped))
return combatant, report
+178 -1
View File
@@ -27,6 +27,16 @@ class AbilityScores(_FrozenModel):
wis_score: int
cha_score: int
@property
def str_mod(self) -> int:
"""Strength ability modifier."""
return (self.str_score - 10) // 2
@property
def dex_mod(self) -> int:
"""Dexterity ability modifier."""
return (self.dex_score - 10) // 2
class ACProfile(_FrozenModel):
total: int
@@ -40,6 +50,166 @@ class Saves(_FrozenModel):
will: int
class SkillSpec(_FrozenModel):
"""Definition of a PF1e skill."""
name: str
ability: Literal["str", "dex", "con", "int", "wis", "cha"]
trained_only: bool = False
armor_check_penalty: bool = False
class SkillRank(_FrozenModel):
"""A combatant's rank in a skill."""
rank: int = 0
class_skill: bool = False
armor_penalty: int = 0
# Predefined PF1e skills (37 skills)
PREDEFINED_SKILLS: dict[str, SkillSpec] = {
"acrobatics": SkillSpec(
name="Acrobatics", ability="dex",
trained_only=False, armor_check_penalty=True
),
"appraise": SkillSpec(
name="Appraise", ability="int",
trained_only=False, armor_check_penalty=False
),
"bluff": SkillSpec(
name="Bluff", ability="cha",
trained_only=False, armor_check_penalty=False
),
"climb": SkillSpec(
name="Climb", ability="str",
trained_only=False, armor_check_penalty=True
),
"craft": SkillSpec(
name="Craft", ability="int",
trained_only=True, armor_check_penalty=False
),
"diplomacy": SkillSpec(
name="Diplomacy", ability="cha",
trained_only=False, armor_check_penalty=False
),
"disable_device": SkillSpec(
name="Disable Device", ability="dex",
trained_only=True, armor_check_penalty=False
),
"disguise": SkillSpec(
name="Disguise", ability="cha",
trained_only=False, armor_check_penalty=False
),
"escape_artist": SkillSpec(
name="Escape Artist", ability="dex",
trained_only=False, armor_check_penalty=True
),
"fly": SkillSpec(
name="Fly", ability="dex",
trained_only=False, armor_check_penalty=True
),
"handle_animal": SkillSpec(
name="Handle Animal", ability="cha",
trained_only=True, armor_check_penalty=False
),
"heal": SkillSpec(
name="Heal", ability="wis",
trained_only=False, armor_check_penalty=False
),
"intimidate": SkillSpec(
name="Intimidate", ability="cha",
trained_only=False, armor_check_penalty=False
),
"knowledge_arcana": SkillSpec(
name="Knowledge (Arcana)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_dungeoneering": SkillSpec(
name="Knowledge (Dungeoneering)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_engineering": SkillSpec(
name="Knowledge (Engineering)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_geography": SkillSpec(
name="Knowledge (Geography)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_history": SkillSpec(
name="Knowledge (History)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_local": SkillSpec(
name="Knowledge (Local)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_nature": SkillSpec(
name="Knowledge (Nature)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_nobility": SkillSpec(
name="Knowledge (Nobility)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_planes": SkillSpec(
name="Knowledge (Planes)", ability="int",
trained_only=True, armor_check_penalty=False
),
"knowledge_religion": SkillSpec(
name="Knowledge (Religion)", ability="int",
trained_only=True, armor_check_penalty=False
),
"linguistics": SkillSpec(
name="Linguistics", ability="int",
trained_only=True, armor_check_penalty=False
),
"perception": SkillSpec(
name="Perception", ability="wis",
trained_only=False, armor_check_penalty=False
),
"perform": SkillSpec(
name="Perform", ability="cha",
trained_only=False, armor_check_penalty=False
),
"profession": SkillSpec(
name="Profession", ability="wis",
trained_only=True, armor_check_penalty=False
),
"ride": SkillSpec(
name="Ride", ability="dex",
trained_only=False, armor_check_penalty=True
),
"sense_motive": SkillSpec(
name="Sense Motive", ability="wis",
trained_only=False, armor_check_penalty=False
),
"sleight_of_hand": SkillSpec(
name="Sleight of Hand", ability="dex",
trained_only=True, armor_check_penalty=True
),
"spellcraft": SkillSpec(
name="Spellcraft", ability="int",
trained_only=True, armor_check_penalty=False
),
"stealth": SkillSpec(
name="Stealth", ability="dex",
trained_only=False, armor_check_penalty=True
),
"survival": SkillSpec(
name="Survival", ability="wis",
trained_only=False, armor_check_penalty=False
),
"swim": SkillSpec(
name="Swim", ability="str",
trained_only=False, armor_check_penalty=True
),
"use_magic_device": SkillSpec(
name="Use Magic Device", ability="cha",
trained_only=True, armor_check_penalty=False
),
}
class DamageComponent(_FrozenModel):
"""One damage roll with its types (e.g. 1d8 piercing).
@@ -112,4 +282,11 @@ class Combatant(_FrozenModel):
source: str = ""
notes: str = ""
features: list[AbilitySpec] = []
spells: list[str] = []
skills: dict[str, SkillRank] = Field(default_factory=dict)
cmb: int | None = None # Combat Maneuver Bonus
cmd: int | None = None # Combat Maneuver Defense
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha
spell_slots: dict[int, int] = Field(default_factory=dict) # max slots per spell level
resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
spells: list[str] = Field(default_factory=list)
+19 -2
View File
@@ -17,12 +17,14 @@ if TYPE_CHECKING:
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec
from pf1e_simulator.models import Combatant
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import zone_cells
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
@dataclass(frozen=True)
@@ -111,6 +113,7 @@ def build_states(spec: EncounterSpec) -> list[CombatantState]:
side=side.name,
pos=pos,
hp=effective.hp_max,
resources_left=dict(effective.resources),
)
)
return states
@@ -122,13 +125,20 @@ def run_encounter(
seed: int,
*,
policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
) -> EncounterReport:
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics."""
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
The spell registry is loaded once (from ``data/spells/`` unless overridden)
and passed into every ``CombatEngine`` so caster PCs actually cast.
"""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
grid = Grid.from_spec(spec.map)
states = build_states(spec)
if spell_registry is None:
spell_registry = load_spell_registry(SPELLS_DIR)
ids = [s.combatant.id for s in states]
wins = {side.name: 0 for side in spec.sides}
draws = 0
@@ -138,7 +148,14 @@ def run_encounter(
if index > 0:
states = build_states(spec)
rng = SeededRng(seed + index)
result = CombatEngine(rng, grid, states, round_cap=spec.round_cap, policy=policy).run()
result = CombatEngine(
rng,
grid,
states,
round_cap=spec.round_cap,
policy=policy,
spell_registry=spell_registry,
).run()
if result.winner is None:
draws += 1
else:
+142 -31
View File
@@ -17,14 +17,16 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
from pf1e_simulator.dice import DiceExpr, parse_dice
from pf1e_simulator.effects import BonusType, StatModifier
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
if TYPE_CHECKING:
from pf1e_simulator.loaders.foundry import Json
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
SaveType = Literal["fort", "ref", "will", "none"]
@@ -54,7 +56,32 @@ class BuffEffect:
duration_rounds: int
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect
@dataclass(frozen=True)
class AreaEffect:
"""Area of effect spell (burst only for Phase 0).
- shape: only "burst" supported (circle around target point)
- radius_ft: radius in feet (e.g., 20 for fireball)
- effects: the effects applied to each creature in the area
- save_for_half: if True, successful save halves damage
"""
shape: Literal["burst"] = "burst"
radius_ft: int = 20
effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True
@dataclass(frozen=True)
class SummonEffect:
"""Summoning spell effect.
- creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json")
- duration_rounds: how long the summoned creature stays
"""
creature: str
duration_rounds: int
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
@dataclass(frozen=True)
@@ -81,54 +108,138 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
return 400 + 40 * caster_level
def _parse_effect(data: dict[str, Json]) -> SpellEffect:
effect_type = str(data["type"])
def _as_dict(value: Json) -> dict[str, Json]:
if isinstance(value, dict):
return value
msg = f"expected object, got {value!r}"
raise ValueError(msg)
def _as_list(value: Json) -> list[Json]:
if isinstance(value, list):
return value
msg = f"expected list, got {value!r}"
raise ValueError(msg)
def _as_str(value: Json) -> str:
if isinstance(value, str):
return value
msg = f"expected string, got {value!r}"
raise ValueError(msg)
def _as_int(value: Json) -> int:
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
return int(value)
msg = f"expected integer, got {value!r}"
raise ValueError(msg)
def _as_optional_str(value: Json) -> str | None:
if value is None:
return None
return _as_str(value)
def _is_spell_range(value: str) -> TypeGuard[SpellRange]:
return value in ("personal", "touch", "close", "medium", "long")
def _is_save_type(value: str) -> TypeGuard[SaveType]:
return value in ("fort", "ref", "will", "none")
def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
return value in get_args(StatTarget)
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
return value in get_args(BonusType)
def _parse_modifier(value: Json) -> StatModifier:
m = _as_dict(value)
target = _as_str(m["target"])
if not _is_stat_target(target):
msg = f"unknown stat target: {target!r}"
raise ValueError(msg)
bonus = _as_str(m.get("bonus_type", ""))
if not _is_bonus_type(bonus):
msg = f"unknown bonus type: {bonus!r}"
raise ValueError(msg)
return StatModifier(
target=target,
value=_as_int(m["value"]),
bonus_type=bonus,
condition=_as_optional_str(m.get("condition")),
weapon_filter=_as_optional_str(m.get("weapon_filter")),
)
def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data)
effect_type = _as_str(effect["type"])
if effect_type == "damage":
return DamageEffect(
formula=parse_dice(str(data["formula"])),
types=tuple(str(t) for t in data["types"]), # type: ignore[index]
half_on_save=bool(data.get("half_on_save", True)),
formula=parse_dice(_as_str(effect["formula"])),
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(effect.get("half_on_save", True)),
)
if effect_type == "condition":
return ConditionEffect(
condition_name=str(data["condition_name"]),
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type]
save_negates=bool(data.get("save_negates", True)),
condition_name=_as_str(effect["condition_name"]),
duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(effect.get("save_negates", True)),
)
if effect_type == "heal":
return HealEffect(
formula=parse_dice(str(data["formula"])),
formula=parse_dice(_as_str(effect["formula"])),
)
if effect_type == "buff":
return BuffEffect(
modifiers=tuple(
StatModifier(
target=str(m["target"]), # type: ignore[index]
value=int(m["value"]), # type: ignore[arg-type]
bonus_type=cast("BonusType", str(m.get("bonus_type", ""))), # type: ignore[union-attr]
condition=m.get("condition"), # type: ignore[assignment]
weapon_filter=m.get("weapon_filter"), # type: ignore[assignment]
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
)
for m in cast("list[dict[str, Json]]", data["modifiers"])
),
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type]
if effect_type == "area":
return AreaEffect(
radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)),
)
if effect_type == "summon":
return SummonEffect(
creature=_as_str(effect["creature"]),
duration_rounds=_as_int(effect["duration_rounds"]),
)
msg = f"Unknown effect type: {effect_type}"
raise ValueError(msg)
def load_spell(path: str | Path) -> SpellSpec:
data: dict[str, Json] = json.loads(Path(path).read_text())
effects = tuple(_parse_effect(e) for e in data.get("effects", [])) # type: ignore[arg-type]
data = _as_dict(json.loads(Path(path).read_text()))
effects = tuple(_parse_effect(e) for e in _as_list(data.get("effects", [])))
range_raw = _as_str(data.get("range", "medium"))
if not _is_spell_range(range_raw):
msg = f"unknown spell range: {range_raw!r}"
raise ValueError(msg)
save_raw = _as_str(data.get("save", "none"))
if not _is_save_type(save_raw):
msg = f"unknown save type: {save_raw!r}"
raise ValueError(msg)
return SpellSpec(
name=str(data["name"]),
level=int(data["level"]), # type: ignore[arg-type]
school=str(data["school"]),
range=str(data.get("range", "medium")), # type: ignore[assignment]
save=str(data.get("save", "none")), # type: ignore[assignment]
base_dc=int(data.get("base_dc", 10)), # type: ignore[arg-type]
name=_as_str(data["name"]),
level=_as_int(data["level"]),
school=_as_str(data["school"]),
range=range_raw,
save=save_raw,
base_dc=_as_int(data.get("base_dc", 10)),
effects=effects,
description=str(data.get("description", "")),
description=_as_str(data.get("description", "")),
)
+7 -4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Literal
from pf1e_simulator.abilities import (
ALERTNESS,
DODGE,
@@ -9,6 +11,7 @@ from pf1e_simulator.abilities import (
IRON_WILL,
LIGHTNING_REFLEXES,
POINT_BLANK_SHOT,
AbilitySpec,
weapon_focus,
)
from pf1e_simulator.combat import CombatantState, CombatEngine
@@ -30,16 +33,16 @@ from pf1e_simulator.rng import ScriptedRng
def _make_hero(
cid: str = "hero",
*,
features: list[object] | None = None,
features: list[AbilitySpec] | None = None,
weapon_name: str = "sword",
weapon_kind: str = "melee",
weapon_kind: Literal["melee", "ranged", "touch"] = "melee",
attack_bonus: int = 5,
damage_bonus: int = 2,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind=weapon_kind, # type: ignore[arg-type]
kind=weapon_kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
damage_bonus=damage_bonus,
@@ -61,7 +64,7 @@ def _make_hero(
speed_land_ft=30,
saves=Saves(fort=5, ref=4, will=3),
attacks=[attack],
features=features or [], # type: ignore[arg-type]
features=features or [],
)
+139
View File
@@ -0,0 +1,139 @@
"""Tests for the cackle hex: bound the extension of hex conditions (Task 9).
Cackle must extend exactly one +1 round per pre-existing hex condition, in a
single pass, without re-extending the replacements it appends. The pre-fix code
mutated ``target.active_conditions`` (remove/append) while iterating it, which
grew the list unboundedly and looped forever.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.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
if TYPE_CHECKING:
from collections.abc import Collection
_HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2")
_ROUNDS_BEFORE = (2, 1)
def _make_witch(cid: str = "caster") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=5,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spell_slots={1: 4},
resources={"hexes": 3},
)
def _make_target(cid: str = "target") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(
combatant=_make_witch(), side="players", pos=(0, 0), hp=20,
resources_left={"hexes": 3},
)
t = CombatantState(
combatant=_make_target(), side="monsters", pos=(3, 0), hp=30,
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t])
return engine, c, t
def _seed_hex_conditions(target: CombatantState) -> dict[str, int]:
target.active_conditions = [
ActiveCondition(
condition=CONDITIONS_BY_NAME["shaken"],
rounds_left=rounds,
source=source,
)
for source, rounds in zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True)
]
return dict(zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True))
def _rounds_by_source(conditions: Collection[ActiveCondition]) -> dict[str, int]:
return {ac.source: ac.rounds_left for ac in conditions}
class TestCackleExtendsHexOnce:
"""Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes,
rounds_left 2 and 1)
When: the witch cackles
Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left
rises by 1, the total hex-condition count stays 2 (no growth), and the
call terminates (the pre-fix loop never did)."""
def test_exactly_two_hex_extensions_of_one_round(self) -> None:
engine, c, t = _make_engine()
expected = _seed_hex_conditions(t)
engine._hex_cackle(c, t)
after = _rounds_by_source(t.active_conditions)
assert len(after) == 2
assert after == {source: rounds + 1 for source, rounds in expected.items()}
+254
View File
@@ -0,0 +1,254 @@
"""Tests for class abilities emitted by default_policy, gated on resources.
Covers the 4 PC resource pools: oni (channel_energy), nairda (hex),
tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
"emit when available" and a "silent when exhausted" test.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
from pf1e_simulator.dice import parse_dice
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 SPELLS_DIR, SpellSpec, load_spell_registry
def _combatant(
cid: str,
*,
resources: dict[str, int] | None = None,
spells: list[str] | None = None,
hp_max: int = 20,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=5,
size="Medium",
abilities=AbilityScores(
str_score=14, dex_score=14, con_score=12,
int_score=14, wis_score=12, cha_score=14,
),
hp_max=hp_max,
ac=ACProfile(total=15, touch=12, flat_footed=14),
bab=3,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=4, ref=3, will=4),
attacks=[attack],
resources=resources or {},
spells=spells or [],
)
def _state(
combatant: Combatant, side: str, pos: tuple[int, int], hp: int | None = None
) -> CombatantState:
state = CombatantState(
combatant=combatant, side=side, pos=pos, hp=hp if hp is not None else combatant.hp_max
)
state.resources_left = dict(combatant.resources)
return state
def _grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
return Grid.from_spec(spec)
def _engine(
combatant: Combatant,
enemies: list[Combatant],
*,
spell_registry: dict[str, SpellSpec] | None = None,
rng_queue: list[int] | None = None,
) -> tuple[CombatEngine, CombatantState, list[CombatantState]]:
me = _state(combatant, "players", (5, 5))
enemy_states = [
_state(e, "monsters", (5, 5 + i + 1)) for i, e in enumerate(enemies)
]
states = [me, *enemy_states]
engine = CombatEngine(
ScriptedRng(rng_queue or []), _grid(), states, spell_registry=spell_registry
)
return engine, me, enemy_states
# --- oni: channel_energy ---
def test_oni_emits_channel_energy_when_ally_hurt() -> None:
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(oni, [goblin])
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
engine.states.append(hurt_ally)
actions = default_policy(engine, me)
assert any(a.kind == "channel_energy" for a in actions)
def test_oni_silent_when_channel_energy_exhausted() -> None:
oni = _combatant("oni", resources={"channel_energy": 0}, hp_max=30)
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(oni, [goblin])
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
engine.states.append(hurt_ally)
actions = default_policy(engine, me)
assert not any(a.kind == "channel_energy" for a in actions)
def test_oni_channel_energy_spends_resource() -> None:
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(oni, [goblin], rng_queue=[5])
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
engine.states.append(hurt_ally)
action = Action(
kind="channel_energy", target_id=engine.nearest_enemy(me).combatant.id
)
engine._execute(me, action)
assert me.resources_left["channel_energy"] == 5
# --- nairda: hex ---
def test_nairda_emits_hex_when_available() -> None:
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(nairda, [goblin])
actions = default_policy(engine, me)
assert any(a.kind == "hex" for a in actions)
def test_nairda_silent_when_hexes_exhausted() -> None:
nairda = _combatant("nairda", resources={"hexes": 0})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(nairda, [goblin])
actions = default_policy(engine, me)
assert not any(a.kind == "hex" for a in actions)
def test_nairda_hex_spends_resource() -> None:
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
action = Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
engine._execute(me, action)
assert me.resources_left["hexes"] == 4
# --- tammara: deeds ---
def test_tammara_emits_deed_alongside_attack() -> None:
tammara = _combatant("tammara", resources={"grit": 3})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(tammara, [goblin])
actions = default_policy(engine, me)
assert any(a.kind == "deed" for a in actions)
assert any(a.kind in {"full_attack", "attack"} for a in actions)
def test_tammara_silent_when_grit_exhausted() -> None:
tammara = _combatant("tammara", resources={"grit": 0})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(tammara, [goblin])
actions = default_policy(engine, me)
assert not any(a.kind == "deed" for a in actions)
def test_tammara_silent_when_target_out_of_deed_range() -> None:
tammara = _combatant("tammara", resources={"grit": 3})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(tammara, [])
far_goblin = _state(goblin, "monsters", (11, 11), hp=6)
engine.states.append(far_goblin)
actions = default_policy(engine, me)
assert not any(a.kind == "deed" for a in actions)
def test_tammara_deed_out_of_range_spends_no_grit() -> None:
tammara = _combatant("tammara", resources={"grit": 3})
goblin = _combatant("goblin", hp_max=6)
engine, me, _ = _engine(tammara, [])
far_goblin = _state(goblin, "monsters", (11, 11), hp=6)
engine.states.append(far_goblin)
engine._execute(
me,
Action(
kind="deed",
target_id=far_goblin.combatant.id,
deed_name="up_close_and_deadly",
),
)
assert me.resources_left["grit"] == 3
# --- misty: spell_combat + inspire_courage ---
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
misty = _combatant(
"misty", resources={"bardic_rounds": 12}, spells=["Burning Hands"]
)
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
actions = default_policy(engine, me)
assert any(a.kind == "inspire_courage" for a in actions)
assert any(a.kind == "spell_combat" for a in actions)
def test_misty_silent_when_bardic_rounds_exhausted() -> None:
misty = _combatant(
"misty", resources={"bardic_rounds": 0}, spells=["Burning Hands"]
)
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
actions = default_policy(engine, me)
assert not any(a.kind == "inspire_courage" for a in actions)
assert not any(a.kind == "spell_combat" for a in actions)
+89
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Literal
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import (
Action,
CombatantState,
@@ -1331,3 +1332,91 @@ def test_5ft_step_blocks_move() -> None:
"round 1 orc: wait",
"battle over: draw after 1 rounds",
)
def _make_guard_cr(dex: int, *, combat_reflexes: bool) -> Combatant:
"""Guard with given DEX and optional Combat Reflexes feat."""
return Combatant(
id="guard",
name="guard",
level=3,
size="Medium",
abilities=AbilityScores(
str_score=14,
dex_score=dex,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=3,
initiative_mod=(dex - 10) // 2,
speed_land_ft=0,
saves=Saves(fort=4, ref=3, will=1),
attacks=[
AttackSpec(
id="guard-w",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
crit_range=20,
crit_mult=2,
)
],
features=[AbilitySpec(name="Combat Reflexes", category="feat")] if combat_reflexes else [],
)
def test_combat_reflexes_multiple_aoos() -> None:
"""Guard with Combat Reflexes + DEX 16 takes multiple AoOs when enemies move."""
runner1 = make_combatant("runner1", hp=20, ac=12, speed=30)
runner2 = make_combatant("runner2", hp=20, ac=12, speed=30)
runner3 = make_combatant("runner3", hp=20, ac=12, speed=30)
guard = _make_guard_cr(dex=16, combat_reflexes=True)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner1, "players", (2, 3)),
make_state(runner2, "players", (4, 3)),
make_state(runner3, "players", (3, 2)),
make_state(guard, "monsters", (3, 5)),
make_state(dest, "monsters", (6, 5)),
]
engine = CombatEngine(
ScriptedRng([15, 14, 13, 5, 4, 15, 3, 14, 2, 13, 4, 12, 1]),
make_grid(),
states,
round_cap=1,
policy=_move_to_dest,
)
result = engine.run()
aoo_lines = [line for line in result.transcript if "AoO" in line]
assert len(aoo_lines) >= 3
def test_no_combat_reflexes_single_aoo() -> None:
"""Without Combat Reflexes, the guard still takes exactly1 AoO per round."""
runner1 = make_combatant("runner1", hp=20, ac=12, speed=30)
runner2 = make_combatant("runner2", hp=20, ac=12, speed=30)
runner3 = make_combatant("runner3", hp=20, ac=12, speed=30)
guard = _make_guard_cr(dex=16, combat_reflexes=False)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner1, "players", (2, 3)),
make_state(runner2, "players", (4, 3)),
make_state(runner3, "players", (3, 2)),
make_state(guard, "monsters", (3, 5)),
make_state(dest, "monsters", (6, 5)),
]
engine = CombatEngine(
ScriptedRng([15, 14, 13, 5, 4, 15, 3]),
make_grid(),
states,
round_cap=1,
policy=_move_to_dest,
)
result = engine.run()
aoo_lines = [line for line in result.transcript if "AoO" in line]
assert len(aoo_lines) == 1
+4 -1
View File
@@ -8,6 +8,8 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.abilities import (
DODGE,
IRON_WILL,
@@ -79,7 +81,8 @@ class TestResolveFeatures:
assert result[1] is DODGE
def test_filters_non_strings(self) -> None:
result = _resolve_features(["Iron Will", 42, None, True], 1) # type: ignore[list-item]
raw: list[Json] = ["Iron Will", 42, None, True]
result = _resolve_features(raw, 1)
assert len(result) == 1
def test_empty_list(self) -> None:
+115
View File
@@ -0,0 +1,115 @@
"""Tests for situation-dependent concentration check DCs (Todo 4).
The concentration DC depends on the casting situation (CRB Table 9-1):
- injured while casting: DC = 10 + damage_taken + spell_level
- grappled or pinned: DC = 10 + grappler's CMB + spell_level
- casting defensively: DC = 15 + 2 * spell_level
The check roll = d20 + caster_level + casting ability mod (+4 Combat Casting),
unchanged from the current logic.
"""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def _combatant(
cid: str,
*,
level: int = 5,
con: int = 12,
cmb: int | None = None,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=con,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
cmb=cmb,
)
def _make_engine(
caster: Combatant,
grappler: Combatant,
queue: list[int],
*,
grappled: bool = False,
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
g = CombatantState(combatant=grappler, side="monsters", pos=(1, 0), hp=grappler.hp_max)
if grappled:
c.grappled_with = grappler.id
g.grappled_with = caster.id
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 4] * 4), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(ScriptedRng(queue), grid, [c, g])
return engine, c, g
class TestConcentrationDc:
"""Given: a caster making a concentration check in a given situation
When: the situation DC is computed
Then: it matches the CRB rule for that situation."""
def test_dc_when_injured_is_ten_plus_damage_plus_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler")
engine, c, g = _make_engine(caster, grappler, [])
assert engine._concentration_dc(c, 1, damage_taken=4) == 10 + 4 + 1
def test_dc_when_grappled_uses_grappler_cmb_plus_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler", cmb=7)
engine, c, g = _make_engine(caster, grappler, [], grappled=True)
assert engine._concentration_dc(c, 1, damage_taken=0) == 10 + 7 + 1
def test_dc_when_casting_defensively_is_15_plus_twice_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler")
engine, c, g = _make_engine(caster, grappler, [])
assert engine._concentration_dc(c, 2, damage_taken=0) == 15 + 2 * 2
def test_check_passes_at_threshold_and_fails_one_below(self) -> None:
caster = _combatant("caster", level=5, con=10)
grappler = _combatant("grappler")
engine_pass, c, _ = _make_engine(caster, grappler, [10])
assert engine_pass._check_concentration(c, 1, damage_taken=4) is True
engine_fail = _make_engine(caster, grappler, [9])[0]
assert engine_fail._check_concentration(c, 1, damage_taken=4) is False
+4 -3
View File
@@ -225,8 +225,8 @@ class TestConditionOnAC:
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(STUNNED)
result = engine.resolve_attack(h, f, hero.attacks[0])
# foe AC 15 - 2 (stunned) = 13
assert result.ac == 13
# foe flat-footed AC 13 - 2 (stunned) = 11 (denies DEX to AC)
assert result.ac == 11
def test_blinded_defender_minus2_ac(self) -> None:
hero = _make_hero()
@@ -234,7 +234,8 @@ class TestConditionOnAC:
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(BLINDED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
# foe flat-footed AC 13 - 2 (blinded) = 11 (denies DEX to AC)
assert result.ac == 11
def test_entangled_defender_minus2_ac(self) -> None:
hero = _make_hero()
+287
View File
@@ -0,0 +1,287 @@
"""Tests for mechanical condition effects (prone, staggered, nauseated, paralyzed)
and StatModifier.condition filtering.
Part (a): StatModifier with condition= is only included when the condition holds.
Part (b): Condition behavioral effects enforced via action economy / AC modifiers.
"""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition, StatModifier
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
def _make_hero(cid: str = "hero", bab: int = 5) -> Combatant:
melee = AttackSpec(
id=f"{cid}-sword",
name="sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
damage_bonus=2,
)
ranged = AttackSpec(
id=f"{cid}-bow",
name="bow",
kind="ranged",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
damage_bonus=2,
range_increment_ft=60,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=14, dex_score=12, con_score=12,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=18, touch=12, flat_footed=16),
bab=bab,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=5, ref=4, will=3),
attacks=[melee, ranged],
)
def _make_foe(cid: str = "foe") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=12, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=15,
ac=ACProfile(total=15, touch=10, flat_footed=13),
bab=3,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=3, ref=2, will=1),
attacks=[attack],
)
def _make_engine(
hero: Combatant, foe: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(ScriptedRng(queue), grid, [h, f])
return engine, h, f
def _make_engine_3(
c1: Combatant, c2: Combatant, c3: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
s1 = CombatantState(combatant=c1, side="players", pos=(0, 0), hp=c1.hp_max)
s2 = CombatantState(combatant=c2, side="monsters", pos=(0, 1), hp=c2.hp_max)
s3 = CombatantState(combatant=c3, side="monsters", pos=(0, 2), hp=c3.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(ScriptedRng(queue), grid, [s1, s2, s3])
return engine, s1, s2, s3
# ── Test 1: prone → -4 AC vs melee attacks ───────────────────────────────────
class TestProneACMelee:
"""Given: a prone target
When: a melee attacker resolves an attack
Then: the target's AC is reduced by 4 (prone penalty)."""
def test_prone_target_minus4_ac_vs_melee(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.active_conditions.append(
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
)
result = engine.resolve_attack(h, f, hero.attacks[0])
# foe total AC 15, prone penalty -4 → effective AC 11
assert result.ac == 11
# ── Test 2: prone ranged attacker → -4 on ranged attack ───────────────────────
class TestProneRangedAttack:
"""Given: an attacker who is prone
When: they make a ranged attack
Then: their attack total is reduced by 4."""
def test_prone_attacker_minus4_ranged_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [15, 4])
h.active_conditions.append(
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
)
ranged_weapon = hero.attacks[1] # bow
result = engine.resolve_attack(h, f, ranged_weapon)
# base_bonus=5, roll=15, no flank (ranged), no range penalty (same cell), prone -4
# total = 15 + 5 - 4 = 16
assert result.total == 16
# ── Test 3: staggered → reduced action economy ────────────────────────────────
class TestStaggeredActionEconomy:
"""Given: a staggered combatant
When: _take_turn is called
Then: they can only do one move OR one standard action (no full attack)."""
def test_staggered_full_attack_becomes_single_attack(self) -> None:
hero = _make_hero(bab=6)
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 10, 10, 4])
h.active_conditions.append(
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
)
engine._roll_initiative()
engine._take_turn(h)
# Staggered: no full-round. A leading full_attack (adjacent, BAB 6 →
# 2 swings) becomes a single standard-action attack → exactly 1 swing.
swings = [e for e in engine._transcript if " vs foe " in e and "sword" in e]
assert len(swings) == 1
def test_staggered_can_still_act(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
h.active_conditions.append(
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
)
engine._roll_initiative()
result = engine._take_turn(h)
assert result is False
# ── Test 4: nauseated → single standard action only ───────────────────────────
class TestNauseatedActionEconomy:
"""Given: a nauseated combatant
When: _take_turn is called
Then: they can only take standard actions (no full attack, no charge, no move)."""
def test_nauseated_no_attack_and_no_full_round(self) -> None:
hero = _make_hero(bab=6)
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
h.active_conditions.append(
ActiveCondition(condition=NAUSEATED, rounds_left=5, source="test")
)
engine._roll_initiative()
engine._take_turn(h)
for entry in engine._transcript:
assert "sword vs foe" not in entry, (
f"nauseated combatant should not attack: {entry}"
)
# ── Test 5: paralyzed → no actions + effective DEX 0 (-4 AC) ─────────────────
class TestParalyzedNoActions:
"""Given: a paralyzed combatant
When: _take_turn is called
Then: they take no actions and are effectively helpless."""
def test_paralyzed_no_action(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
h.active_conditions.append(
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
)
engine._roll_initiative()
engine._take_turn(h)
# Paralyzed: cant_act=True → should only log "wait (cannot act)"
found_wait = False
for entry in engine._transcript:
if "cannot act" in entry:
found_wait = True
# Should not have executed any attack
assert "attack" not in entry.lower() or "cannot" in entry.lower(), (
f"paralyzed combatant should not attack: {entry}"
)
assert found_wait, "paralyzed combatant should log 'wait (cannot act)'"
def test_paralyzed_defender_helpless_ac(self) -> None:
"""Paralyzed target: denies_dex_to_ac=True (flat-footed) + -4 AC (helpless)."""
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.active_conditions.append(
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
)
result = engine.resolve_attack(h, f, hero.attacks[0])
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9
assert result.ac == 9
# ── Test 6: StatModifier condition filtering ──────────────────────────────────
class TestStatModifierConditionFiltering:
"""Given: a StatModifier with condition= set
When: the condition does not hold → modifier excluded
When: the condition holds → modifier included."""
def test_modifier_excluded_when_condition_not_held(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
# Add a modifier with condition="mounted" that doesn't hold
h.effects.append(
StatModifier(target="attack", value=5, condition="mounted")
)
result = engine.resolve_attack(h, f, hero.attacks[0])
# The +5 should NOT apply because "mounted" condition doesn't hold
# base_bonus=5, roll=10, no modifiers from condition
assert result.total == 10 + 5 # 15
def test_modifier_included_when_condition_holds(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
# Add a modifier with no condition (always active)
h.effects.append(
StatModifier(target="attack", value=3)
)
result = engine.resolve_attack(h, f, hero.attacks[0])
# base_bonus=5, roll=10, +3 from always-active modifier
assert result.total == 10 + 5 + 3 # 18
+95
View File
@@ -0,0 +1,95 @@
"""Determinism regression and class-ability action routing tests.
Every die roll in the engine flows through the injected ``Rng`` stream, never
a module-global ``random``. Two runs with the same seed must produce identical
transcripts — including a channel-energy caster that draws from the engine RNG
via ``roll_die``. The class-ability ``Action`` kinds (deed, spell_combat,
inspire_courage, hex, channel_energy) must route through ``_execute`` and read
their carrier fields without raising AttributeError.
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
CombatResult,
default_policy,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import SeededRng
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
_ONI_JSON = _DATA_DIR / "pcs" / "oni.json"
def _make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Emit a channel_energy on an alpha, otherwise the default policy."""
if state.combatant.id == "oni-a":
return (Action(kind="channel_energy", target_id="oni-b"),)
return default_policy(_engine, state)
def _make_state(combatant_id: str, side: str, pos: tuple[int, int]) -> CombatantState:
combatant = load_combatant(_ONI_JSON)
effective = combatant if combatant.id == combatant_id else combatant.model_copy(
update={"id": combatant_id}
)
return CombatantState(
combatant=effective,
side=side,
pos=pos,
hp=effective.hp_max,
resources_left=dict(effective.resources),
deed_up_close_and_deadly=False,
)
def test_channel_energy_draws_from_engine_rng_same_seed_identical_transcripts() -> None:
"""Given the same seed, two runs of a channel-energy caster have identical transcripts."""
def make_run() -> CombatResult:
states = [
_make_state("oni-a", "players", (1, 2)),
_make_state("oni-b", "monsters", (2, 4)),
]
return CombatEngine(
SeededRng(7), _make_grid(), states, policy=_make_channel_policy
).run()
result_one = make_run()
result_two = make_run()
assert result_one.transcript == result_two.transcript
assert result_one.transcript
assert any("channel" in line for line in result_one.transcript)
def test_class_ability_action_kinds_route_without_attribute_error() -> None:
"""Given class-ability actions, _execute dispatches them reading carrier fields."""
attacker = _make_state("oni-att", "players", (1, 2))
target = _make_state("oni-tgt", "monsters", (2, 4))
states = [attacker, target]
engine = CombatEngine(SeededRng(9), _make_grid(), states)
cases: list[Action] = [
Action(kind="deed", target_id="oni-tgt", deed_name="up_close_and_deadly"),
Action(kind="spell_combat", target_id="oni-tgt"),
Action(kind="inspire_courage", target_id="oni-tgt"),
Action(kind="hex", target_id="oni-tgt", hex_name="evil_eye"),
Action(kind="channel_energy", target_id="oni-tgt"),
]
for action in cases:
engine._execute(attacker, action)
+184
View File
@@ -0,0 +1,184 @@
"""Feat resolution for the 8 PC data files.
Every feature name string in each ``data/pcs/*.json`` file must resolve to a
real ``AbilitySpec`` (real effects where modeled, otherwise an attested traced
no-op) — never a silently empty spec. This test proves no feat in the 8 PCs is
silently unimplemented.
"""
from __future__ import annotations
import json
from pathlib import Path
from pf1e_simulator.abilities import (
COMBAT_REFLEXES,
MOUNTED_COMBAT,
POINT_BLANK_SHOT,
)
from pf1e_simulator.loaders.combatant import (
_KNOWN_FEATS,
_NOOP_FEATS,
_resolve_feat,
)
PC_FILES = [
"data/pcs/esha.json",
"data/pcs/harvie.json",
"data/pcs/ierlieth.json",
"data/pcs/jeanne.json",
"data/pcs/misty.json",
"data/pcs/nairda.json",
"data/pcs/oni.json",
"data/pcs/tammara.json",
]
def _pcs_features() -> set[str]:
names: set[str] = set()
for path in PC_FILES:
with Path(path).open(encoding="utf-8") as handle:
data = json.load(handle)
for feat in data.get("features", []):
if isinstance(feat, str) and feat.strip():
names.add(feat.strip())
return names
def _all_feature_names() -> list[tuple[str, str, int]]:
rows: list[tuple[str, str, int]] = []
for path in PC_FILES:
with Path(path).open(encoding="utf-8") as handle:
data = json.load(handle)
for feat in data.get("features", []):
if isinstance(feat, str) and feat.strip():
level = data.get("level", 1)
rows.append((path, feat.strip(), level if isinstance(level, int) else 1))
return rows
def test_55_unique_feature_names_across_8_pcs() -> None:
assert len(_pcs_features()) == 55
def test_no_feat_in_8_pcs_is_silently_unresolved() -> None:
"""Every feature either maps to a known spec or is an attested traced-no-op.
This is the core acceptance: no feat in the 8 JSONs is silently
unimplemented (an unmatched name would be a silent empty spec).
"""
unresolved = []
for path, name, level in _all_feature_names():
spec = _resolve_feat(name, level)
lower = name.lower()
if lower in _KNOWN_FEATS:
expected = _KNOWN_FEATS[lower].name
elif lower == "toughness":
expected = "Toughness"
elif lower.startswith("weapon focus (") or lower in _NOOP_FEATS:
expected = name
else:
unresolved.append(name)
continue
assert spec.name == expected, f"{path}: {name!r} -> {spec.name!r} != {expected!r}"
assert unresolved == [], (
f"{len(unresolved)} feats silently unresolved: {sorted(set(unresolved))}"
)
def test_spot_check_real_effects() -> None:
assert _resolve_feat("Point-Blank Shot", 1) is POINT_BLANK_SHOT
assert POINT_BLANK_SHOT.effects != ()
assert _resolve_feat("Combat Reflexes", 1) is COMBAT_REFLEXES
assert _resolve_feat("Mounted Combat", 1) is MOUNTED_COMBAT
assert MOUNTED_COMBAT.effects != ()
def test_spot_check_weapon_focus_pistol() -> None:
spec = _resolve_feat("Weapon Focus (Pistol)", 1)
assert spec.name == "Weapon Focus (Pistol)"
assert any("Pistol" in str(e.weapon_filter) for e in spec.effects)
def test_spot_check_toughness_level_tied() -> None:
spec = _resolve_feat("Toughness", 5)
assert spec.name == "Toughness"
assert any(e.value == 5 for e in spec.effects)
# ── Expected mapping table (name -> resolved display name or traced-no-op) ─────
_EXPECTED_RESOLUTION: dict[str, str] = {
"6. Vert - Yes Mistress": "6. Vert - Yes Mistress",
"Adopted": "Adopted",
"Alertness": "Alertness",
"Ambitious": "Ambitious",
"Animal (race)": "Animal (race)",
"Armor Proficiency (Light)": "Armor Proficiency (Light)",
"Armor Proficiency (Medium)": "Armor Proficiency (Medium)",
"Axe to Grind (combat)": "Axe to Grind",
"Bitter": "Bitter",
"Born Damned (race: tiefling)": "Born Damned (race: tiefling)",
"Breeze-Kissed": "Breeze-Kissed",
"Brew Potion": "Brew Potion",
"Combat Reflexes": "Combat Reflexes",
"Craft Wondrous Item": "Craft Wondrous Item",
"Dazzling Display": "Dazzling Display",
"Drake (race)": "Drake (race)",
"Eschew Materials": "Eschew Materials",
"Exalted of the Society (Cleric, Pathfinder Society)": (
"Exalted of the Society (Cleric, Pathfinder Society)"
),
"Extra Hex": "Extra Hex",
"Extremely Fashionable": "Extremely Fashionable",
"Fate's Favored": "Fate's Favored",
"Fey-touched": "Fey-touched",
"Focused Study (trait racial humain — remplace le bonus feat)": "Focused Study",
"Fortified Drinker (Cayden Cailean)": "Fortified Drinker (Cayden Cailean)",
"Friendly Face": "Friendly Face",
"Gifted Adept (dispel magic; magic)": "Gifted Adept (dispel magic; magic)",
"Gunsmithing": "Gunsmithing",
"Half-Forgotten Secrets (Dhampir; Ajibachana)": "Half-Forgotten Secrets (Dhampir; Ajibachana)",
"Harmonic Spell": "Harmonic Spell",
"Larger Than Life": "Larger Than Life",
"Like the Wind": "Like the Wind",
"Lonely": "Lonely",
"Maw or Claw (Bite) (tiefling daemon-spawn)": "Maw or Claw (Bite) (tiefling daemon-spawn)",
"Mounted Combat": "Mounted Combat",
"Orange - Lazy Ass": "Orange - Lazy Ass",
"Pingre": "Pingre",
"Point-Blank Shot": "Point-Blank Shot",
"Power Attack": "Power Attack",
"Precise Shot": "Precise Shot",
"Prehensile Tail (tiefling daemon-spawn)": "Prehensile Tail (tiefling daemon-spawn)",
"Rapid Reload (Pistol)": "Rapid Reload (Pistol)",
"Sacred Summons": "Sacred Summons",
"Scaled Skin (electricity) (tiefling daemon-spawn)": (
"Scaled Skin (electricity) (tiefling daemon-spawn)"
),
"Selective Channeling": "Selective Channeling",
"Skill Focus (Perception)": "Skill Focus (Perception)",
"Spell Vulnerability (divination; drawback)": "Spell Vulnerability (divination; drawback)",
"Stealth Synergy": "Stealth Synergy",
"Summon Good Monster": "Summon Good Monster",
"Thoroughbred": "Thoroughbred",
"Thoroughbred (Human; Keleshite)": "Thoroughbred",
"Toughness": "Toughness",
"Vagabond Child (Urban)": "Vagabond Child",
"Weapon Finesse": "Weapon Finesse",
"Weapon Focus (Pistol)": "Weapon Focus (Pistol)",
"Whispering Wind": "Whispering Wind",
}
def test_every_pc_feature_resolves_to_expected_spec() -> None:
"""The full 55-name mapping table is satisfied by the loader."""
for name, expected_name in _EXPECTED_RESOLUTION.items():
spec = _resolve_feat(name, 1)
assert spec.name == expected_name, (
f"{name!r} resolved to {spec.name!r}, expected {expected_name!r}"
)
def test_mapping_table_covers_exactly_the_8_pc_feats() -> None:
assert set(_EXPECTED_RESOLUTION) == _pcs_features()
+162
View File
@@ -0,0 +1,162 @@
"""Tests for hex DC, AoE save DC, and spell damage DR bypass (Task 5)."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import load_spell_registry
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def _make_caster(cid: str = "caster", *, level: int = 5, int_score: int = 18) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=int_score, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spell_slots={1: 4, 2: 3, 3: 2},
resources={"hexes": 3, "channel_energy": 6},
)
def _make_target(cid: str = "target", *, hp_max: int = 30) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp_max,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_engine(
caster: Combatant,
target: Combatant,
queue: list[int],
*,
caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (3, 0),
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(
combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max,
resources_left=dict(caster.resources),
)
t = CombatantState(
combatant=target, side="monsters", pos=target_pos, hp=target.hp_max,
resources_left=dict(target.resources),
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
class TestHexDcUsesInt:
"""Given: a witch with level 4, INT 21 (mod +5)
When: evil_eye hex is cast (Will save)
Then: DC = 10 + 4//2 + (21-10)//2 = 17.
Behavioral test: target has Will +2. d20=13 -> total 15.
DC 17: 15 < 17 -> fail -> shaken applied.
DC 12 (wrong, uses CHA): 15 >= 12 -> success -> no effect.
"""
def test_hex_dc_uses_int_not_cha(self) -> None:
caster = _make_caster(level=4, int_score=21)
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [13])
engine._execute_hex(c, t, "evil_eye")
assert any("shaken" in line for line in engine._transcript)
class TestAoEDcEqualsCasterSpellDc:
"""Given: a caster with INT 18 (mod +4) casting Fireball (level 3)
When: the AoE targets a secondary creature (caster self in area)
Then: the area save DC equals the caster's spell DC (17), not hardcoded 15.
Behavioral test: secondary target (caster) has ref +4.
d20=12 -> total 12+4=16. DC 17 -> 16<17 -> fail (full damage).
If DC 15 (old bug): 16>=15 -> success (half damage).
"""
def test_area_save_uses_caster_dc(self) -> None:
caster = _make_caster(level=5, int_score=18)
target = _make_target(hp_max=200)
queue = [1, 12, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target, queue)
hp_before_caster = c.hp
hp_before_target = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before_target - 18
assert c.hp == hp_before_caster - 18
class TestSpellDamageBypassesDr:
"""Given: a target with DR 5/- (bypassed by nothing)
When: a spell deals fire damage
Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR)."""
def test_dr_does_not_reduce_spell_damage(self) -> None:
caster = _make_caster(level=5, int_score=18)
target = _make_target(hp_max=200)
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()),
})
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - 18
+190
View File
@@ -0,0 +1,190 @@
"""Tests for the Lich's Fear Aura, Paralyzing Touch, and clean JSON.
Given: a Lich monster with the "Fear Aura" and "Paralyzing Touch" features.
When: it lands a Paralyzing Touch hit, or begins its turn with enemies in range.
Then: those enemies gain the paralyzed / shaken active conditions.
"""
from __future__ import annotations
import json
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.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
_DATA = Path(__file__).resolve().parents[1] / "data" / "monsters" / "lich.json"
def _make_lich() -> Combatant:
return Combatant(
id="lich",
name="Lich",
level=11,
size="Medium",
abilities=AbilityScores(
str_score=12, dex_score=14, con_score=14,
int_score=20, wis_score=14, cha_score=16,
),
hp_max=100,
ac=ACProfile(total=25, touch=14, flat_footed=23),
bab=8,
initiative_mod=6,
speed_land_ft=30,
saves=Saves(fort=10, ref=10, will=14),
attacks=[
AttackSpec(
id="lich:touch",
name="Paralyzing Touch",
kind="touch",
attack_bonus=10,
damage=[DamageComponent(formula=parse_dice("1d8+5"), types=["negative"])],
damage_bonus=5,
crit_range=20,
crit_mult=2,
reach_ft=5,
count=1,
)
],
features=[
AbilitySpec(name="Fear Aura", category="class"),
AbilitySpec(name="Paralyzing Touch", category="class"),
],
)
def _make_foe(cid: str = "foe", hp: int = 50, ac: int = 13, will: int = 0) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=12,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=will),
attacks=[
AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
crit_range=20,
crit_mult=2,
)
],
)
def _make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(
combatant=combatant, side=side, pos=pos, hp=combatant.hp_max
)
def _make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
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(queue), grid, states, round_cap=100)
# ── Paralyzing Touch ──────────────────────────────────────────────────────────
class TestParalyzingTouch:
"""Given: a touch hit lands on the target
When: the attacker has the Paralyzing Touch feature
Then: the target gains the paralyzed active condition (cant_act)."""
def test_hit_applies_paralyzed_condition(self) -> None:
lich = _make_lich()
foe = _make_foe()
lich_state = _make_state(lich, "monsters", (1, 1))
foe_state = _make_state(foe, "players", (1, 2))
engine = _make_engine(
[12, 3, 2], [lich_state, foe_state]
) # d20=12 hit (no crit confirm), 1d8+5 damage=3, 1d4=2 -> 3 rounds
engine._resolve_swing(
lich_state, foe_state, lich_state.combatant.attacks[0], label="Paralyzing Touch"
)
par = [ac for ac in foe_state.active_conditions if ac.condition.name == "paralyzed"]
assert len(par) == 1
assert par[0].rounds_left == 3
assert par[0].condition.cant_act is True
def test_miss_does_not_apply_paralyzed(self) -> None:
lich = _make_lich()
foe = _make_foe(ac=30)
lich_state = _make_state(lich, "monsters", (1, 1))
foe_state = _make_state(foe, "players", (1, 2))
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 miss (low bonus vs AC 30)
engine._resolve_swing(
lich_state, foe_state, lich_state.combatant.attacks[0], label="Paralyzing Touch"
)
assert all(ac.condition.name != "paralyzed" for ac in foe_state.active_conditions)
# ── Fear Aura ─────────────────────────────────────────────────────────────────
class TestFearAura:
"""Given: an enemy within 30 ft at the start of the Lich's turn
When: it fails its Will save (DC 22)
Then: the enemy gains the shaken active condition."""
def test_failed_save_applies_shaken(self) -> None:
lich = _make_lich()
foe = _make_foe(will=0)
lich_state = _make_state(lich, "monsters", (1, 1))
foe_state = _make_state(foe, "players", (1, 2)) # adjacent: 5 ft
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 -> fails Will DC 22
engine._apply_fear_aura(lich_state)
shaken = [ac for ac in foe_state.active_conditions if ac.condition.name == "shaken"]
assert len(shaken) == 1
assert shaken[0].condition.modifiers
def test_successful_save_does_not_apply_shaken(self) -> None:
lich = _make_lich()
foe = _make_foe(will=30)
lich_state = _make_state(lich, "monsters", (1, 1))
foe_state = _make_state(foe, "players", (1, 2))
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 + will 30 >= 22
engine._apply_fear_aura(lich_state)
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
# ── JSON integrity ────────────────────────────────────────────────────────────
class TestLichJsonIntegrity:
"""Given: the lich bestiary JSON
When: parsed
Then: it is valid, has no duplicate keys, and no legacy spell_slots_N keys."""
def test_json_is_clean(self) -> None:
raw = _DATA.read_text(encoding="utf-8")
data = json.loads(raw)
assert any(k == "spell_slots" for k in data)
assert not any("spell_slots_" in k for k in data)
assert len(data["casting_ability"]) > 0
assert data["spell_slots"]["6"] == 3
+118
View File
@@ -0,0 +1,118 @@
"""Tests for combat maneuvers, focused on the sunder result.
A successful sunder damages the defender's WEAPON, never its HP. Repeated
sunders can destroy the weapon, after which the defender can no longer use
it (effectively disarmed). See the plan task-12.
"""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
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,
SkillRank,
)
from pf1e_simulator.rng import ScriptedRng
def make_combatant(
cid: str, *, attack_bonus: int = 2, weapon_name: str = "sword"
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=6,
ac=ACProfile(total=13, touch=13, flat_footed=13),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(
queue: list[int], states: list[CombatantState]
) -> CombatEngine:
return CombatEngine(ScriptedRng(queue), make_grid(), states)
def test_sunder_damages_weapon_not_hp() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2
assert "b-w" not in b.weapon_broken
def test_sunder_destroys_weapon_and_disarms() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12, 12, 12, 12, 12], [a, b])
for _ in range(5):
engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
assert "b-w" in b.weapon_broken
assert engine.weapon_for(b, a) is None
assert any(c.condition.name == "disarmed" for c in b.active_conditions)
def test_escape_from_grapple_consumes_single_d20() -> None:
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"skills": {"escape_artist": SkillRank(rank=10)}}
)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
a.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["grappled"],
rounds_left=1,
source="test",
)
)
a.grappled_with = b_spec.id
engine = make_engine([20], [a, b])
engine._maneuver_escape(a, b)
assert not engine._rng._queue
assert a.grappled_with is None
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
+195
View File
@@ -0,0 +1,195 @@
"""Golden tests for the 8 PCs - deterministic combats with pinned transcripts."""
from pathlib import Path
import pytest
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import load_spell_registry
PCS_DIR = Path(__file__).resolve().parent.parent / "data" / "pcs"
SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def make_grid():
"""Standard 20x20 open arena."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 20] * 20), legend=legend)
return Grid.from_spec(spec)
def _build_queue():
"""Return the fixed ScriptedRng queue used by every golden test."""
init_rolls = [15, 10]
combat_rolls = []
for _ in range(100):
combat_rolls.extend([10, 15])
return init_rolls + combat_rolls
def run_golden(
pc_name: str, monster_name: str = "goblin", seed: int = 42, rounds: int = 10
) -> tuple:
"""Run a deterministic combat and return (result, transcript).
The queue is designed so PC wins initiative (first roll higher than monster's
by at least init_mod difference).
"""
pc = load_combatant(Path(f"{PCS_DIR}/{pc_name}.json"))
monster = load_combatant(Path(f"{DATA_DIR}/monsters/{monster_name}.json"))
spell_reg = load_spell_registry(SPELLS_DIR)
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
)
rng = ScriptedRng(_build_queue())
engine = CombatEngine(
rng, make_grid(), [pc_state, monster_state], round_cap=10, spell_registry=spell_reg
)
result = engine.run()
return result, engine._transcript
class TestGoldenCombats:
"""Golden tests for each PC vs goblin - deterministic combats with pinned transcripts."""
def test_tammara_vs_goblin(self):
"""Tammara (Gunslinger/Warpriest) vs Goblin — full pinned transcript."""
result, _ = run_golden("tammara")
assert result.winner is None
assert result.rounds == 10
assert result.transcript == (
"initiative: tammara d20=15+7=22",
"initiative: goblin d20=10+6=16",
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(36->36)"),
"round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(36->36)"),
"round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(36->36)"),
"round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 5 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 6 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 6 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 7 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 7 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 8 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 8 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"round 9 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS",
"round 9 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 "
"-> MISS"),
"round 10 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
"battle over: draw after 10 rounds",
)
def test_tammara_vs_lich(self):
"""Tammara vs Lich (boss fight) — full pinned transcript."""
result, _ = run_golden("tammara", "lich")
assert result.winner == "monsters"
assert result.rounds == 6
assert result.transcript == (
"initiative: tammara d20=15+7=22",
"initiative: lich d20=10+6=16",
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(36->36)"),
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (36->20)",
("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(20->20)"),
"round 2 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)",
"round 3 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)",
"round 3 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 11 damage (20->9)",
"round 4 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +11 HP (9->20)",
"round 4 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)",
"round 5 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)",
"round 5 lich: move (10,10)->(9,9)->(8,8)->(7,7)->(6,6)->(5,5)->(4,4)",
("round 6 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
"(20->20)"),
"round 6 lich: fear aura on tammara -> SAVED",
"round 6 lich: move (4,4)->(3,3)->(2,2)->(1,1)",
("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 20 -> HIT 25 damage "
"(20->-5) paralyzed (11r)"),
"round 6 lich: tammara down",
"battle over: monsters win in 6 rounds",
)
def _assert_deterministic_goblin(self, pc_name: str):
"""Run the same PC vs goblin twice and assert identical outcomes."""
r1, t1 = run_golden(pc_name)
r2, t2 = run_golden(pc_name)
assert r1.winner == r2.winner
assert t1 == t2
def test_esha_vs_goblin(self):
"""Esha (Luring Cavalier/Phantom Thief) vs Goblin."""
result, transcript = run_golden("esha")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("esha")
def test_nairda_vs_goblin(self):
"""Nairda (Witch/Sorcerer) vs Goblin - draw under the fixed queue."""
result, transcript = run_golden("nairda")
assert result.winner is None
assert result.rounds == 10
self._assert_deterministic_goblin("nairda")
def test_misty_vs_goblin(self):
"""Misty (Magus/Bard) vs Goblin."""
result, transcript = run_golden("misty")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("misty")
def test_oni_vs_goblin(self):
"""Oni (Cleric/Shifter) vs Goblin."""
result, transcript = run_golden("oni")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("oni")
def test_harvie_vs_goblin(self):
"""Harvie (Animal Companion) vs Goblin - the goblin wins."""
result, transcript = run_golden("harvie")
assert result.winner == "monsters"
assert result.rounds >= 1
self._assert_deterministic_goblin("harvie")
def test_ierlieth_vs_goblin(self):
"""Ierlieth (Court Drake) vs Goblin."""
result, transcript = run_golden("ierlieth")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("ierlieth")
def test_jeanne_vs_goblin(self):
"""Jeanne (Animal Companion Bodyguard) vs Goblin."""
result, transcript = run_golden("jeanne")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("jeanne")
class TestAllPCsLoad:
"""Verify all 8 PCs load without errors."""
@pytest.mark.parametrize("pc_name", [
"esha", "tammara", "nairda", "misty", "oni", "harvie", "ierlieth", "jeanne"
])
def test_pc_loads(self, pc_name):
pc = load_combatant(Path(f"data/pcs/{pc_name}.json"))
assert pc.name
assert pc.level == 4 or pc.name == "Harvie" # Harvie is level 2
assert pc.hp_max > 0
assert pc.ac.total > 0
assert pc.bab >= 0
assert len(pc.attacks) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from pf1e_simulator.abilities import POWER_ATTACK
from pf1e_simulator.abilities import POWER_ATTACK, AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
@@ -24,7 +24,7 @@ def _make_fighter(
bab: int = 4,
attack_bonus: int = 5,
damage_bonus: int = 2,
features: list[object] | None = None,
features: list[AbilitySpec] | None = None,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
@@ -50,7 +50,7 @@ def _make_fighter(
speed_land_ft=30,
saves=Saves(fort=6, ref=2, will=2),
attacks=[attack],
features=features or [], # type: ignore[arg-type]
features=features or [],
)
+66
View File
@@ -0,0 +1,66 @@
"""Wiring tests: the Monte Carlo runner loads the real spell registry and
spellcasting PCs cast against ``data/spells/``.
The engine's ``spell_registry`` defaults to ``{}`` for retro-compat with pure
unit tests; the runner must therefore load the registry from ``SPELLS_DIR`` and
pass it into every ``CombatEngine`` so a caster PC actually produces
``cast_spell`` actions in Monte Carlo.
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders import load_combatant
from pf1e_simulator.map import load_map
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.runner import EncounterSpec, Side, build_states
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
ONI = ROOT / "data" / "pcs" / "oni.json"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
_NUM_SPELLS = 11
def test_runner_loads_realtime_spell_registry() -> None:
"""The runner's registry source holds all 11 real spells from data/spells/."""
registry = load_spell_registry(SPELLS_DIR)
assert len(registry) == _NUM_SPELLS
def test_monte_carlo_caster_pc_casts_spell() -> None:
"""oni (cleric) casts against data/spells/ once it drops below 50% HP.
oni's known spells overlap the registry's heal spells (Cure Light/Moderate
Wounds); with enough damage taken it heals via cast_spell. The Monte Carlo
loop mirrors run_encounter: build fresh states and one CombatEngine per run
with the registry from SPELLS_DIR.
"""
spec = EncounterSpec(
map=load_map(MAP),
round_cap=100,
sides=(
Side(name="players", combatants=(load_combatant(ONI),), zone="A"),
Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"),
),
)
registry = load_spell_registry(SPELLS_DIR)
grid = Grid.from_spec(spec.map)
casts = 0
for i in range(100):
states = build_states(spec)
rng = SeededRng(7 + i)
result = CombatEngine(
rng, grid, states, round_cap=spec.round_cap, spell_registry=registry
).run()
casts += sum(
1
for line in result.transcript
if " vs " in line and "cast " in line
)
assert casts > 0
+119 -36
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
@@ -19,6 +19,7 @@ from pf1e_simulator.models import (
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
BuffEffect,
ConditionEffect,
DamageEffect,
@@ -56,6 +57,7 @@ def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
)
@@ -92,7 +94,7 @@ def _make_engine(
queue: list[int],
*,
caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (1, 1),
target_pos: tuple[int, int] = (3, 0),
spell_registry: dict[str, SpellSpec] | None = None,
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
@@ -106,13 +108,13 @@ def _make_engine(
class TestSpellLoading:
"""Given: data/spells/ directory with 10 JSON spell files
"""Given: data/spells/ directory with 11 JSON spell files
When: load_spell_registry is called
Then: all 10 spells are loaded with correct fields."""
Then: all 11 spells are loaded with correct fields."""
def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 10
assert len(reg) == 11
def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
@@ -120,6 +122,7 @@ class TestSpellLoading:
"Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt",
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II",
}
assert set(reg) == expected
@@ -131,9 +134,15 @@ class TestSpellLoading:
assert spell.range == "long"
assert spell.save == "ref"
assert spell.base_dc == 10
assert len(spell.effects) == 1
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is True
assert isinstance(spell.effects[0], AreaEffect)
area_eff = spell.effects[0]
assert isinstance(area_eff, AreaEffect)
assert area_eff.radius_ft == 20
assert area_eff.save_for_half is True
assert len(area_eff.effects) == 1
inner = area_eff.effects[0]
assert isinstance(inner, DamageEffect)
assert inner.half_on_save is True
def test_hold_person_has_condition_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "hold_person.json")
@@ -210,7 +219,8 @@ class TestFireballSave:
def test_failed_save_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [2, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
@@ -218,7 +228,8 @@ class TestFireballSave:
def test_successful_save_half_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 3 + 3 + 3 + 3 + 3 + 3
@@ -227,11 +238,13 @@ class TestFireballSave:
def test_transcript_shows_saved(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball")
assert any("SAVED" in line for line in engine._transcript)
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)
When: target fails save
@@ -242,14 +255,14 @@ class TestHoldPerson:
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Hold Person")
assert any(cond.name == "paralyzed" for cond in t.conditions)
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_successful_save_no_condition(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Hold Person")
assert not any(cond.name == "paralyzed" for cond in t.conditions)
assert not any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_transcript_shows_negated_on_save(self) -> None:
caster = _make_caster()
@@ -269,7 +282,7 @@ class TestFear:
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Fear")
assert any(cond.name == "frightened" for cond in t.conditions)
assert any(ac.condition.name == "frightened" for ac in t.active_conditions)
class TestCureLightWounds:
@@ -280,7 +293,7 @@ class TestCureLightWounds:
def test_heals_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [20, 5])
engine, c, t = _make_engine(caster, target, [3, 3, 5], target_pos=(1, 0))
t.hp = 10
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 10 + (5 + 1)
@@ -288,7 +301,7 @@ 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, [20, 6])
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0))
t.hp = 28
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 30
@@ -302,10 +315,11 @@ class TestBullsStrength:
def test_applies_buff_effects(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0))
engine._cast_spell(c, t, "Bull's Strength")
atk_mods = [e for e in t.effects if e.target == "attack"]
dmg_mods = [e for e in t.effects if e.target == "damage"]
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"]
dmg_mods = [e for e in buff_mods if e.target == "damage"]
assert len(atk_mods) == 1
assert len(dmg_mods) == 1
assert atk_mods[0].value == 1
@@ -314,7 +328,7 @@ class TestBullsStrength:
def test_transcript_shows_buff(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0))
engine._cast_spell(c, t, "Bull's Strength")
assert any("buff" in line for line in engine._transcript)
@@ -366,33 +380,102 @@ class TestSpellDispatchViaExecute:
)
assert t.hp < hp_before
class TestSpellDcScalesWithLevel:
"""Given: a caster with level N
When: a spell with base_dc 10 is cast
Then: the DC is 10 + N."""
"""Given: a caster with level N and casting ability mod M
When: a spell of level L is cast
Then: the DC is 10 + L + M."""
def test_dc_is_base_plus_level(self) -> None:
caster = _make_caster(level=5)
def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
caster = _make_caster(level=5) # int 18 -> mod +4
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1])
engine._cast_spell(c, t, "Fireball")
assert any("DC15" in line for line in engine._transcript)
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball") # fireball is level 3
# DC = 10 + spell_level(3) + int_mod(4) = 17
assert any("DC17" in line for line in engine._transcript)
class TestSpellDamageWithDr:
"""Given: a target with DR vs the spell's damage type
"""Given: a target with DR 5/- (bypassed by nothing)
When: a spell deals damage
Then: DR is applied after save halving."""
Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR)."""
def test_dr_reduces_spell_damage(self) -> None:
def test_dr_does_not_reduce_spell_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# DR 5/- does NOT apply to spell damage
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset({"bludgeoning"})),
"dr": DamageReduction(amount=5, bypass=frozenset()),
})
engine, c, t = _make_engine(caster, target_with_dr, [2, 6, 6, 6, 6, 6, 6])
# 2 save rolls (2 targets in area) + 6 damage dice
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 6 * 6
assert t.hp == hp_before - max(0, full - 5)
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage
assert t.hp == hp_before - 18
class TestSummonMonster:
"""Given: a caster casting Summon Monster II
When: _cast_spell is called
Then: a celestial wolf is summoned adjacent to the caster."""
def test_summon_creature(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = len(engine._states)
engine._cast_spell(c, t, "Summon Monster II")
# Caster + target + summoned = 3 combatants
assert len(engine._states) == hp_before + 1
# Find the summoned creature
summoned = [s for s in engine._states if s.combatant.id == "celestial_wolf"]
assert len(summoned) == 1
assert summoned[0].side == "players"
# Check it has correct HP
assert summoned[0].hp == 15
# Check transcript mentions summon
assert any("summon" in line.lower() for line in engine._transcript)
class TestSpellSlots:
"""Given: a caster with per-level spell_slots
When: a spell of a level is cast
Then: that level's slot count decrements and exhausted levels block further casts."""
def test_slot_depense(self) -> None:
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
engine._cast_spell(c, t, "Magic Missile") # level 1
assert c.spell_slots_left[1] == 1
assert c.spell_slots_left[2] == 1
def test_slots_epuises(self) -> None:
caster = _make_caster().model_copy(
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
)
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
assert _choose_spell_action(engine, c, t) is not None
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
assert c.spell_slots_left[1] == 0
assert _choose_spell_action(engine, c, t) is None
class TestActiveIsSingleSource:
"""Given: active_* is the single source of truth for conditions and buffs
When: _cast_spell applies a condition and a buff
Then: the legacy conditions/effects lists stay empty while the active_* lists are populated."""
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._cast_spell(c, t, "Hold Person")
engine._cast_spell(c, t, "Bull's Strength")
assert t.conditions == []
assert t.effects == []
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
assert any(b.source.startswith("spell:") for b in t.active_buffs)
+262
View File
@@ -0,0 +1,262 @@
"""Tests for summoning: summoned creatures act in combat and expire after duration."""
from __future__ import annotations
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
default_policy,
)
from pf1e_simulator.dice import parse_dice
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 SeededRng
from pf1e_simulator.spells import SpellSpec, SummonEffect
def _make_caster() -> Combatant:
attack = AttackSpec(
id="caster-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id="caster",
name="caster",
level=5,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=50,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spells=["Summon Monster II"],
spell_slots={1: 3, 2: 3},
)
def _make_enemy() -> Combatant:
attack = AttackSpec(
id="enemy-w",
name="club",
kind="melee",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id="enemy",
name="enemy",
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=200,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_summon_spell(duration_rounds: int = 2) -> SpellSpec:
return SpellSpec(
name="Summon Monster II",
level=2,
school="conjuration",
range="close",
save="none",
base_dc=10,
effects=(
SummonEffect(
creature="data/monsters/celestial_wolf.json",
duration_rounds=duration_rounds,
),
),
)
def _summon_policy(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
"""Force the caster to summon on first turn with slots available."""
if (
state.combatant.id == "caster"
and state.combatant.spells
and state.spell_slots_left.get(2, 0) > 0
):
target = engine.nearest_enemy(state)
if target is not None:
return (
Action(
kind="cast_spell",
target_id=target.combatant.id,
spell_name="Summon Monster II",
),
)
return default_policy(engine, state)
def _build_engine(seed: int, duration_rounds: int = 2) -> CombatEngine:
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(
combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max,
)
e = CombatantState(
combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max,
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
return CombatEngine(
SeededRng(seed), grid, [c, e],
policy=_summon_policy,
spell_registry=reg,
)
class TestSummonedCreatureActs:
"""Given: a caster who summons a celestial wolf
When: combat runs for several rounds
Then: the summoned creature takes at least one turn (acts)."""
def test_summoned_creature_acts_in_combat(self) -> None:
engine = _build_engine(seed=42)
result = engine.run()
wolf_lines = [
line for line in result.transcript if "celestial_wolf" in line
]
action_keywords = (
"charge", "attack", "move", "full_attack", "bite",
"5-foot", "wait",
)
action_lines = [
line for line in wolf_lines
if any(kw in line for kw in action_keywords)
]
assert len(action_lines) >= 1, (
f"Summoned creature never acted. All wolf lines: {wolf_lines}"
)
def _build_engine_with_walls(seed: int = 42, duration_rounds: int = 2) -> CombatEngine:
"""Caster at (1,1) under a walled row 0, like sample_arena's border walls.
The spawn search must skip wall cells and pick the first passable one.
"""
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(
combatant=caster, side="players", pos=(1, 1), hp=caster.hp_max,
)
e = CombatantState(
combatant=enemy, side="monsters", pos=(9, 1), hp=enemy.hp_max,
)
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None),
}
terrain = tuple(
"#" * 12 if r == 0 else "#..........." if r == 1 else "." * 12
for r in range(12)
)
spec = MapSpec(name="test", terrain=terrain, legend=legend)
grid = Grid.from_spec(spec)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
return CombatEngine(
SeededRng(seed), grid, [c, e],
policy=_summon_policy,
spell_registry=reg,
)
class TestSummonSpawnsOnPassableCell:
"""Given: a caster whose adjacent ring contains wall cells (arena border)
When: a summons resolves
Then: the creature spawns on a passable cell and acts (not just waits)."""
def test_summon_spawns_on_passable_cell_and_acts(self) -> None:
engine = _build_engine_with_walls(seed=42)
result = engine.run()
wolf_lines = [
line for line in result.transcript if "celestial_wolf" in line
]
spawn_line = next(
(ln for ln in wolf_lines if "summon celestial_wolf at" in ln),
None,
)
assert spawn_line is not None, (
f"Wolf was never summoned. Wolf lines: {wolf_lines}"
)
# "summon celestial_wolf at (r, c) (2r)" — parse the spawn position.
pos_str = spawn_line.split("at (")[1].split(")")[0]
spawn_pos = tuple(int(part) for part in pos_str.split(", "))
assert engine._grid.passable(spawn_pos), (
f"Wolf spawned on impassable cell {spawn_pos}: {spawn_line!r}"
)
action_keywords = (
"charge", "attack", "move", "full_attack", "bite", "5-foot",
)
acted_lines = [
line for line in wolf_lines
if any(kw in line for kw in action_keywords)
]
assert len(acted_lines) >= 1, (
f"Wolf never acted (only waited). Wolf lines: {wolf_lines}"
)
class TestSummonedCreatureExpires:
"""Given: a caster who summons with duration 2
When: enough rounds pass
Then: the summoned creature expires and the transcript logs it."""
def test_summoned_creature_expires_after_duration(self) -> None:
engine = _build_engine(seed=42, duration_rounds=2)
result = engine.run()
expire_lines = [
line for line in result.transcript
if "summon expired" in line.lower()
]
summon_lines = [
ln for ln in result.transcript
if "celestial" in ln.lower() or "summon" in ln.lower()
]
assert len(expire_lines) >= 1, (
f"Summoned creature never expired. "
f"Transcript excerpt: {summon_lines}"
)
wolf_states = [
s for s in engine._states if s.combatant.id == "celestial_wolf"
]
if wolf_states:
assert not wolf_states[0].active, (
"Wolf should be inactive after expiration"
)