Compare commits
36 Commits
83a5250ca6
...
9819c3bda9
| Author | SHA1 | Date | |
|---|---|---|---|
| 9819c3bda9 | |||
| fc62a19c9e | |||
| 114ec20866 | |||
| 9ffae1a7a2 | |||
| f155146225 | |||
| 9f9bd27ca8 | |||
| 364e28e2c0 | |||
| 70d253f05b | |||
| c01a3c1e89 | |||
| 4921db7b28 | |||
| 57fcd2ac12 | |||
| 7a00911c55 | |||
| 293f20c98a | |||
| d99cd30309 | |||
| fb413d76a8 | |||
| 90b253185c | |||
| febfb76e63 | |||
| 7065d1f990 | |||
| aa711c4023 | |||
| 7336109ab2 | |||
| bc5bf918b5 | |||
| 46ac13a91e | |||
| ba2167526a | |||
| b300a413be | |||
| df624cc041 | |||
| b5c15ae60b | |||
| 03e0d6ecb6 | |||
| 7e1aa25bc1 | |||
| cd09aa1087 | |||
| 842d5aee7e | |||
| 223e10b88e | |||
| 67df26e042 | |||
| d3f7f27c51 | |||
| 1c039ff4f1 | |||
| 6e27a2bba1 | |||
| 8b5ba12f46 |
@@ -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.)
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
"ref": 10,
|
||||
"will": 5
|
||||
},
|
||||
"casting_ability": "int",
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"attacks": [
|
||||
{
|
||||
"name": "Lance",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
"ref": 7,
|
||||
"will": 9
|
||||
},
|
||||
"casting_ability": "wis",
|
||||
"spell_slots": {
|
||||
"1": 4,
|
||||
"2": 3
|
||||
},
|
||||
"resources": {
|
||||
"channel_energy": 6
|
||||
},
|
||||
"attacks": [
|
||||
{
|
||||
"name": "Longbow",
|
||||
|
||||
@@ -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,10 +7,17 @@
|
||||
"base_dc": 10,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "6d6",
|
||||
"types": ["fire"],
|
||||
"half_on_save": true
|
||||
"type": "area",
|
||||
"radius_ft": 20,
|
||||
"save_for_half": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "damage",
|
||||
"formula": "6d6",
|
||||
"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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+1598
-103
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,6 +20,8 @@ from typing import TYPE_CHECKING, Literal
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from pf1e_simulator.conditions import Condition
|
||||
|
||||
BonusType = Literal[
|
||||
"", # untyped — stacks with everything
|
||||
"dodge", # stacks with itself
|
||||
@@ -41,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"})
|
||||
@@ -65,6 +78,40 @@ class StatModifier:
|
||||
weapon_filter: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveBuff:
|
||||
"""A temporary buff with a remaining duration in rounds.
|
||||
|
||||
- ``modifiers``: the stat modifiers this buff provides.
|
||||
- ``rounds_left``: how many more rounds this buff lasts (decremented at
|
||||
the start of the affected creature's turn). When it reaches 0, the buff
|
||||
expires and is removed.
|
||||
- ``source``: a human-readable description of what created this buff
|
||||
(e.g. "spell: bull's strength", "charge", "class ability: inspire courage").
|
||||
"""
|
||||
|
||||
modifiers: tuple[StatModifier, ...]
|
||||
rounds_left: int
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveCondition:
|
||||
"""A temporary condition with a remaining duration in rounds.
|
||||
|
||||
- ``condition``: the Condition definition (modifiers + behavioral flags).
|
||||
- ``rounds_left``: how many more rounds this condition lasts (decremented at
|
||||
the start of the affected creature's turn). When it reaches 0, the condition
|
||||
expires and is removed.
|
||||
- ``source``: a human-readable description of what created this condition
|
||||
(e.g. "spell: hold person", "monster ability: fear aura").
|
||||
"""
|
||||
|
||||
condition: Condition
|
||||
rounds_left: int
|
||||
source: str
|
||||
|
||||
|
||||
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
|
||||
"""Sum stat modifiers applying PF1e stacking rules.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+143
-32
@@ -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]
|
||||
)
|
||||
for m in cast("list[dict[str, Json]]", data["modifiers"])
|
||||
),
|
||||
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type]
|
||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||
)
|
||||
if effect_type == "area":
|
||||
return AreaEffect(
|
||||
radius_ft=_as_int(effect.get("radius_ft", 20)),
|
||||
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||
save_for_half=bool(effect.get("save_for_half", True)),
|
||||
)
|
||||
if effect_type == "summon":
|
||||
return SummonEffect(
|
||||
creature=_as_str(effect["creature"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
)
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
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", "")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user