Compare commits

...

23 Commits

Author SHA1 Message Date
ctan cd796ecffb docs: document LLM observation contract and expanded action options 2026-09-06 11:14:18 +02:00
ctan e75ab58055 feat(combat): add serializable Observation snapshot and LLMPolicy adapter 2026-09-06 11:07:30 +02:00
ctan 7843756de2 feat(combat): expose charge failure reasons via ChargeResult 2026-09-06 10:47:36 +02:00
ctan 56c8970d5b feat(combat): expose threatened squares and movement cost fields for LLM policy
Rename _threatened_squares -> threatened_squares and
_enemies_threatening -> enemies_threatening (public read-only API for a
future LLM policy), and add reachable_ft(state, budget) and
movement_cost_ft(state, target) accessors returning movement costs in
feet. Pure exposure: no behavior change, resolution engine untouched,
goldens bit-identical.
2026-09-06 10:36:48 +02:00
ctan 9c49f85af8 feat(combat): resolve all natural melee weapons in full_attack 2026-09-06 10:22:58 +02:00
ctan fdcd103e8a feat(combat): add repair_weapon action to fix broken weapons 2026-09-06 10:05:55 +02:00
ctan 8e957c9fac feat(combat): add retrieve_weapon action to recover stolen weapons 2026-09-06 10:01:07 +02:00
ctan b640e29a91 feat(combat): add trick_condition field to Action for dirty trick 2026-09-05 18:49:56 +02:00
ctan 7fbf3988e9 feat(combat): add direction field to Action for directional maneuvers 2026-09-05 18:37:26 +02:00
ctan febd408f29 docs: document B5 engine hardening - reach, sizes, elevation, immediate actions 2026-09-05 18:04:35 +02:00
ctan 66b6d1f387 feat(combat): support off-turn immediate actions via reaction policy 2026-09-05 17:03:51 +02:00
ctan ab99ab67e3 feat(grid): apply elevation to movement cost and high ground 2026-09-05 16:18:56 +02:00
ctan 30f8905a3e feat(combat): support multi-cell occupancy for large and larger creatures 2026-09-05 16:01:28 +02:00
ctan 7f57df16a6 feat(combat): support variable melee reach for weapons and creature size 2026-09-05 15:10:21 +02:00
ctan 7cb3653750 fix(combat): apply save negates to targeted spell damage effects 2026-09-05 12:52:58 +02:00
ctan cd75a4f2bf feat(combat): implement overrun, dirty trick, steal, reposition and drag maneuvers 2026-09-05 12:37:06 +02:00
ctan 2f2acd992f fix(combat): evaluate conditional stat modifiers with action context 2026-09-05 12:17:58 +02:00
ctan 31d970dd5f fix(combat): resolve touch attacks against touch AC and firearms 2026-09-05 11:28:46 +02:00
ctan 8e2753e67a chore: final green gate 2026-09-05 09:09:20 +02:00
ctan 2929045e4b feat(data): add Thrune Agent monster 2026-09-05 00:33:07 +02:00
ctan 22d76e9677 fix(combat): channel energy targets living vs undead 2026-09-05 00:16:17 +02:00
ctan 7591614460 feat(cli): add --transcript option 2026-09-04 23:34:12 +02:00
ctan cedff5b932 feat(data): add giant belostomatid monster 2026-09-04 23:22:38 +02:00
37 changed files with 6287 additions and 340 deletions
+135 -50
View File
@@ -166,7 +166,7 @@ legend: # un caractère = un type de terrain
"~": { type: water, move_cost: 2 }
"C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert
elevation: | # optionnel : chiffres = hauteur (validé, pas encore appliqué)
elevation: | # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground)
....................
..1111..............
..1111..............
@@ -232,10 +232,11 @@ ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA.
}
```
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) :
incrément de portée en pieds. La pénalité de portée vaut −2 par incrément
complet au-delà du premier ; l'arme reste utilisable jusqu'à 10 incréments,
au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
`kind: touch`) : incrément de portée en pieds. La pénalité de portée vaut −2
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
### Fiches de personnages (PJ)
@@ -272,7 +273,8 @@ Règles modélisées :
`default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)`
si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
(hors-tour, consomment le prochain swift) ne sont pas encore modélisées.
(hors-tour, consomment le prochain swift) sont modélisées via une politique
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
@@ -280,11 +282,34 @@ Règles modélisées :
attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1)
ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque
à outrance ne sont pas modélisées.
- Mêlée : allonge d'une case ; mouvement : un déplacement parcourt jusqu'à la
- Mêlée : allonge effective = max(allonge naturelle du gabarit (Small/Medium
5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft`
de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à
distance égale à son allonge — les cases à 5 ft sont une « dead zone » où
elle ne frappe pas ; une arme sans allonge menace les cases adjacentes.
Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
l'ennemi le plus proche — les combattants contournent les murs au lieu
d'osciller contre eux.
- Tailles et occupation multi-cases : une créature occupe un footprint carré à
son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3,
Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne
d'effet/couvert, flanquement, zones menacées, AoO, charge, mouvement,
manœuvres et invocation sont généralisés aux cellules occupées (distance =
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
reste bit-identique.
- Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par
niveau (la descente est gratuite) ; une créature située plus haut que sa
cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de
critique (règle du high ground). La ligne d'effet reste 2D (pas de LoS 3D).
- Actions immédiates hors-tour : politique de réaction optionnelle
`reaction_policy(engine, state, event)` (défaut : aucune) ; sur événement
(`attack_resolved`, `movement_step`, `spell_cast`, `damage_taken`) une
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
événement — et consomme le prochain swift de l'état (immédiate pendant le
tour : exécutée, consomme aussi le prochain swift — simplification
documentée).
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée.
@@ -297,7 +322,9 @@ Règles modélisées :
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
pas pour le flanquement.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une
les cases à distance de son allonge effective (les 8 cases adjacentes pour
une arme sans allonge ; l'anneau à son allonge pour une arme reach — la
dead zone de 5 ft n'est pas menacée). Deux déclencheurs sont modélisés : (1) quitter une
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
@@ -328,13 +355,31 @@ Règles modélisées :
portée.
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
(5 incréments max) ne sont pas distinguées des armes à projectiles.
les armes ranged restent utilisables jusqu'à 10 incréments, les armes touch
avec incrément (armes de jet, firearms) jusqu'à 5.
- Touch AC et armes à feu : les attaques `kind: touch` résolvent leur touche
contre la CA de contact de la cible (`ac.touch`) ; si la cible perd son bonus
de Dextérité à la CA (flat-footed, `denies_dex_to_ac`), la CA de contact est
amputée du modificateur de Dextérité. Les armes marquées `firearm: true`
(pistolets) résolvent contre la CA de contact uniquement dans leur premier
incrément de portée — au-delà, elles frappent la CA totale. Une attaque touch
avec incrément est une attaque à distance : elle provoque des attaques
d'opportunité depuis une case menacée, subit −4 si l'attaquant est à plat
ventre et bénéficie du couvert mou ; un contact de mêlée (sans incrément,
ex. le contact paralysant du liche) n'en fait rien.
- Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will.
1 naturel = échec automatique, 20 naturel = succès automatique. Les
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).
- Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué
que si la condition tient au moment de la résolution (`_modifier_condition_holds`) :
`target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom
contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`,
`electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les
descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts,
conditions de peur, écoles enchantement/divination) et les effets de zone. Toute
condition inconnue ou non supportée reste exclue (False).
- 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
@@ -345,7 +390,11 @@ Règles modélisées :
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
(+4 avec le don Combat Casting). Un sort ciblé avec sauvegarde fait pleins dégâts
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
pour les explosions). `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`
@@ -358,14 +407,25 @@ Règles modélisées :
`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 ;
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) :
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
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
`disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via
`Action(kind="repair_weapon")`. overrun →
`prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick →
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de
mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
`Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la
cible vers le premier voisin libre (direction sélectionnable via
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant
occupant sa case d'origine. `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
@@ -381,29 +441,36 @@ Règles modélisées :
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`
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive :
si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
modélisé), 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
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), 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.
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
JSON) figée — champs par combattant (id, side, pos, occupied_cells,
hp/hp_max, active/dead, conditions noms+durées, resources_left,
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack),
par paire combattant actif × cible (distance_ft, weapon_for, range_penalty,
can_charge, can_step5_to_attack, has_line_of_effect, flanking) et couches
terrain/élévation de la grille. Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
`can_charge` → `ChargeResult`, zones menacées `threatened_squares`/
`enemies_threatening`, coûts `reachable_ft`/`movement_cost_ft`, kinds
`retrieve_weapon`/`repair_weapon`. Une politique LLM se branche via
`CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, state) ->
tuple[Action, ...]`) ; le snapshot est la SEULE source d'info de la LLM
(aucune référence `engine.*` exposée, collections copiées/figées).
Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) :
- 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).
Non modélisé : la couche `markers` (points nommés de la carte) est validée à
la lecture mais non appliquée dans la résolution.
## Architecture
@@ -411,19 +478,28 @@ dans la résolution) :
- `rng.py` — `SeededRng` (flux reproductible par graine) et `ScriptedRng`
(séquence de dés scriptée pour les tests de transcripts).
- `models.py` — schémas Pydantic v2 : `Combatant`, `AbilityScores`,
`ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction`.
`ACProfile`, `AttackSpec` (avec `firearm` pour les armes touch à feu),
`DamageComponent`, `Saves`, `DamageReduction`.
- `map.py` — `MapSpec`/`TerrainType`, chargement YAML et validation
(dimensions, caractères connus, zones, déploiement, markers).
- `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins
(`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget.
- `grid.py` — grille 5-10-5 : `distance`, `step_cost` (terrain + élévation),
règle des coins (`diagonal_allowed`), Dijkstra `reachable` avec ou sans
budget.
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert +4 CA (terrain et couvert mou
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,
incantation (emplacements, concentration par situation, AoO d'incantation),
invocation, manœuvres, capacités de classe, transcripts, politique par
défaut.
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
pour les attaques touch et firearms), tailles et allonges (occupation
multi-cases, zones menacées en anneau), high ground, actions immédiates
opt-in (réactions hors-tour), états de vie, incantation
(emplacements, concentration par situation, AoO d'incantation), invocation,
manœuvres (avec sélection directionnelle et choix de condition d'état),
capacités de classe, transcripts, politique par défaut, snapshot
d'observation sérialisable `Observation`/`LLMPolicy` (frontière LLM). Les
modificateurs conditionnés (`StatModifier.condition`) sont évalués via
`_modifier_condition_holds` avec le contexte d'action (arme, portée,
descripteurs de sauvegarde) ; les conditions inconnues restent exclues.
- `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,
@@ -452,12 +528,17 @@ dans la résolution) :
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 (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`.
`load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` :
résolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde
réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si
l'effet déclare `half_on_save` ; 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 +
@@ -474,7 +555,7 @@ dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 472 tests
uv run pytest -q # 664 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
@@ -493,10 +574,14 @@ uv run basedpyright src # mode strict
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.
« Règles implémentées ». Le socle B5 est en place : hauteurs/élévations
(coût de déplacement + high ground), tailles Large+ (occupation multi-cases),
allonges > 5 ft (allonge naturelle + `reach_ft`, zones menacées en anneau) et
actions immédiates hors-tour (politique de réaction opt-in). Restent hors du
socle (voir « Non modélisé ») : les markers.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. (Non implémentée.)
en politiques, balayage de matrices de positionnement. Le contrat
d'observation (snapshot sérialisable `Observation` + wrapper `LLMPolicy`)
est en place ; la couche LLM elle-même reste à implémenter.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
rédaction du rapport d'équilibrage en français. (Non implémentée.)
+2 -2
View File
@@ -20,8 +20,8 @@ legend:
elevation: |
....................
..1111..............
..1111..............
....................
....................
....................
....................
....................
+50
View File
@@ -0,0 +1,50 @@
name: "Elevation Test"
square_size_ft: 5
terrain: |
############
#..........#
#..........#
#..........#
#..........#
#..........#
#..........#
#..........#
#..........#
############
legend:
"#": { type: wall, move_cost: null, blocks_los: true }
".": { type: floor, move_cost: 1 }
elevation: |
............
............
..0000......
..0000......
............
............
......1111..
......1111..
............
............
zones: |
............
............
..AAAA......
..AAAA......
............
............
......BBBB..
......BBBB..
............
............
markers:
ground_center: [5, 5]
platform_center: [6, 8]
deployment:
players: A
monsters: B
+46
View File
@@ -0,0 +1,46 @@
{
"name": "Giant Belostomatid",
"level": 3,
"size": "Large",
"cr": "3",
"xp": 800,
"source": "Bestiary > Giant Belostomatid",
"notes": "Digestive enzyme (DC 15 Fort or paralyzed 1d4 rounds + 1d2 Con damage/round) and grab on claws not modeled in Phase 0; Int - (mindless) modeled as 0; immune to mind-affecting not modeled; swim 40 ft / fly 20 ft (poor) not modeled, land speed only.",
"abilities": {
"str_score": 15,
"dex_score": 13,
"con_score": 16,
"int_score": 0,
"wis_score": 11,
"cha_score": 2
},
"hp_max": 30,
"ac": { "total": 15, "touch": 10, "flat_footed": 14 },
"bab": 3,
"initiative_mod": 1,
"speed_land_ft": 20,
"saves": { "fort": 7, "ref": 2, "will": 1 },
"attacks": [
{
"name": "bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [{ "formula": "1d6", "types": ["piercing"] }],
"damage_bonus": 2,
"crit_mult": 2,
"reach_ft": 10
},
{
"name": "claw",
"kind": "melee",
"attack_bonus": 4,
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
"damage_bonus": 2,
"crit_mult": 2,
"count": 2,
"reach_ft": 10
}
],
"cmb": 6,
"cmd": 17
}
+1
View File
@@ -2,6 +2,7 @@
"name": "Lich",
"level": 11,
"size": "Medium",
"creature_type": "undead",
"abilities": {
"str_score": 12,
"dex_score": 14,
+109
View File
@@ -0,0 +1,109 @@
{
"name": "Thrune Agent",
"level": 8,
"size": "Medium",
"creature_type": "living",
"cr": "7",
"xp": 3200,
"source": "Bestiary > Thrune Agent",
"notes": "Magus class abilities (spell combat, spellstrike, arcane pool, spell recall, knowledge pool) not emitted by default_policy; combat gear (cure moderate wounds potions x2, fireball scroll) not modeled as items. Heavy mace is +1 (enhancement folded into attack_bonus/damage_bonus); Weapon Focus (heavy mace) applies via features at runtime. Extra Arcana and Skill Focus (Intimidate) resolve to traced no-ops. Magus spells prepared: arcane sight, dispel magic, vampiric touch (3rd); bear's endurance, blur, elemental touch, invisibility, scorching ray (2nd); burning hands, magic missile, ray of enfeeblement, shield, true strike (1st).",
"abilities": {
"str_score": 14,
"dex_score": 10,
"con_score": 12,
"int_score": 18,
"wis_score": 8,
"cha_score": 14
},
"hp_max": 63,
"ac": {
"total": 15,
"touch": 11,
"flat_footed": 15
},
"bab": 6,
"initiative_mod": 0,
"speed_land_ft": 30,
"saves": {
"fort": 8,
"ref": 3,
"will": 6
},
"casting_ability": "int",
"spell_slots": {
"1": 5,
"2": 5,
"3": 3
},
"attacks": [
{
"id": "thrune_agent:mace",
"name": "heavy mace",
"kind": "melee",
"attack_bonus": 9,
"damage": [
{
"formula": "1d8",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 3,
"crit_range": 20,
"crit_mult": 2
},
{
"id": "thrune_agent:crossbow",
"name": "light crossbow",
"kind": "ranged",
"attack_bonus": 6,
"damage": [
{
"formula": "1d8",
"types": [
"piercing"
]
}
],
"damage_bonus": 0,
"crit_range": 19,
"crit_mult": 2,
"range_increment_ft": 80
}
],
"cmb": 8,
"cmd": 19,
"features": [
"Alertness",
"Combat Casting",
"Extra Arcana",
"Skill Focus (Intimidate)",
"Toughness",
"Weapon Focus (heavy mace)",
"Medium Armor Proficiency",
"Spell Combat",
"Improved Spell Combat",
"Spellstrike",
"Arcane Pool",
"Spell Recall",
"Knowledge Pool",
"Magus Arcana (arcane accuracy, empowered magic, spell shield)"
],
"spells": [
"Burning Hands",
"Magic Missile",
"Ray of Enfeeblement",
"Shield",
"True Strike",
"Bear's Endurance",
"Blur",
"Elemental Touch",
"Invisibility",
"Scorching Ray",
"Arcane Sight",
"Dispel Magic",
"Vampiric Touch"
],
"skills": {}
}
+6 -4
View File
@@ -53,7 +53,7 @@
},
{
"name": "Braises d'Automne (pistolet ?)",
"kind": "ranged",
"kind": "touch",
"attack_bonus": 9,
"damage": [
{
@@ -68,11 +68,12 @@
"crit_mult": 4,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 20
"range_increment_ft": 20,
"firearm": true
},
{
"name": "Adieux du Printemps (pistolet, masterwork)",
"kind": "ranged",
"kind": "touch",
"attack_bonus": 10,
"damage": [
{
@@ -87,7 +88,8 @@
"crit_mult": 4,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 20
"range_increment_ft": 20,
"firearm": true
},
{
"name": "Alchemist's Fire",
+2
View File
@@ -54,6 +54,8 @@ allowed-confusables = ["σ"]
"src/pf1e_simulator/cli.py" = ["T201"]
# combat.py: CombatEngine.__init__ needs 6 params (rng, grid, states, round_cap, policy, spell_registry)
"src/pf1e_simulator/combat.py" = ["PLR0913"]
# runner.py: run_encounter needs 6 params (spec, runs, seed, policy, spell_registry, capture_first_transcript)
"src/pf1e_simulator/runner.py" = ["PLR0913"]
# ── Basedpyright ──────────────────────────────────────────────────────────────
[tool.basedpyright]
+16 -1
View File
@@ -100,6 +100,11 @@ def main(argv: list[str] | None = None) -> int:
default=100,
help="max rounds per battle (default: 100)",
)
parser.add_argument(
"--transcript",
action="store_true",
help="print the turn-by-turn log of the first battle before the report",
)
args = parser.parse_args(argv)
if not args.side:
parser.error("at least one --side is required")
@@ -113,9 +118,19 @@ def main(argv: list[str] | None = None) -> int:
return 2
try:
registry = load_spell_registry(SPELLS_DIR)
report = run_encounter(spec, runs=args.runs, seed=args.seed, spell_registry=registry)
report = run_encounter(
spec,
runs=args.runs,
seed=args.seed,
spell_registry=registry,
capture_first_transcript=args.transcript,
)
except ValueError as exc:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
if args.transcript and report.transcript is not None:
print("=== Déroulé du premier combat ===")
print("\n".join(report.transcript))
print()
print(format_report(report, spec))
return 0
+1204 -243
View File
File diff suppressed because it is too large Load Diff
+42 -7
View File
@@ -32,17 +32,39 @@ _DIRECTIONS: tuple[Pos, ...] = (
class Grid:
"""Terrain view of a MapSpec with movement and distance queries."""
"""Terrain view of a MapSpec with movement and distance queries.
def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None:
An optional elevation layer stores a height per cell; climbing one level
costs one extra square of movement (PF1e), descending is free.
"""
def __init__(
self,
cells: tuple[tuple[TerrainType, ...], ...],
elevation: tuple[tuple[int, ...], ...] | None = None,
) -> None:
self._cells = cells
self._height = len(cells)
self._width = len(cells[0]) if cells else 0
self._elevation = elevation
@classmethod
def from_spec(cls, spec: MapSpec) -> Grid:
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
return cls(cells)
elevation = None
if spec.elevation is not None:
height = len(cells)
width = len(cells[0]) if cells else 0
if len(spec.elevation) != height or (
height and len(spec.elevation[0]) != width
):
msg = "elevation layer dimensions do not match terrain"
raise ValueError(msg)
elevation = tuple(
tuple(0 if ch == "." else int(ch) for ch in row)
for row in spec.elevation
)
return cls(cells, elevation=elevation)
@property
def height(self) -> int:
@@ -63,6 +85,13 @@ class Grid:
def passable(self, pos: Pos) -> bool:
return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None
def elevation_at(self, pos: Pos) -> int:
"""Height of a cell; 0 when no elevation layer or out of bounds."""
if self._elevation is None or not self.in_bounds(pos):
return 0
row, col = pos
return self._elevation[row][col]
def distance(self, a: Pos, b: Pos) -> int:
"""Distance in squares under the 5-10-5-10 diagonal rule."""
d_row = abs(a[0] - b[0])
@@ -70,17 +99,23 @@ class Grid:
return max(d_row, d_col) + min(d_row, d_col) // 2
def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int:
"""Cost in squares of one step; diagonals_used is the count so far."""
"""Cost in squares of one step; diagonals_used is the count so far.
Climbing one elevation level adds one square per level; descending is
free and never reduces the terrain cost.
"""
cost = self.terrain_at(to).move_cost
if cost is None:
msg = f"cannot step into impassable square {to}"
raise ValueError(msg)
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
if not is_diagonal:
return cost
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
if cost >= _DIFFICULT_TERRAIN_COST:
return 3
return 1 + (diagonals_used % 2)
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
return 1 + (diagonals_used % 2) + max(
0, self.elevation_at(to) - self.elevation_at(frm)
)
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
flank_a = (frm[0], to[1])
+49 -13
View File
@@ -67,18 +67,17 @@ def _segment_hits_cell(p1: Pt, p2: Pt, row: int, col: int) -> bool:
class _LineGrid:
"""Bound view for corner-line checks between two squares of one grid."""
"""Bound view for corner-line checks between two sets of squares of one grid."""
def __init__(
self,
grid: Grid,
a: Pos,
b: Pos,
exclude: frozenset[Pos],
blocking: Callable[[TerrainType], bool],
occupied: frozenset[Pos] = frozenset(),
) -> None:
self._grid = grid
self._exclude = frozenset({a, b})
self._exclude = exclude
self._blocking = blocking
self._occupied = occupied
@@ -105,13 +104,23 @@ def _grants_cover(terrain: TerrainType) -> bool:
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
"""LoE iff some corner of a reaches some corner of b unblocked."""
if a == b:
return has_line_of_effect_cells(grid, frozenset({a}), frozenset({b}))
def has_line_of_effect_cells(
grid: Grid, a_cells: frozenset[Pos], b_cells: frozenset[Pos]
) -> bool:
"""LoE iff some corner of some cell of ``a_cells`` reaches some corner of
some cell of ``b_cells`` unblocked."""
if a_cells & b_cells:
return True
lines = _LineGrid(grid, a, b, lambda terrain: terrain.blocks_los)
lines = _LineGrid(grid, a_cells | b_cells, lambda terrain: terrain.blocks_los)
return any(
not lines.blocked(corner_a, corner_b)
for corner_a in _corners(a)
for corner_b in _corners(b)
for cell_a in a_cells
for corner_a in _corners(cell_a)
for cell_b in b_cells
for corner_b in _corners(cell_b)
)
@@ -130,16 +139,43 @@ def has_cover(
ranged attacks, squares occupied by other creatures also block the
lines (soft cover, CRB); melee ignores creatures.
"""
if attacker == target:
return has_cover_cells(
grid,
frozenset({attacker}),
frozenset({target}),
ranged=ranged,
occupied=occupied,
)
def has_cover_cells(
grid: Grid,
attacker_cells: frozenset[Pos],
target_cells: frozenset[Pos],
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner.
The attacker picks their single best cell (and best corner within it);
the target's corners are the union over all its cells. For 1x1 creatures
this is exactly the single-cell corner rule.
"""
if attacker_cells & target_cells:
return False
lines = _LineGrid(
grid,
attacker,
target,
attacker_cells | target_cells,
_grants_cover,
occupied if ranged else frozenset(),
)
return not any(
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
for corner_a in _corners(attacker)
all(
not lines.blocked(corner_a, corner_t)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
)
+5 -1
View File
@@ -237,7 +237,9 @@ class AttackSpec(_FrozenModel):
`count` captures repeated identical swings ("2x Talons") without listing
the same entry twice. `reach_ft` applies to melee/touch kinds,
`range_increment_ft` to ranged/touch kinds.
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
weapon (gun) whose shots resolve against touch AC within the first range
increment and against full AC beyond it.
"""
id: str
@@ -251,6 +253,7 @@ class AttackSpec(_FrozenModel):
reach_ft: int = 5
range_increment_ft: int | None = None
count: int = Field(default=1, ge=1)
firearm: bool = False
class DamageReduction(_FrozenModel):
@@ -267,6 +270,7 @@ class Combatant(_FrozenModel):
name: str
level: int = Field(ge=1)
size: str = "Medium"
creature_type: Literal["living", "undead", "construct"] = "living"
abilities: AbilityScores
hp_max: int = Field(ge=1)
ac: ACProfile
+61 -11
View File
@@ -19,9 +19,14 @@ if TYPE_CHECKING:
from pf1e_simulator.models import Combatant
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
from pf1e_simulator.combat import (
SIZE_CELLS,
CombatantState,
CombatantStats,
CombatEngine,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import zone_cells
from pf1e_simulator.map import Pos, 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
@@ -57,7 +62,12 @@ class CombatantAttrition:
@dataclass(frozen=True)
class EncounterReport:
"""Aggregated results of a Monte Carlo encounter."""
"""Aggregated results of a Monte Carlo encounter.
`transcript` holds the turn-by-turn log of the first battle when
`run_encounter` is called with ``capture_first_transcript=True``; it is
None otherwise (the default keeps the report cheap for pure Monte Carlo).
"""
runs: int
seed: int
@@ -65,6 +75,7 @@ class EncounterReport:
draws: int
avg_rounds: float
attrition: dict[str, CombatantAttrition]
transcript: tuple[str, ...] | None = None
def win_rate(self, side: str) -> float:
"""Fraction of runs won by `side` (closed form, see metrics)."""
@@ -79,26 +90,52 @@ class EncounterReport:
return win_rate_band(self.win_rate(side), self.runs)
def _first_fit_anchor(
cells: list[Pos], placed: set[Pos], n: int, grid: Grid
) -> Pos | None:
"""First zone cell whose n x n footprint is in bounds, passable, and unplaced."""
for anchor in cells:
r, c = anchor
footprint = {(r + dr, c + dc) for dr in range(n) for dc in range(n)}
if any(not grid.in_bounds(p) or not grid.passable(p) for p in footprint):
continue
if footprint & placed:
continue
return anchor
return None
def build_states(spec: EncounterSpec) -> list[CombatantState]:
"""Fresh states for one battle; duplicate combatant ids get -N suffixes.
Combatants of a side fill the zone's cells in (row, col) order. A side with
more combatants than zone cells, or a zone with no cells, is a spec error.
Creatures larger than 1x1 occupy an n x n footprint anchored at their cell.
"""
states: list[CombatantState] = []
seen: dict[str, int] = {}
grid = Grid.from_spec(spec.map)
for side in spec.sides:
cells = zone_cells(spec.map, side.zone)
if not cells:
msg = f"side {side.name!r}: zone {side.zone!r} has no cells"
raise ValueError(msg)
if len(cells) < len(side.combatants):
msg = (
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
f"for {len(side.combatants)} combatants"
)
raise ValueError(msg)
for combatant, pos in zip(side.combatants, cells, strict=False):
placed: set[Pos] = set()
for combatant in side.combatants:
n = SIZE_CELLS.get(combatant.size, 1)
anchor = _first_fit_anchor(cells, placed, n, grid)
if anchor is None:
if n == 1:
msg = (
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
f"for {len(side.combatants)} combatants"
)
else:
msg = (
f"side {side.name!r}: zone {side.zone!r} has no room for "
f"{combatant.id!r} ({combatant.size}, {n}x{n})"
)
raise ValueError(msg)
count = seen.get(combatant.id, 0) + 1
seen[combatant.id] = count
cid = combatant.id if count == 1 else f"{combatant.id}-{count}"
@@ -107,11 +144,16 @@ def build_states(spec: EncounterSpec) -> list[CombatantState]:
if cid == combatant.id
else combatant.model_copy(update={"id": cid})
)
placed |= {
(anchor[0] + dr, anchor[1] + dc)
for dr in range(n)
for dc in range(n)
}
states.append(
CombatantState(
combatant=effective,
side=side.name,
pos=pos,
pos=anchor,
hp=effective.hp_max,
resources_left=dict(effective.resources),
)
@@ -126,11 +168,15 @@ def run_encounter(
*,
policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
capture_first_transcript: bool = False,
) -> EncounterReport:
"""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.
When ``capture_first_transcript`` is True, the turn-by-turn log of the
first battle (``seed + 0``) is stored in ``report.transcript``.
"""
if runs <= 0:
msg = "runs must be positive"
@@ -144,6 +190,7 @@ def run_encounter(
draws = 0
total_rounds = 0
totals = {cid: CombatantStats() for cid in ids}
transcript: tuple[str, ...] | None = None
for index in range(runs):
if index > 0:
states = build_states(spec)
@@ -156,6 +203,8 @@ def run_encounter(
policy=policy,
spell_registry=spell_registry,
).run()
if capture_first_transcript and index == 0:
transcript = result.transcript
if result.winner is None:
draws += 1
else:
@@ -185,4 +234,5 @@ def run_encounter(
draws=draws,
avg_rounds=total_rounds / runs,
attrition=attrition,
transcript=transcript,
)
+31 -2
View File
@@ -35,7 +35,8 @@ SaveType = Literal["fort", "ref", "will", "none"]
class DamageEffect:
formula: DiceExpr
types: tuple[str, ...]
half_on_save: bool = True
# False = save negates (targeted); True = half damage on save (ray/line/cone/AoE)
half_on_save: bool = False
@dataclass(frozen=True)
@@ -108,6 +109,34 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
return 400 + 40 * caster_level
_FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering"})
def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]:
descriptors: set[str] = set()
for effect in effects:
if isinstance(effect, DamageEffect):
descriptors.update(effect.types)
elif isinstance(effect, ConditionEffect) and effect.condition_name in _FEAR_CONDITIONS:
descriptors.add("fear")
elif isinstance(effect, AreaEffect):
descriptors.update(save_descriptors_for_effects(effect.effects))
return frozenset(descriptors)
def spell_save_descriptors(spell: SpellSpec) -> frozenset[str]:
"""Spell save descriptors: effect-derived plus CRB school tags.
Enchantment is mind_affecting, divination is divination.
"""
descriptors = set(save_descriptors_for_effects(spell.effects))
if spell.school == "enchantment":
descriptors.add("mind_affecting")
elif spell.school == "divination":
descriptors.add("divination")
return frozenset(descriptors)
def _as_dict(value: Json) -> dict[str, Json]:
if isinstance(value, dict):
return value
@@ -188,7 +217,7 @@ def _parse_effect(data: Json) -> SpellEffect:
return DamageEffect(
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)),
half_on_save=bool(effect.get("half_on_save", False)),
)
if effect_type == "condition":
return ConditionEffect(
+244
View File
@@ -0,0 +1,244 @@
"""Tests for L4: ``ChargeResult`` — exposing the cause of a charge failure.
``can_charge`` no longer returns ``AttackSpec | None`` but a ``ChargeResult``
that carries the weapon, the straight-line distance in feet, and a ``reason``
(None = success, otherwise an explanatory string). The engine behaviour is
unchanged: a result without a weapon means no charge (same decision as before),
a result with a weapon means charge (same weapon as before).
Covers:
- Success: ``ChargeResult`` with a weapon, ``reason is None``, ``distance_ft``
aligned on 5 ft.
- ``_execute_charge`` migration: a successful charge still deals damage and
logs ``charge``.
- LoE failure: ``reason == "no_line_of_effect"`` and ``_melee_action`` does not
pick ``charge``.
- ``no_charge_path``: target too close (adjacent) and too far (> 2x speed).
- No melee weapon: ``can_charge`` returns literal ``None``.
- Determinism: two runs with the same seed produce the same transcript.
"""
from __future__ import annotations
from pf1e_simulator.combat import (
Action,
ChargeResult,
CombatantState,
CombatEngine,
_melee_action,
)
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_combatant(
cid: str,
*,
size: str = "Medium",
reach_ft: int = 5,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
kind: str = "melee",
) -> Combatant:
"""Build a single-weapon combatant (melee by default)."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_ft > 5 else "short sword",
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d6", types=["piercing"])],
reach_ft=reach_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=10,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(n: int = 8) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * n] * n), legend=legend)
return Grid.from_spec(spec)
def make_engine(states: list[CombatantState], grid: Grid | None = None) -> CombatEngine:
return CombatEngine(ScriptedRng([]), grid or make_grid(), states)
def _wall_grid() -> Grid:
"""8x8 grid with a wall cell at (0, 2) blocking LoS between (0,0) and (0,4)."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
rows = []
for r in range(8):
row = list("........")
if r == 0:
row[2] = "#"
rows.append("".join(row))
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
return Grid.from_spec(spec)
# --- Test 1: success ---------------------------------------------------------
def test_charge_result_success() -> None:
"""A clear straight-line charge returns a ChargeResult with a weapon."""
a = make_state(make_combatant("a"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 2))
engine = make_engine([a, b])
result = engine.can_charge(a, b)
assert isinstance(result, ChargeResult)
assert result.weapon is not None
assert result.reason is None
assert result.distance_ft > 0
assert result.distance_ft % 5 == 0
# --- Test 2: _execute_charge migration ---------------------------------------
def _charge_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "a":
return (Action(kind="charge", target_id="b"),)
return (Action(kind="attack", target_id="a"),)
def test_execute_charge_still_deals_damage() -> None:
"""A successful charge still moves, hits, and logs ``charge``."""
a = make_state(make_combatant("a", hp=20, ac=15, attack_bonus=2, speed=30), "players", (1, 1))
b = make_state(make_combatant("b", hp=20, ac=15, attack_bonus=0, speed=0), "monsters", (1, 5))
engine = CombatEngine(
ScriptedRng([10, 9, 12, 3, 11, 2]),
make_grid(),
[a, b],
round_cap=1,
policy=_charge_policy,
)
result = engine.run()
assert b.hp < b.combatant.hp_max
assert any("charge" in line for line in result.transcript)
# --- Test 3: LoE failure -----------------------------------------------------
def test_charge_result_no_line_of_effect() -> None:
"""A wall blocking LoS yields reason ``no_line_of_effect``."""
a = make_state(make_combatant("a"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 4))
engine = make_engine([a, b], _wall_grid())
result = engine.can_charge(a, b)
assert isinstance(result, ChargeResult)
assert result.weapon is None
assert result.reason == "no_line_of_effect"
assert result.distance_ft == 0
def test_melee_action_not_charge_on_no_line_of_effect() -> None:
"""With LoS blocked, _melee_action falls back to move+attack, not charge."""
a = make_state(make_combatant("a"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 4))
engine = make_engine([a, b], _wall_grid())
actions = _melee_action(engine, a, b)
assert all(action.kind != "charge" for action in actions)
# --- Test 4: no_charge_path --------------------------------------------------
def test_charge_result_no_charge_path_adjacent() -> None:
"""An adjacent target (already in reach) yields reason ``no_charge_path``."""
a = make_state(make_combatant("a"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 1))
engine = make_engine([a, b])
result = engine.can_charge(a, b)
assert isinstance(result, ChargeResult)
assert result.weapon is None
assert result.reason == "no_charge_path"
assert result.distance_ft == 0
def test_charge_result_no_charge_path_too_far() -> None:
"""A target beyond 2x speed yields reason ``no_charge_path``."""
a = make_state(make_combatant("a"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 20))
engine = make_engine([a, b], make_grid(30))
result = engine.can_charge(a, b)
assert isinstance(result, ChargeResult)
assert result.weapon is None
assert result.reason == "no_charge_path"
assert result.distance_ft == 0
# --- Test 5: no melee weapon -------------------------------------------------
def test_charge_result_none_without_melee_weapon() -> None:
"""A ranged-only combatant yields literal ``None`` (nothing to charge with)."""
a = make_state(make_combatant("a", kind="ranged"), "players", (0, 0))
b = make_state(make_combatant("b"), "monsters", (0, 2))
engine = make_engine([a, b])
assert engine.can_charge(a, b) is None
# --- Test 6: determinism -----------------------------------------------------
def test_charge_result_deterministic_transcript() -> None:
"""Two runs with the same seed produce the same transcript."""
transcripts = []
for _ in range(2):
a = make_state(
make_combatant("a", hp=20, ac=15, attack_bonus=2, speed=30),
"players",
(1, 1),
)
b = make_state(
make_combatant("b", hp=20, ac=15, attack_bonus=0, speed=0),
"monsters",
(1, 5),
)
engine = CombatEngine(
ScriptedRng([10, 9, 12, 3, 11, 2]),
make_grid(),
[a, b],
round_cap=1,
policy=_charge_policy,
)
transcripts.append(engine.run().transcript)
assert transcripts[0] == transcripts[1]
+67
View File
@@ -7,6 +7,8 @@ tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
from __future__ import annotations
from typing import Literal
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
@@ -29,6 +31,7 @@ def _combatant(
resources: dict[str, int] | None = None,
spells: list[str] | None = None,
hp_max: int = 20,
creature_type: Literal["living", "undead", "construct"] = "living",
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
@@ -55,6 +58,7 @@ def _combatant(
attacks=[attack],
resources=resources or {},
spells=spells or [],
creature_type=creature_type,
)
@@ -134,6 +138,69 @@ def test_oni_channel_energy_spends_resource() -> None:
assert me.resources_left["channel_energy"] == 5
def test_oni_channel_harm_does_not_damage_living_enemy() -> None:
"""Regression: positive energy must not damage living creatures (reported bug:
oni harmed a Giant Belostomatid — living vermin). Harm mode only hits undead."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
vermin = _combatant("belostomatid", hp_max=30)
engine, me, enemy = _engine(oni, [vermin], rng_queue=[11])
action = Action(
kind="channel_energy", target_id=vermin.id, channel_mode="harm"
)
engine._execute(me, action)
assert me.hp == 30 # living caster unaffected by harm mode
assert enemy[0].hp == 30 # living vermin unaffected by harm mode
def test_oni_channel_harm_damages_undead() -> None:
"""Harm mode damages undead creatures in the burst regardless of side."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
lich = _combatant("lich", hp_max=100, creature_type="undead")
engine, me, enemy = _engine(oni, [lich], rng_queue=[11])
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
engine._execute(me, action)
assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11)
def test_oni_channel_heal_heals_living_enemies_in_range() -> None:
"""Heal mode heals all living creatures in the burst — including enemies
(Selective Channeling, which would exclude them, is not modeled)."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(oni, [goblin], rng_queue=[5])
enemy[0].hp = 2
action = Action(
kind="channel_energy", target_id=me.combatant.id, channel_mode="heal"
)
engine._execute(me, action)
assert enemy[0].hp == min(2 + (1 + 5), 6) # healed, capped at hp_max
def test_oni_emits_channel_harm_only_for_grouped_undead() -> None:
"""Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft
— living enemies are never harmed by positive energy."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
lich1 = _combatant("lich1", hp_max=100, creature_type="undead")
engine, me, _ = _engine(oni, [lich1])
assert not any(a.kind == "channel_energy" for a in default_policy(engine, me))
lich2 = _combatant("lich2", hp_max=100, creature_type="undead")
engine, me, _ = _engine(oni, [lich1, lich2])
actions = default_policy(engine, me)
assert any(
a.kind == "channel_energy" and a.channel_mode == "harm"
for a in actions
)
# --- nairda: hex ---
+28
View File
@@ -57,3 +57,31 @@ def test_cli_rejects_missing_deployment_side(capsys: pytest.CaptureFixture[str])
rc = main(["--map", str(MAP), "--side", "players", str(GOBLIN), "--runs", "10"])
assert rc == 2
assert "monsters" in capsys.readouterr().err
def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[str]) -> None:
rc = main(
[
"--map",
str(MAP),
"--side",
"players",
str(GOBLIN),
"--side",
"monsters",
str(ORC),
"--runs",
"10",
"--seed",
"1",
"--transcript",
]
)
out = capsys.readouterr().out
assert rc == 0
assert "Déroulé du premier combat" in out
assert "initiative:" in out
assert "round 1" in out
# The balance report must still be printed after the transcript.
assert "Rapport d'équilibrage" in out
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
+467
View File
@@ -0,0 +1,467 @@
"""Tests for conditional StatModifier evaluation with action context (B1).
Given: a StatModifier whose ``condition`` refers to a runtime property
(target range, wielded weapon, save descriptor)
When: the engine resolves an attack or a saving throw
Then: the modifier applies only when the runtime condition holds;
unsupported or unknown condition names stay excluded (security).
Supported condition families (plan B1):
- ``target_within_30ft_ranged`` — ranged-style weapon (or touch with
increment) fired at a target within 30 ft (Point-Blank Shot).
- ``axe`` — wielded weapon whose name contains "axe" (Axe to Grind).
- save descriptors (fear, cold, electricity, fey, divination, poison,
disease, mind_affecting) — the saving throw's context, threaded from the
fear aura and from spell effects/schools (Bitter, Lonely, Breeze-Kissed,
Fey-touched, Spell Vulnerability, Born Damned, Pingre, Half-Forgotten
Secrets).
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
alcohol) and unknown names always evaluate to False.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import pytest
from pf1e_simulator.abilities import (
AXE_TO_GRIND,
BITTER,
BORN_DAMNED,
BREEZE_KISSED,
FEY_TOUCHED,
FORTIFIED_DRINKER,
HALF_FORGOTTEN_SECRETS,
LONELY,
MOUNTED_COMBAT,
PINGRE,
POINT_BLANK_SHOT,
SPELL_VULNERABILITY_DIVINATION,
AbilitySpec,
)
from pf1e_simulator.combat import CombatantState, CombatEngine, SaveResult
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import 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
from pf1e_simulator.spells import load_spell_registry, spell_save_descriptors
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def make_combatant(
cid: str,
*,
hp: int = 20,
ac_total: int = 18,
attack_bonus: int = 5,
damage: str = "1d6",
kind: Literal["melee", "ranged", "touch"] = "melee",
weapon_name: str = "test weapon",
range_increment_ft: int | None = None,
features: tuple[AbilitySpec, ...] = (),
will: int = 0,
fort: int = 0,
ref: int = 0,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
range_increment_ft=range_increment_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=12,
dex_score=12,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac_total, touch=max(12, ac_total - 6), flat_footed=ac_total - 2),
bab=attack_bonus,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=fort, ref=ref, will=will),
attacks=[attack],
features=list(features),
)
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(width: int = 8) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * width] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(
queue: list[int], states: list[CombatantState], *, width: int = 8
) -> CombatEngine:
return CombatEngine(ScriptedRng(queue), make_grid(width), states)
def _resolve_save(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
*,
roll: int = 18,
save_descriptors: frozenset[str] = frozenset(),
) -> SaveResult:
hero = make_combatant("hero", features=(feat,), will=0, fort=0, ref=0)
h = make_state(hero, "players", (0, 0))
engine = make_engine([roll], [h])
return engine.resolve_save(h, save_type, dc, save_descriptors=save_descriptors)
# ── Weapon/range conditions ───────────────────────────────────────────────────
def test_point_blank_shot_applies_within_30_ft() -> None:
"""PBS: ranged attack at 5 ft gains +1 attack and +1 damage."""
archer = make_combatant(
"archer",
kind="ranged",
range_increment_ft=60,
features=(POINT_BLANK_SHOT,),
)
victim = make_combatant("victim", hp=20)
a = make_state(archer, "players", (1, 1))
t = make_state(victim, "monsters", (1, 2)) # 5 ft
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, archer.attacks[0])
# 12 + 5 + 1 (PBS) = 18 >= AC 18; damage 3 + 1 (PBS) = 4
assert result.total == 18
assert result.hit is True
assert t.hp == 16
def test_point_blank_shot_inactive_beyond_30_ft() -> None:
"""PBS: the +1/+1 does not apply at 35 ft even though the weapon is in range."""
archer = make_combatant(
"archer",
kind="ranged",
range_increment_ft=60,
features=(POINT_BLANK_SHOT,),
)
victim = make_combatant("victim", ac_total=16, hp=20)
a = make_state(archer, "players", (0, 0))
t = make_state(victim, "monsters", (0, 7)) # 35 ft
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, archer.attacks[0])
# 12 + 5 = 17 >= AC 16; damage 3 (no PBS)
assert result.total == 17
assert result.hit is True
assert t.hp == 17
def test_point_blank_shot_inactive_for_melee_weapons() -> None:
"""PBS is ranged-only: a melee attack at 5 ft gets no bonus."""
hero = make_combatant("hero", features=(POINT_BLANK_SHOT,))
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2)) # 5 ft, melee
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # no +1
assert t.hp == 17 # damage 3, no +1
def test_axe_to_grind_applies_to_axe_weapon() -> None:
"""Axe to Grind: +1 damage with a weapon whose name contains 'axe'."""
hero = make_combatant("hero", weapon_name="Battleaxe", features=(AXE_TO_GRIND,))
foe = make_combatant("foe", ac_total=16, hp=20)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.hit is True
assert t.hp == 16 # 3 + 1 (axe)
def test_axe_to_grind_inactive_without_axe() -> None:
"""Axe to Grind: no bonus when the weapon is not an axe."""
hero = make_combatant("hero", weapon_name="Shortsword", features=(AXE_TO_GRIND,))
foe = make_combatant("foe", ac_total=16, hp=20)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.hit is True
assert t.hp == 17 # 3, no bonus
# ── Save descriptor conditions ────────────────────────────────────────────────
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(BITTER, "will", 19, frozenset({"fear"})),
(LONELY, "will", 19, frozenset({"fear"})),
(BREEZE_KISSED, "fort", 20, frozenset({"cold"})),
(BREEZE_KISSED, "ref", 20, frozenset({"electricity"})),
(FEY_TOUCHED, "will", 19, frozenset({"fey"})),
(HALF_FORGOTTEN_SECRETS, "fort", 20, frozenset({"disease"})),
(HALF_FORGOTTEN_SECRETS, "will", 20, frozenset({"mind_affecting"})),
(BORN_DAMNED, "fort", 20, frozenset({"poison"})),
(PINGRE, "fort", 19, frozenset({"poison"})),
],
)
def test_conditioned_save_bonus_applies_when_descriptor_present(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
descriptors: frozenset[str],
) -> None:
"""A save carrying the matching descriptor activates the conditional bonus."""
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
assert result.total == dc # 18 + bonus reaches exactly dc
assert result.success is True
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(BITTER, "will", 19, frozenset({"cold"})),
(BREEZE_KISSED, "fort", 19, frozenset({"fear"})),
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
(BORN_DAMNED, "fort", 19, frozenset({"cold"})),
(PINGRE, "fort", 19, frozenset({"cold"})),
],
)
def test_conditioned_save_bonus_excluded_without_matching_descriptor(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
descriptors: frozenset[str],
) -> None:
"""A save with an unrelated descriptor keeps the conditional bonus excluded."""
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
assert result.total == 18 # no bonus
assert result.success is False
def test_spell_vulnerability_divination_penalty() -> None:
"""Spell Vulnerability: -2 Will/Ref penalty applies on divination saves only."""
penalized = _resolve_save(
SPELL_VULNERABILITY_DIVINATION, "will", 17,
save_descriptors=frozenset({"divination"}), roll=18,
)
assert penalized.total == 16 # 18 - 2
assert penalized.success is False
unaffected = _resolve_save(
SPELL_VULNERABILITY_DIVINATION, "ref", 17,
save_descriptors=frozenset({"fear"}), roll=18,
)
assert unaffected.total == 18
assert unaffected.success is True
# ── Unsupported / unknown conditions stay excluded ────────────────────────────
def test_mounted_condition_unsupported_stays_excluded() -> None:
"""Mounted Combat: 'mounted' is not evaluable (no mount model) -> excluded."""
hero = make_combatant("hero", attack_bonus=5, features=(MOUNTED_COMBAT,))
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # no +1 mounted bonus
assert t.hp == 17 # damage 3, no +1
def test_alcohol_condition_unsupported_stays_excluded() -> None:
"""Fortified Drinker: 'alcohol' context never exists -> excluded."""
result = _resolve_save(
FORTIFIED_DRINKER, "fort", 19, save_descriptors=frozenset({"poison"}), roll=18,
)
assert result.total == 18 # no +2 alcohol bonus
assert result.success is False
def test_unknown_condition_stays_excluded() -> None:
"""A condition name with no evaluation rule is excluded (security)."""
hero = make_combatant("hero", attack_bonus=5)
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
a.effects.append(StatModifier(target="attack", value=5, condition="bogus_condition"))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # the +5 modifier is excluded
assert result.hit is True
# ── End-to-end: fear aura threading ───────────────────────────────────────────
def _make_fear_aura_lich() -> Combatant:
return make_combatant(
"lich",
hp=100,
attack_bonus=10,
features=(AbilitySpec(name="Fear Aura", category="class"),),
)
def test_fear_aura_save_carries_fear_descriptor() -> None:
"""The lich's fear aura save carries 'fear': Lonely's +1 flips the save."""
lich = _make_fear_aura_lich()
foe = make_combatant("foe", will=0, features=(LONELY,))
lich_state = make_state(lich, "monsters", (1, 1))
foe_state = make_state(foe, "players", (1, 2)) # 5 ft, inside aura range
engine = make_engine([21], [lich_state, foe_state]) # 21 + 0 + 1 = 22 >= DC 22
engine._apply_fear_aura(lich_state)
assert not any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
def test_fear_aura_without_fear_bonus_fails_save() -> None:
"""Without the +1, the same d20 fails the aura save and applies shaken."""
lich = _make_fear_aura_lich()
foe = make_combatant("foe", will=0)
lich_state = make_state(lich, "monsters", (1, 1))
foe_state = make_state(foe, "players", (1, 2))
engine = make_engine([21], [lich_state, foe_state]) # 21 < DC 22
engine._apply_fear_aura(lich_state)
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
# ── End-to-end: spell save descriptor threading ───────────────────────────────
def _make_caster(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",
)
def _make_target(
cid: str = "target", *, features: tuple[AbilitySpec, ...] = ()
) -> 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],
features=list(features),
)
def _make_spell_engine(
caster: Combatant, target: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=(3, 0), hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
def test_hold_person_save_carries_mind_affecting_descriptor() -> None:
"""Hold Person (enchantment) save carries 'mind_affecting': trait +2 flips it.
Caster level 5, INT 18 -> DC = 10 + 3 (level) + 4 (mod) = 17. A d20 of 13
vs will 2 is 15 (< 17, fails) without the trait and 17 with it (saves).
"""
caster = _make_caster()
target = _make_target(features=(HALF_FORGOTTEN_SECRETS,))
engine, c, t = _make_spell_engine(caster, target, [13])
engine._cast_spell(c, t, "Hold Person")
assert not any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_hold_person_without_bonus_fails_save() -> None:
"""Without the trait the same d20 fails the Hold Person save."""
caster = _make_caster()
target = _make_target()
engine, c, t = _make_spell_engine(caster, target, [13])
engine._cast_spell(c, t, "Hold Person")
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
# ── Spell save descriptor derivation ──────────────────────────────────────────
@pytest.mark.parametrize(
("spell_name", "expected"),
[
("Lightning Bolt", frozenset({"electricity"})),
("Fear", frozenset({"fear"})),
("Fireball", frozenset({"fire"})),
("Hold Person", frozenset({"mind_affecting"})),
("Bull's Strength", frozenset()),
],
)
def test_spell_save_descriptors_derivation(spell_name: str, expected: frozenset[str]) -> None:
"""Damage types, fear conditions and school tags map to save descriptors."""
reg = load_spell_registry(_SPELLS_DIR)
assert spell_save_descriptors(reg[spell_name]) == expected
+522
View File
@@ -0,0 +1,522 @@
"""Tests for B5.2: multi-cell occupancy for Large+ creatures.
Covers:
- occupied_cells(): Medium -> {pos}, Large -> 4 cells, Huge -> 9 cells
- build_states: Large in 2x2+ zone; zone too small -> error
- creature_distance: min distance across occupied cells
- _threatened_squares: union of per-cell threatened areas for Large
- Movement: Large moves as footprint; stops at melee range
- Flanking: mirror cell from any occupied cell grants +2
- Charge: Large charge ends where footprint fits
- AoO: leaving threatened footprint cell triggers AoO
- LoS/cover: best cell of attacker vs target
- Equivalence: Medium 1x1 unchanged
"""
from __future__ import annotations
import pytest
from pf1e_simulator.combat import (
SIZE_CELLS,
Action,
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.los import has_cover, has_line_of_effect
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.runner import EncounterSpec, Side, build_states
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_combatant(
cid: str,
*,
size: str = "Medium",
reach_ft: int = 5,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
weapon_name: str = "short sword",
) -> Combatant:
"""Build a single-melee-weapon combatant."""
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
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=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(h: int = 10, w: int = 10) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="test", terrain=tuple(["." * w] * h), legend=legend,
)
return Grid.from_spec(spec)
def make_wall_grid() -> Grid:
"""10x10 grid with a wall at column 5 (rows 0-9)."""
terrain = []
for _ in range(10):
row = "." * 5 + "#" + "." * 4
terrain.append(row)
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
spec = MapSpec(name="test", terrain=tuple(terrain), legend=legend)
return Grid.from_spec(spec)
def make_engine(
states: list[CombatantState], *, grid: Grid | None = None,
) -> CombatEngine:
return CombatEngine(
ScriptedRng([]), grid or make_grid(), states,
)
# ---------------------------------------------------------------------------
# 1. occupied_cells
# ---------------------------------------------------------------------------
class TestOccupiedCells:
def test_medium_returns_singleton(self) -> None:
c = make_combatant("m", size="Medium")
s = make_state(c, "a", (3, 4))
assert s.occupied_cells() == frozenset({(3, 4)})
def test_small_returns_singleton(self) -> None:
c = make_combatant("s", size="Small")
s = make_state(c, "a", (3, 4))
assert s.occupied_cells() == frozenset({(3, 4)})
def test_large_returns_2x2(self) -> None:
c = make_combatant("l", size="Large")
s = make_state(c, "a", (3, 4))
assert s.occupied_cells() == frozenset({
(3, 4), (3, 5), (4, 4), (4, 5),
})
def test_huge_returns_3x3(self) -> None:
c = make_combatant("h", size="Huge")
s = make_state(c, "a", (0, 0))
cells = s.occupied_cells()
assert len(cells) == 9
for dr in range(3):
for dc in range(3):
assert (dr, dc) in cells
def test_unknown_size_returns_singleton(self) -> None:
c = make_combatant("u", size="Weird")
s = make_state(c, "a", (2, 2))
assert s.occupied_cells() == frozenset({(2, 2)})
def test_size_cells_table(self) -> None:
assert SIZE_CELLS["Small"] == 1
assert SIZE_CELLS["Medium"] == 1
assert SIZE_CELLS["Large"] == 2
assert SIZE_CELLS["Huge"] == 3
assert SIZE_CELLS["Gargantuan"] == 4
assert SIZE_CELLS["Colossal"] == 6
# ---------------------------------------------------------------------------
# 2. build_states placement
# ---------------------------------------------------------------------------
class TestBuildStatesLarge:
def test_large_in_zone_2x2(self) -> None:
"""A Large creature placed in a zone cell occupies 2x2 from that anchor."""
spec = EncounterSpec(
map=MapSpec(
name="t",
terrain=tuple(["...."] * 4),
legend={".": TerrainType(type="floor", move_cost=1)},
zones=tuple(["AAAA"] * 4),
deployment={"p": "A"},
),
sides=(Side(name="p", combatants=(make_combatant("g", size="Large"),), zone="A"),),
)
states = build_states(spec)
assert len(states) == 1
assert states[0].occupied_cells() == frozenset({
(0, 0), (0, 1), (1, 0), (1, 1),
})
def test_zone_too_small_for_large_raises(self) -> None:
"""A zone with only 1 cell can't hold a Large creature's 2x2 footprint."""
spec = EncounterSpec(
map=MapSpec(
name="t",
terrain=(".",),
legend={".": TerrainType(type="floor", move_cost=1)},
zones=("A",),
deployment={"p": "A"},
),
sides=(Side(name="p", combatants=(make_combatant("g", size="Large"),), zone="A"),),
)
with pytest.raises(ValueError, match="zone"):
build_states(spec)
# ---------------------------------------------------------------------------
# 3. creature_distance
# ---------------------------------------------------------------------------
class TestCreatureDistance:
def test_medium_to_medium(self) -> None:
a = make_state(make_combatant("a"), "p", (0, 0))
b = make_state(make_combatant("b"), "e", (3, 4))
engine = make_engine([a, b])
assert engine.creature_distance(a, b) == 5 # 5-10-5 diagonal
def test_large_to_medium_adjacent(self) -> None:
"""Large at (0,0) occupies (0,0)(0,1)(1,0)(1,1).
Medium at (1,2): distance from any Large cell = min 1.
"""
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
b = make_state(make_combatant("b"), "e", (1, 2))
engine = make_engine([a, b])
assert engine.creature_distance(a, b) == 1
def test_large_to_medium_diagonal_touches_at_dist_1(self) -> None:
"""Large at (0,0) has cell (1,1).
Medium at (2,2): dist from (1,1) to (2,2) = 1 diagonal.
"""
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
b = make_state(make_combatant("b"), "e", (2, 2))
engine = make_engine([a, b])
assert engine.creature_distance(a, b) == 1
def test_large_to_large(self) -> None:
"""Two Large creatures at (0,0) and (5,5)."""
a = make_state(make_combatant("a", size="Large"), "p", (0, 0))
b = make_state(make_combatant("b", size="Large"), "e", (5, 5))
engine = make_engine([a, b])
# min dist from (1,1) to (5,5): d_row=4, d_col=4, max+min//2=4+2=6
# min dist from (0,0) to (5,5): same 6
# min dist from (0,1) to (5,5): 4+2=6
# min dist from (1,0) to (5,5): 4+2=6
assert engine.creature_distance(a, b) == 6
# ---------------------------------------------------------------------------
# 4. Threatened squares for Large
# ---------------------------------------------------------------------------
class TestThreatenedSquaresLarge:
def test_large_sword_threatens_union_of_2x2_cells(self) -> None:
"""A Large creature at (2,2) with a 5ft sword has natural reach 10ft (2 cells).
Each of the 4 occupied cells radiates a disk of radius 2.
"""
giant = make_combatant("g", size="Large")
state = make_state(giant, "p", (2, 2))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
# Must contain cells adjacent to any of the 4 occupied cells
# e.g. (0,2) is at distance 2 from (2,2), should be in
assert (0, 2) in threatened
# (1,2) is adjacent to (2,2) -> at distance 1 from (2,2), inside disk(2)
assert (1, 2) in threatened
# (5,5) should be too far
assert (5, 5) not in threatened
def test_large_reach_weapon_threatens_union_of_rings(self) -> None:
"""A Large at (2,2) with a reach weapon (10ft) threatens ring(2) from each cell."""
spear = AttackSpec(
id="spear", name="longspear", kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=10,
)
combatant = Combatant(
id="g", name="g", level=1, size="Large",
abilities=AbilityScores(
str_score=10, dex_score=10, 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=5, initiative_mod=0, speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[spear],
)
state = make_state(combatant, "p", (2, 2))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
# Ring at distance 2 from each occupied cell is included
# (2,2) ring(2) includes (0,2) etc.
# (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc.
assert (1, 3) in threatened
assert (3, 5) in threatened
# ---------------------------------------------------------------------------
# 5. Movement: Large moves as footprint
# ---------------------------------------------------------------------------
def _move_policy(
engine: CombatEngine, state: CombatantState,
) -> tuple[Action, ...]:
return (Action(kind="move", target_id="d"),)
class TestLargeMovement:
def test_large_can_move_and_all_cells_update(self) -> None:
"""A Large creature moves; all 4 cells of the footprint shift."""
giant = make_combatant("g", size="Large", speed=30)
g_state = make_state(giant, "p", (0, 0))
dest = make_combatant("d", hp=100)
d_state = make_state(dest, "e", (5, 5))
states = [g_state, d_state]
engine = CombatEngine(
ScriptedRng([20, 1]), make_grid(), states,
round_cap=1, policy=_move_policy,
)
engine.run()
# Giant moved from (0,0) toward (5,5)
old_cells = frozenset({(0, 0), (0, 1), (1, 0), (1, 1)})
assert g_state.pos != (0, 0) or g_state.occupied_cells() != old_cells
def test_large_cannot_pass_through_wall(self) -> None:
"""A Large creature at (0,0) cannot move right because wall at col 5
blocks some cells of its footprint."""
giant = make_combatant("g", size="Large", speed=30)
g_state = make_state(giant, "p", (0, 3))
dest = make_combatant("d", hp=100)
d_state = make_state(dest, "e", (0, 9))
grid = make_wall_grid()
states = [g_state, d_state]
engine = CombatEngine(
ScriptedRng([20, 1]), grid, states,
round_cap=1, policy=_move_policy,
)
engine.run()
# Cannot move right past column 5 (wall blocks cells (0,5), (1,5))
# Should stay near start or move in a direction that doesn't cross wall
cells = g_state.occupied_cells()
for cell in cells:
assert grid.passable(cell)
# ---------------------------------------------------------------------------
# 6. Flanking: mirror from any occupied cell
# ---------------------------------------------------------------------------
class TestFlankingLarge:
def test_ally_on_mirror_of_occupied_cell_grants_flank(self) -> None:
"""Ally on the mirror position of one of the Large's occupied cells grants +2."""
giant = make_combatant("g", size="Large")
attacker = make_state(giant, "p", (3, 3))
target = make_combatant("t")
t_state = make_state(target, "e", (3, 5))
# Giant occupies (3,3),(3,4),(4,3),(4,4)
# Mirror of (4,4) through target (3,5): 2*3-4=2, 2*5-4=6 -> (2,6)
ally = make_combatant("a")
a_state = make_state(ally, "p", (2, 6))
engine = make_engine([attacker, t_state, a_state])
assert engine._flanking_bonus(attacker, t_state) == 2
# ---------------------------------------------------------------------------
# 7. Charge: Large charge ends where footprint fits
# ---------------------------------------------------------------------------
def _charge_policy(
engine: CombatEngine, state: CombatantState,
) -> tuple[Action, ...]:
return (Action(kind="charge", target_id="d"),)
class TestLargeCharge:
def test_large_can_charge_and_footprint_fits(self) -> None:
"""A Large creature charges and its 2x2 footprint is free at the endpoint."""
giant = make_combatant("g", size="Large", speed=30)
g_state = make_state(giant, "p", (0, 0))
dest = make_combatant("d", hp=100, speed=0)
d_state = make_state(dest, "e", (5, 5))
states = [g_state, d_state]
engine = CombatEngine(
ScriptedRng([20, 10, 20, 10, 10]), make_grid(), states,
round_cap=1, policy=_charge_policy,
)
engine.run()
# After charging, the footprint should be somewhere reachable
assert g_state.active
for cell in g_state.occupied_cells():
assert make_grid().passable(cell)
# ---------------------------------------------------------------------------
# 8. AoO: leaving threatened footprint cell
# ---------------------------------------------------------------------------
class TestLargeAoO:
def test_aoo_when_large_leaves_threatened_cell(self) -> None:
"""When a Large creature moves away, enemies threatening its footprint
get AoOs."""
giant = make_combatant("g", size="Large", speed=30, hp=30, ac=12)
g_state = make_state(giant, "p", (3, 3))
# enemy with reach weapon at (3,6) — threatens ring(2) = dist 2 from (3,6)
# (3,4) is at dist 2 from (3,6), so it's in the threatened zone
enemy = make_combatant("e", reach_ft=10, hp=100, speed=0)
e_state = make_state(enemy, "m", (3, 6))
dest = make_combatant("d", hp=100, speed=0)
d_state = make_state(dest, "e", (0, 0))
states = [g_state, e_state, d_state]
def policy(eng: CombatEngine, st: CombatantState) -> tuple[Action, ...]:
if st is g_state:
return (Action(kind="move", target_id="d"),)
return ()
engine = CombatEngine(
ScriptedRng([20, 10, 10, 15, 5] * 10), make_grid(), states,
round_cap=1, policy=policy,
)
result = engine.run()
# The transcript should show an AoO
aoo_lines = [line for line in result.transcript if "AoO" in line]
# At least one AoO should be generated (enemy threatens (3,4))
assert len(aoo_lines) >= 1
# ---------------------------------------------------------------------------
# 9. LoS/cover from best cell of Large
# ---------------------------------------------------------------------------
class TestLoSLarge:
def test_los_from_best_cell(self) -> None:
"""A Large creature behind a wall can see the target if any cell has LoE."""
grid = make_wall_grid()
# Large at (3,3) occupies (3,3)(3,4)(4,3)(4,4)
# Wall at column 5
# (3,4) and (4,4) are right next to the wall but still on left side
# Target at (3,6) is on the right side of wall
# From (3,4) corner to (3,6) corner: must cross wall at col 5
# No cell of the Large has LoE through the wall to the other side
# This verifies that large doesn't magically see through walls
giant = make_combatant("g", size="Large")
g_state = make_state(giant, "p", (3, 0))
target = make_combatant("t")
t_state = make_state(target, "e", (3, 7))
# No LoE because wall at col 5 blocks all paths
assert not has_line_of_effect(grid, g_state.pos, t_state.pos)
# But from (3,0) to (3,4) which is before the wall: LoE exists
assert has_line_of_effect(grid, (3, 0), (3, 4))
def test_cover_from_best_cell_large(self) -> None:
"""A Large creature's cover check uses the attacker's cells."""
grid = make_grid()
# Large at (3,3), target at (3,5), with a blocking creature at (3,4)
occupied = frozenset({(3, 4)})
# From any cell of the Large, the creature at (3,4) blocks
# The best attacker corner might still find a path around
# This is a basic smoke test that cover still works with occupied sets
result = has_cover(
grid, (3, 3), (3, 5), ranged=True, occupied=occupied,
)
# With a creature directly between, should get cover
assert result is True
# ---------------------------------------------------------------------------
# 10. Equivalence: Medium is 1x1 unchanged
# ---------------------------------------------------------------------------
class TestMediumEquivalence:
def test_medium_occupied_cells_is_pos(self) -> None:
c = make_combatant("m", size="Medium")
s = make_state(c, "a", (5, 5))
assert s.occupied_cells() == frozenset({(5, 5)})
def test_medium_distance_matches_grid(self) -> None:
a = make_state(make_combatant("a"), "p", (0, 0))
b = make_state(make_combatant("b"), "e", (3, 4))
engine = make_engine([a, b])
assert engine.creature_distance(a, b) == engine._grid.distance(a.pos, b.pos)
# ---------------------------------------------------------------------------
# 11. Summon spawn: Large summoned creature finds free footprint
# ---------------------------------------------------------------------------
class TestSummonLarge:
def test_summon_large_needs_free_footprint(self) -> None:
"""A Large summoned creature needs a 2x2 free area near the caster."""
# This is a structural test - we verify the spawn logic exists
# by checking the summon function handles footprint
giant = make_combatant("g", size="Large")
g_state = make_state(giant, "p", (5, 5))
make_engine([g_state])
# Verify SIZE_CELLS is accessible
assert SIZE_CELLS.get("Large") == 2
# ---------------------------------------------------------------------------
# 12. Bull rush: Large target pushed with footprint
# ---------------------------------------------------------------------------
class TestBullRushLarge:
def test_bull_rush_pushes_footprint(self) -> None:
"""Bull rush on a Large target moves the anchor and all cells follow."""
attacker = make_combatant("a", speed=0)
a_state = make_state(attacker, "p", (3, 1))
target = make_combatant("t", size="Large", speed=0)
t_state = make_state(target, "e", (3, 3))
engine = make_engine([a_state, t_state])
# Push direction: away from attacker (3,1) -> (3,3) means push right
result = engine._bull_rush_push(a_state, t_state)
if result:
# Target moved 1 cell right
assert t_state.pos == (3, 4)
assert t_state.occupied_cells() == frozenset({
(3, 4), (3, 5), (4, 4), (4, 5),
})
+2 -2
View File
@@ -35,9 +35,9 @@ def _make_grid() -> Grid:
def _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Emit a channel_energy on an alpha, otherwise the default policy."""
"""Emit a channel_energy (heal mode) on an alpha, otherwise the default policy."""
if state.combatant.id == "oni-a":
return (Action(kind="channel_energy", target_id="oni-b"),)
return (Action(kind="channel_energy", target_id="oni-b", channel_mode="heal"),)
return default_policy(_engine, state)
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the directional ``direction`` field on directional maneuvers.
L2 of the ``llm-policy-contract`` plan: ``Action`` gains a ``direction``
field that lets a policy steer the three directional maneuvers (reposition,
drag, bull_rush) instead of relying on the deterministic default direction
(first free neighbor / beyond the target / away from the attacker).
When ``direction`` is ``None`` the previous deterministic behavior is kept
bit-identical.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
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, SeededRng
def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
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)
# --- Test 1: reposition with direction -------------------------------------
def test_reposition_direction_right() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
assert b.pos == (1, 3)
def test_reposition_direction_down() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(1, 0))
assert b.pos == (2, 2)
# --- Test 2: drag with direction -------------------------------------------
def test_drag_direction_up() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "drag", direction=(-1, 0))
assert b.pos == (0, 2)
assert a.pos == (1, 2)
# --- Test 3: bull_rush with direction --------------------------------------
def test_bull_rush_direction_left() -> None:
a_spec = make_combatant("a")
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(0, -1))
assert b.pos == (1, 1)
# --- Test 4: invalid direction -> no movement -------------------------------
def test_reposition_direction_out_of_bounds_no_move() -> None:
a_spec = make_combatant("a")
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(-1, 0))
assert b.pos == (0, 0)
def test_reposition_direction_occupied_no_move() -> None:
a_spec = make_combatant("a")
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], [a, b])
# direction points at the attacker's own cell -> occupied -> no move
engine._execute_maneuver(a, b, "reposition", direction=(0, -1))
assert b.pos == (1, 2)
def test_bull_rush_direction_out_of_bounds_no_move() -> None:
a_spec = make_combatant("a")
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(-1, 0))
assert b.pos == (0, 0)
# --- Test 5: direction=None keeps previous behavior -------------------------
def test_reposition_none_uses_first_free_neighbor() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "reposition")
# First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell.
assert b.pos == (0, 1)
def test_drag_none_uses_beyond_cell() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "drag")
# Beyond the target from the attacker: (1, 3).
assert b.pos == (1, 3)
assert a.pos == (1, 2)
def test_bull_rush_none_uses_away_from_attacker() -> None:
a_spec = make_combatant("a")
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
# Away from attacker (up, since target is above): (0, 2).
assert b.pos == (0, 2)
# --- Test 6: determinism ----------------------------------------------------
def _run_with_direction(direction: tuple[int, int] | None) -> tuple[str, ...]:
a_spec = make_combatant("a", attack_bonus=5)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "a":
return (
Action(
kind="maneuver", target_id="b", maneuver="reposition",
direction=direction,
),
)
return (Action(kind="wait"),)
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
return engine.run().transcript
def test_determinism_same_seed_same_transcript() -> None:
assert _run_with_direction((0, 1)) == _run_with_direction((0, 1))
assert _run_with_direction(None) == _run_with_direction(None)
def test_direction_changes_transcript() -> None:
assert _run_with_direction((0, 1)) != _run_with_direction(None)
+169
View File
@@ -0,0 +1,169 @@
"""Tests for the ``trick_condition`` field on the dirty trick maneuver.
L6 of the ``llm-policy-contract`` plan: ``Action`` gains a
``trick_condition`` field that lets a policy choose which condition a
successful dirty trick applies (blinded, deafened, entangled, nauseated)
instead of always applying ``dazzled``.
When ``trick_condition`` is ``None`` the previous behavior (``dazzled``) is
kept bit-identical.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
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, SeededRng
def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
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 _condition_names(state: CombatantState) -> list[str]:
return [ac.condition.name for ac in state.active_conditions]
# --- Test 1: explicit trick_condition applies that condition -----------------
def test_dirty_trick_blinded() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == ["blinded"]
# --- Test 2: trick_condition=None keeps historical dazzled -------------------
def test_dirty_trick_default_dazzled() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert _condition_names(b) == ["dazzled"]
# --- Test 3: each other valid condition --------------------------------------
def test_dirty_trick_deafened() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="deafened")
assert _condition_names(b) == ["deafened"]
def test_dirty_trick_entangled() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="entangled")
assert _condition_names(b) == ["entangled"]
def test_dirty_trick_nauseated() -> None:
a_spec = make_combatant("a")
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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="nauseated")
assert _condition_names(b) == ["nauseated"]
# --- Test 4: failed maneuver applies no condition ----------------------------
def test_dirty_trick_failure_no_condition() -> None:
a_spec = make_combatant("a")
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1], [a, b]) # roll 1 -> automatic failure
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == []
# --- Test 5: determinism -----------------------------------------------------
def _run_with_trick_condition(trick_condition: str | None) -> tuple[str, ...]:
a_spec = make_combatant("a", attack_bonus=5)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "a":
return (
Action(
kind="maneuver", target_id="b", maneuver="dirty_trick",
trick_condition=trick_condition,
),
)
return (Action(kind="wait"),)
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
return engine.run().transcript
def test_determinism_same_seed_same_transcript() -> None:
assert _run_with_trick_condition("blinded") == _run_with_trick_condition("blinded")
assert _run_with_trick_condition(None) == _run_with_trick_condition(None)
def test_trick_condition_changes_transcript() -> None:
assert _run_with_trick_condition("blinded") != _run_with_trick_condition(None)
+305
View File
@@ -0,0 +1,305 @@
"""Tests for B5.1 applied elevation: movement cost, high ground, from_spec."""
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, load_map
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng, SeededRng
MAPS_DIR = Path(__file__).resolve().parents[1] / "data" / "maps"
_FLOOR = TerrainType(type="floor", move_cost=1)
_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True)
_LEGEND: dict[str, TerrainType] = {
".": _FLOOR,
"#": _WALL,
}
def _make_floor_grid(
rows: int,
cols: int,
elevation: tuple[tuple[int, ...], ...] | None = None,
) -> Grid:
cells = tuple(tuple(_FLOOR for _ in range(cols)) for _ in range(rows))
return Grid(cells, elevation=elevation)
def _make_combatant(
cid: str,
*,
kind: str = "melee",
attack_bonus: int = 2,
weapon_name: str | None = None,
ac: int = 10,
) -> Combatant:
name = weapon_name or ("short bow" if kind == "ranged" else "short sword")
attack = AttackSpec(
id=f"{cid}-w",
name=name,
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
damage_bonus=0,
crit_range=20,
crit_mult=2,
range_increment_ft=60 if kind == "ranged" else None,
count=1,
)
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=20,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
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
)
# ---------------------------------------------------------------------------
# 1. step_cost: elevation adds climb cost
# ---------------------------------------------------------------------------
def test_step_cost_climb_0_to_1_costs_plus_1() -> None:
elev = ((0, 0), (1, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 2
def test_step_cost_climb_0_to_2_costs_plus_2() -> None:
elev = ((0, 0), (2, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 3
def test_step_cost_descend_costs_no_extra() -> None:
elev = ((2, 0), (0, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
def test_step_cost_flat_unchanged() -> None:
elev = ((0, 0), (0, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
def test_step_cost_no_elevation_layer_unchanged() -> None:
grid = _make_floor_grid(2, 2, elevation=None)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
# ---------------------------------------------------------------------------
# 2. elevation_at
# ---------------------------------------------------------------------------
def test_elevation_at_returns_height() -> None:
elev = ((0, 1, 2), (3, 4, 5))
grid = _make_floor_grid(2, 3, elevation=elev)
assert grid.elevation_at((0, 0)) == 0
assert grid.elevation_at((0, 1)) == 1
assert grid.elevation_at((1, 2)) == 5
def test_elevation_at_no_layer_returns_zero() -> None:
grid = _make_floor_grid(2, 2, elevation=None)
assert grid.elevation_at((0, 0)) == 0
def test_elevation_at_out_of_bounds_returns_zero() -> None:
elev = ((0, 1),)
grid = _make_floor_grid(1, 2, elevation=elev)
assert grid.elevation_at((-1, 0)) == 0
assert grid.elevation_at((0, 5)) == 0
# ---------------------------------------------------------------------------
# 3. reachable: elevation reduces budget reach
# ---------------------------------------------------------------------------
def test_reachable_climbing_costs_more_cells() -> None:
flat_grid = _make_floor_grid(5, 1)
elev_grid = _make_floor_grid(5, 1, elevation=((0,), (0,), (0,), (0,), (1,)))
flat_reached = flat_grid.reachable((0, 0), 4, frozenset())
elev_reached = elev_grid.reachable((0, 0), 4, frozenset())
assert (4, 0) in flat_reached
assert (4, 0) not in elev_reached
# ---------------------------------------------------------------------------
# 4. _move_path greedy: step cost reflects elevation
# ---------------------------------------------------------------------------
def test_reachable_cumulative_cost_includes_climb() -> None:
elev = ((0, 0, 0), (0, 0, 1))
grid = _make_floor_grid(2, 3, elevation=elev)
reached = grid.reachable((0, 0), 10, frozenset())
assert reached[(1, 2)] == 3
# ---------------------------------------------------------------------------
# 5. High ground: melee +1 from higher elevation
# ---------------------------------------------------------------------------
def _make_elevated_combat_engine(
attacker_pos: tuple[int, int],
target_pos: tuple[int, int],
attacker_elev: int,
target_elev: int,
*,
kind: str = "melee",
) -> tuple[CombatEngine, CombatantState, CombatantState]:
height = max(attacker_pos[0], target_pos[0]) + 1
width = max(attacker_pos[1], target_pos[1]) + 1
elev = tuple(
tuple(
attacker_elev
if (r, c) == attacker_pos
else target_elev
if (r, c) == target_pos
else 0
for c in range(width)
)
for r in range(height)
)
grid = _make_floor_grid(height, width, elevation=elev)
atk = _make_combatant("atk", kind=kind)
tgt = _make_combatant("tgt", ac=1)
a_state = _make_state(atk, "players", attacker_pos)
t_state = _make_state(tgt, "monsters", target_pos)
rng = ScriptedRng([10, 10])
engine = CombatEngine(rng, grid, [a_state, t_state])
return engine, a_state, t_state
def test_high_ground_melee_plus_1() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=1, target_elev=0
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total >= 10 + 1 + 2 # d20(10) + high_ground(1) + base(2)
def test_no_high_ground_ranged() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 3), attacker_elev=1, target_elev=0, kind="ranged"
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), no high ground
def test_no_high_ground_attacker_lower() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=0, target_elev=1
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), no high ground
def test_no_high_ground_same_elevation() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=1, target_elev=1
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), same level
# ---------------------------------------------------------------------------
# 6. Grid.from_spec: elevation parsing
# ---------------------------------------------------------------------------
def test_from_spec_with_elevation_layer() -> None:
spec = MapSpec(
name="test",
terrain=("..", ".."),
legend=_LEGEND,
elevation=("01", "2."),
)
grid = Grid.from_spec(spec)
assert grid.elevation_at((0, 0)) == 0
assert grid.elevation_at((0, 1)) == 1
assert grid.elevation_at((1, 0)) == 2
assert grid.elevation_at((1, 1)) == 0
def test_from_spec_without_elevation_layer() -> None:
spec = MapSpec(name="test", terrain=(".",), legend=_LEGEND)
grid = Grid.from_spec(spec)
assert grid.elevation_at((0, 0)) == 0
def test_from_spec_real_map() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
grid = Grid.from_spec(spec)
assert grid.elevation_at((2, 2)) == 0
assert grid.elevation_at((6, 8)) == 1
# ---------------------------------------------------------------------------
# 7. Integration: combat runs on elevation map
# ---------------------------------------------------------------------------
def test_combat_on_elevation_map_runs() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
grid = Grid.from_spec(spec)
atk = _make_combatant("p1")
tgt = _make_combatant("m1")
p1 = _make_state(atk, "players", (2, 2))
m1 = _make_state(tgt, "monsters", (6, 6))
rng = SeededRng(1)
engine = CombatEngine(rng, grid, [p1, m1])
result = engine.run()
assert result.rounds >= 1
assert result.winner is not None or result.rounds <= 100
def test_from_spec_loads_test_elevation_map() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
assert spec.elevation is not None
assert len(spec.elevation) == 10
assert len(spec.elevation[0]) == 12
grid = Grid.from_spec(spec)
assert grid.height == 10
assert grid.width == 12
+323
View File
@@ -0,0 +1,323 @@
"""Tests for L5: public exposure of threatened squares, enemies threatening,
and movement-cost fields (read-only API for a future LLM policy).
Covers:
- ``threatened_squares`` (public, renamed from ``_threatened_squares``): the
disk of effective reach for non-reach weapons and the ring (dead zone) for
reach weapons.
- ``enemies_threatening`` (public, renamed from ``_enemies_threatening``):
active enemies whose threatened squares include a given cell, in
``self._states`` order; allies never count.
- ``reachable_ft``: movement cost in feet from a state's anchor to each
reachable cell (same cost model as ``Grid.reachable``).
- ``movement_cost_ft``: minimal cost in feet to move ``state`` to a cell from
which it can melee ``target``, or ``None`` if unreachable under speed.
- Determinism + non-mutation: the public accessors never mutate state nor
consume RNG.
"""
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 make_combatant(
cid: str,
*,
size: str = "Medium",
reach_ft: int = 5,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
) -> Combatant:
"""Build a single-melee-weapon combatant."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_ft > 5 else "short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
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=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), make_grid(), states)
def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
"""The 8 cells adjacent to ``pos``."""
r, c = pos
return {
(r + dr, c + dc)
for dr in (-1, 0, 1)
for dc in (-1, 0, 1)
if (dr, dc) != (0, 0)
}
def _ring(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
"""Cells at exactly ``radius`` squares from ``pos`` (5-10-5 distance)."""
r, c = pos
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0)
and max(abs(dr), abs(dc)) + min(abs(dr), abs(dc)) // 2 == radius
}
# ---------------------------------------------------------------------------
# 1. threatened_squares (public)
# ---------------------------------------------------------------------------
def test_threatened_squares_public_non_reach_disk() -> None:
"""A Medium combatant with a 5 ft weapon threatens exactly the 8 adjacent squares."""
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
def test_threatened_squares_public_reach_dead_zone() -> None:
"""A longspear (reach 10 ft) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10)
state = make_state(spearman, "players", (3, 3))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
assert threatened == frozenset(_ring((3, 3), 2))
assert (3, 2) not in threatened # adjacent orthogonal (dead zone)
assert (3, 4) not in threatened
assert (2, 2) not in threatened # adjacent diagonal (dead zone)
assert (4, 4) not in threatened
# ---------------------------------------------------------------------------
# 2. enemies_threatening (public)
# ---------------------------------------------------------------------------
def test_enemies_threatening_public_order_and_allies() -> None:
"""Only active enemies whose threatened squares include pos appear, in
self._states order; allies never count."""
hero = make_combatant("hero")
foe_near = make_combatant("foe_near")
foe_far = make_combatant("foe_far")
ally = make_combatant("ally")
hero_state = make_state(hero, "players", (3, 3))
# foe_near adjacent to (3,3) -> threatens it
foe_near_state = make_state(foe_near, "monsters", (3, 4))
# foe_far far away -> does not threaten (3,3)
foe_far_state = make_state(foe_far, "monsters", (6, 6))
# ally adjacent but same side -> never counts
ally_state = make_state(ally, "players", (4, 3))
engine = make_engine([hero_state, foe_near_state, foe_far_state, ally_state])
result = engine.enemies_threatening(hero_state, (3, 3))
assert result == [foe_near_state]
def test_enemies_threatening_public_empty_when_none() -> None:
"""No enemies threatening a cell -> empty list."""
hero = make_combatant("hero")
foe_far = make_combatant("foe_far")
hero_state = make_state(hero, "players", (3, 3))
foe_far_state = make_state(foe_far, "monsters", (6, 6))
engine = make_engine([hero_state, foe_far_state])
assert engine.enemies_threatening(hero_state, (3, 3)) == []
# ---------------------------------------------------------------------------
# 3. reachable_ft
# ---------------------------------------------------------------------------
def test_reachable_ft_costs_and_budget() -> None:
"""reachable_ft returns movement cost in feet; budget is in feet."""
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (0, 0))
engine = make_engine([state])
field = engine.reachable_ft(state)
assert field[(0, 0)] == 0
assert field[(0, 1)] == 5 # orthogonal 1 cell
assert field[(1, 1)] == 5 # first diagonal = 1 cell (5-10-5)
assert field[(0, 2)] == 10 # orthogonal 2 cells
# budget in feet: cells with cost <= 15 present, beyond absent
budgeted = engine.reachable_ft(state, budget=15)
assert (0, 1) in budgeted
assert (1, 1) in budgeted
assert (0, 2) in budgeted
assert (0, 3) in budgeted # 3 orthogonal cells = 15 ft, within budget
assert (0, 4) not in budgeted # 4 cells = 20 ft > 15
def test_reachable_ft_difficult_terrain() -> None:
"""A difficult-terrain cell (move_cost 2) costs 10 ft."""
legend = {".": TerrainType(type="floor", move_cost=1),
"D": TerrainType(type="rubble", move_cost=2)}
spec = MapSpec(
name="test",
terrain=(
".D......",
"........",
"........",
"........",
"........",
"........",
"........",
"........",
),
legend=legend,
)
grid = Grid.from_spec(spec)
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), grid, [state])
field = engine.reachable_ft(state)
assert field[(0, 1)] == 10 # difficult terrain cell costs 2 cells = 10 ft
# ---------------------------------------------------------------------------
# 4. movement_cost_ft
# ---------------------------------------------------------------------------
def test_movement_cost_ft_distance() -> None:
"""movement_cost_ft returns the minimal cost in feet to reach a cell from
which state can melee target (state 2 cells away -> ~10 ft)."""
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (0, 2)) # 2 cells away
engine = make_engine([state, target_state])
cost = engine.movement_cost_ft(state, target_state)
assert cost == 10
def test_movement_cost_ft_unreachable_returns_none() -> None:
"""If the target is sealed behind impassable walls, movement_cost_ft is None."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None),
}
walled = MapSpec(
name="test",
terrain=(
"........",
"........",
"..####..",
"..#..#..",
"..#..#..",
"..####..",
"........",
"........",
),
legend=legend,
)
grid = Grid.from_spec(walled)
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
# state outside the box, target inside -> no path
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (3, 3))
engine = CombatEngine(ScriptedRng([]), grid, [state, target_state])
assert engine.movement_cost_ft(state, target_state) is None
# ---------------------------------------------------------------------------
# 5. Determinism + non-mutation
# ---------------------------------------------------------------------------
def test_public_accessors_do_not_mutate_state() -> None:
"""Calling the public accessors leaves state and engine unchanged."""
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (0, 2))
engine = make_engine([state, target_state])
pos_before = state.pos
hp_before = state.hp
states_before = list(engine._states)
engine.threatened_squares(state)
engine.enemies_threatening(state, (0, 1))
engine.reachable_ft(state)
engine.movement_cost_ft(state, target_state)
assert state.pos == pos_before
assert state.hp == hp_before
assert list(engine._states) == states_before
def test_public_accessors_deterministic_two_runs() -> None:
"""Two runs with the same seed and default policy produce identical
transcripts (the resolution engine still uses the renamed private logic)."""
def run() -> tuple:
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
states = [
make_state(fighter, "players", (0, 0)),
make_state(target, "monsters", (0, 3)),
]
eng = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1,
)
return eng.run().transcript
assert run() == run()
+353
View File
@@ -0,0 +1,353 @@
"""Tests for off-turn immediate actions (B5.4).
A ``reaction_policy`` hook lets a combatant act outside its own turn with an
``Action(kind="immediate")``. Per PF1e, an immediate action consumes the swift
action of the combatant's next turn. The hook is opt-in: without a
``reaction_policy`` the engine behaves exactly as before.
"""
from __future__ import annotations
from typing import Literal
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
CombatResult,
ReactionEvent,
ReactionPolicy,
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 DamageEffect, SpellSpec
def make_combatant(
cid: str,
*,
hp: int = 30,
ac: int = 15,
attack_bonus: int = 5,
damage: str = "1d6",
initiative_mod: int = 0,
speed: int = 30,
kind: Literal["melee", "ranged"] = "melee",
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="short bow" if kind == "ranged" else "short sword",
kind=kind,
attack_bonus=attack_bonus,
damage=[
DamageComponent(
formula=parse_dice(damage),
types=["piercing"] if kind == "ranged" else ["slashing"],
)
],
range_increment_ft=60 if kind == "ranged" else None,
)
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=attack_bonus,
initiative_mod=initiative_mod,
speed_land_ft=speed,
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 test_reaction_policy_records_events() -> None:
"""A reaction policy is invoked for movement and attack events."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 6))]
events: list[ReactionEvent] = []
def approach_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (
Action(kind="move", target_id="goblin"),
Action(kind="attack", target_id="goblin"),
)
return default_policy(_engine, state)
def recording_policy(
_engine: CombatEngine,
_state: CombatantState,
event: ReactionEvent,
) -> Action | None:
events.append(event)
return None
engine = CombatEngine(
SeededRng(3),
make_grid(),
states,
policy=approach_policy,
reaction_policy=recording_policy,
)
engine.run()
assert any(e.kind == "movement_step" for e in events)
assert any(e.kind == "attack_resolved" for e in events)
def test_no_reaction_policy_runs_unchanged() -> None:
"""A no-op reaction policy leaves the transcript bit-identical."""
def make_run(reaction_policy: ReactionPolicy | None = None) -> CombatResult:
states = [
make_state(make_combatant("hero", initiative_mod=5), "players", (1, 1)),
make_state(make_combatant("goblin"), "monsters", (1, 2)),
]
return CombatEngine(
SeededRng(9), make_grid(), states, reaction_policy=reaction_policy
).run()
def noop_policy(
_engine: CombatEngine,
_state: CombatantState,
_event: ReactionEvent,
) -> Action | None:
return None
assert make_run(reaction_policy=noop_policy).transcript == make_run().transcript
def test_immediate_attack_reaction_executes() -> None:
"""A waiting ally can react to an enemy attack with an immediate attack."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
goblin_state = make_state(goblin, "monsters", (1, 2))
states = [hero_state, goblin_state]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.target is not None
and event.target.combatant.id == "hero"
):
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("hero: short sword vs goblin" in line for line in result.transcript)
assert goblin_state.hp < 30
def test_immediate_consumes_next_swift() -> None:
"""An immediate action consumes the swift action of the next turn."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
hero_state.resources_left = {"grit": 2}
states = [hero_state, make_state(goblin, "monsters", (1, 2))]
reacted = False
def deed_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
if state.resources_left.get("grit", 0) > 0:
return (
Action(
kind="deed",
target_id="goblin",
deed_name="up_close_and_deadly",
),
Action(kind="attack", target_id="goblin"),
)
return (Action(kind="attack", target_id="goblin"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
nonlocal reacted
if state.combatant.id == "hero" and event.kind == "attack_resolved" and not reacted:
reacted = True
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=deed_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
deed_rounds = [
int(line.split()[1])
for line in result.transcript
if "hero: deed Up Close and Deadly activated" in line
]
assert deed_rounds == [1, 3]
def test_immediate_in_turn_sequence_executes() -> None:
"""An immediate action in the turn sequence executes like an attack."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
goblin_state = make_state(goblin, "monsters", (1, 2))
states = [hero_state, goblin_state]
def immediate_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="immediate", target_id="goblin"),)
return default_policy(_engine, state)
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=immediate_policy,
)
result = engine.run()
assert any("hero: short sword vs goblin" in line for line in result.transcript)
assert goblin_state.hp < 30
def test_reaction_determinism_same_seed_identical_transcripts() -> None:
"""Reactions draw from the engine RNG: same seed, identical transcripts."""
def make_run() -> CombatResult:
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 2))]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.target is not None
and event.target.combatant.id == "hero"
):
return Action(kind="immediate", target_id="goblin")
return None
return CombatEngine(
SeededRng(7),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
).run()
result_one = make_run()
result_two = make_run()
assert result_one.transcript == result_two.transcript
assert result_one.transcript
assert any("hero: short sword vs goblin" in line for line in result_one.transcript)
def test_damage_taken_reaction_fires() -> None:
"""A reaction policy can react to spell damage taken."""
caster = make_combatant("caster", hp=50)
reactor = make_combatant("reactor", hp=50, kind="ranged")
caster_state = make_state(caster, "players", (1, 1))
reactor_state = make_state(reactor, "monsters", (1, 3))
states = [caster_state, reactor_state]
bolt = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="close",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("force",)),),
)
def cast_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "caster":
return (Action(kind="cast_spell", target_id="reactor", spell_name="Bolt"),)
if state.combatant.id == "reactor":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "reactor" and event.kind == "damage_taken":
return Action(kind="immediate", target_id="caster")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=cast_policy,
spell_registry={"Bolt": bolt},
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("reactor: short bow vs caster" in line for line in result.transcript)
assert caster_state.hp < 50
+130
View File
@@ -116,3 +116,133 @@ def test_escape_from_grapple_consumes_single_d20() -> None:
assert not engine._rng._queue
assert a.grappled_with is None
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
def test_overrun_knocks_prone_and_moves_through() -> 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], [a, b])
engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1
assert prone.source == "maneuver: overrun"
assert a.pos == (1, 3)
def test_overrun_prone_but_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1)
assert "SUCCESS (prone)" in engine._transcript[-1]
assert "moved through" not in engine._transcript[-1]
def test_dirty_trick_dazzles() -> 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], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1
assert dazzled.source == "maneuver: dirty trick"
def test_dirty_trick_fails_on_natural_1() -> 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([1], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" for c in b.active_conditions)
assert engine._transcript[-1].endswith("-> FAILED")
def test_steal_takes_melee_weapon() -> 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, "steal")
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
assert b.hp == hp_before
def test_steal_nothing_to_steal() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b").model_copy(update={"attacks": []})
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert not b.weapon_stolen
assert "nothing to steal" in engine._transcript[-1]
def test_reposition_moves_to_first_free_neighbor() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1)
assert c.pos == (1, 3)
def test_reposition_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
blocker_positions = [(0, 0), (0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 2)]
blockers = [
make_state(make_combatant(f"c{idx}"), "monsters", pos)
for idx, pos in enumerate(blocker_positions)
]
a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 1))
engine = make_engine([12], [a, b, *blockers])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1]
def test_drag_moves_pair_together() -> 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], [a, b])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3)
assert a.pos == (1, 2)
def test_drag_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2)
assert a.pos == (1, 1)
assert "no space" in engine._transcript[-1]
+5 -1
View File
@@ -46,7 +46,11 @@ def test_sample_arena_loads() -> None:
def test_sample_arena_zones_and_elevation() -> None:
spec = load_map(MAPS_DIR / "sample_arena.yaml")
assert spec.elevation is not None
assert spec.elevation[1][2] == "1"
# Flattened to '.' — platform removed so elevation has no mechanical effect
assert spec.elevation[1][2] == "."
assert all(
ch == "." for row in spec.elevation for ch in row
)
cells_a = zone_cells(spec, "A")
assert cells_a == [
(1, 1), (1, 2), (1, 3), (1, 4),
+205
View File
@@ -0,0 +1,205 @@
"""Tests for L3 of the ``llm-policy-contract`` plan: multiple natural weapons
in a single full_attack.
Previously ``_full_attack`` only struck with the single weapon returned by
``weapon_for`` (the first usable one). A combatant with several natural
weapons (e.g. "bite" + "2x Talons") only struck with the first in a
full_attack. This module locks in the new behaviour: ``_full_attack`` iterates
over every melee weapon in ``usable_attacks()``, resolves each one's swings,
and excludes ranged/touch weapons from the loop.
"""
from __future__ import annotations
from typing import Literal
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
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, SeededRng
def _attack(
cid: str, name: str, *, kind: Literal["melee", "ranged", "touch"] = "melee",
count: int = 1, attack_bonus: int = 2,
) -> AttackSpec:
return AttackSpec(
id=f"{cid}-{name.replace(' ', '-')}",
name=name,
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
count=count,
)
def make_combatant(
cid: str, attacks: list[AttackSpec], *, bab: int = 1, hp_max: int = 20,
) -> Combatant:
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=13, touch=13, flat_footed=13),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=attacks,
)
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 _full_attack_transcript(
attacker: CombatantState, target: CombatantState, queue: list[int],
) -> list[str]:
engine = make_engine(queue, [attacker, target])
engine._execute(attacker, Action(kind="full_attack", target_id=target.combatant.id))
return list(engine._transcript)
def _labels(transcript: list[str]) -> list[str]:
"""Extract the swing labels (e.g. "bite", "claw #1") from swing log lines."""
return [
line.split(": ", 1)[1].split(" vs ", 1)[0]
for line in transcript
if " vs " in line and " d20=" in line
]
# --- Test 1: multiple melee weapons all resolve in one full_attack ----------
def test_full_attack_resolves_all_melee_weapons() -> None:
a_spec = make_combatant(
"a",
[
_attack("a", "bite", count=1),
_attack("a", "claw", count=2),
],
bab=1,
hp_max=20,
)
b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
# bite (1 swing) + claw (2 swings): each swing = attack roll + damage roll.
transcript = _full_attack_transcript(a, b, [15, 2, 15, 2, 15, 2])
labels = _labels(transcript)
assert labels == ["bite", "claw #1", "claw #2"]
# --- Test 2: ranged weapons are excluded from the full_attack loop ----------
def test_full_attack_excludes_ranged_weapon() -> None:
a_spec = make_combatant(
"a",
[
_attack("a", "sword", count=1),
_attack("a", "short bow", kind="ranged", attack_bonus=4),
],
bab=1,
hp_max=20,
)
b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
transcript = _full_attack_transcript(a, b, [15, 2])
labels = _labels(transcript)
assert labels == ["sword"]
assert not any("short bow" in line for line in transcript)
# --- Test 3: mono-weapon full_attack keeps its labels and swing count -------
def test_full_attack_mono_weapon_labels_and_count() -> None:
# BAB 6 -> n = 2 iteratives, labels "sword #1" / "sword #2".
a_spec = make_combatant("a", [_attack("a", "sword", count=1)], bab=6, hp_max=20)
b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
transcript = _full_attack_transcript(a, b, [15, 2, 15, 2])
assert _labels(transcript) == ["sword #1", "sword #2"]
# BAB 1 -> n = 1, single label "sword" (no "#1").
a_spec = make_combatant("a", [_attack("a", "sword", count=1)], bab=1, hp_max=20)
a = make_state(a_spec, "players", (1, 1))
transcript = _full_attack_transcript(a, b, [15, 2])
assert _labels(transcript) == ["sword"]
# --- Test 4: remaining swings/weapons are lost when the target drops --------
def test_full_attack_stops_when_target_drops() -> None:
a_spec = make_combatant(
"a",
[
_attack("a", "bite", count=1),
_attack("a", "claw", count=2),
],
bab=1,
hp_max=20,
)
b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=1)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
# bite hits for 2 -> target (1 hp) drops; claw swings are lost.
transcript = _full_attack_transcript(a, b, [15, 2])
labels = _labels(transcript)
assert labels == ["bite"]
assert not any("claw" in line for line in transcript)
# --- Test 5: determinism ----------------------------------------------------
def _run_full_attack() -> tuple[str, ...]:
a_spec = make_combatant(
"a",
[
_attack("a", "bite", count=1),
_attack("a", "claw", count=2),
],
bab=1,
hp_max=20,
)
b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "a":
return (Action(kind="full_attack", target_id="b"),)
return (Action(kind="wait"),)
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
return engine.run().transcript
def test_full_attack_determinism_same_seed_same_transcript() -> None:
assert _run_full_attack() == _run_full_attack()
+497
View File
@@ -0,0 +1,497 @@
"""Tests for L1: serializable Observation snapshot + LLMPolicy adapter.
Covers the pinned schema:
- Per-combatant fields (id, side, pos, occupied_cells, hp/hp_max, active/dead,
conditions, resources_left + spell_slot_* keys, weapon_broken/weapon_stolen,
grappled_with, moved_this_turn, power_attack).
- Per-pair fields (target_index, distance_ft, weapon_for, range_penalty,
can_charge, can_step5_to_attack, has_line_of_effect, flanking).
- Terrain/elevation layers.
- Frozen copies: mutating live state after construction does not change the
already-built Observation.
- ``to_dict()`` is JSON-serializable and exposes no ``engine.*`` callables.
- The ``llm_policy`` adapter builds the Observation at the point of policy
invocation and delegates to ``llm_policy(observation, state)``.
"""
from __future__ import annotations
import json
import pytest
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, Observation
from pf1e_simulator.conditions import SHAKEN
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
def make_combatant(
cid: str,
*,
size: str = "Medium",
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
ranged: bool = False,
) -> Combatant:
"""Build a single-weapon combatant (melee by default, ranged if requested)."""
if ranged:
attack = AttackSpec(
id=f"{cid}-w",
name="short bow",
kind="ranged",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=60,
)
else:
attack = AttackSpec(
id=f"{cid}-w",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
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=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(*, elevation: bool = False) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="test",
terrain=tuple(["." * 8] * 8),
legend=legend,
elevation=tuple(["0" * 8] * 8) if elevation else None,
)
return Grid.from_spec(spec)
def make_engine(states: list[CombatantState], *, grid: Grid | None = None) -> CombatEngine:
return CombatEngine(ScriptedRng([]), grid or make_grid(), states)
def _snapshot(engine: CombatEngine, state: CombatantState) -> dict:
return engine._build_observation(state).to_dict()
# ---------------------------------------------------------------------------
# 1. Basic 1v1 schema — all combatant fields present and correct
# ---------------------------------------------------------------------------
def test_observation_basic_schema_1v1() -> None:
"""Given: a simple 1v1 with default state
When: an Observation is built for the acting combatant
Then: every per-combatant field is present and correct."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
assert obs["round"] == 0
assert obs["acting_combatant"] == 0
assert len(obs["combatants"]) == 2
c = obs["combatants"][0]
assert c["id"] == "hero"
assert c["side"] == "players"
assert c["pos"] == (0, 0)
assert c["occupied_cells"] == ((0, 0),)
assert c["hp"] == 20
assert c["hp_max"] == 20
assert c["active"] is True
assert c["dead"] is False
assert c["conditions"] == ()
assert c["resources_left"] == {}
assert c["weapon_broken"] == ()
assert c["weapon_stolen"] == ()
assert c["grappled_with"] is None
assert c["moved_this_turn"] is False
assert c["power_attack"] is False
# ---------------------------------------------------------------------------
# 2. Conditions → (name, rounds_left) tuples
# ---------------------------------------------------------------------------
def test_observation_conditions() -> None:
"""Given: a combatant with an active condition (shaken 2 rounds)
When: an Observation is built
Then: conditions contains (name, rounds_left)."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
hero_state.active_conditions.append(
ActiveCondition(condition=SHAKEN, rounds_left=2, source="test")
)
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
c = _snapshot(engine, hero_state)["combatants"][0]
assert c["conditions"] == (("shaken", 2),)
# ---------------------------------------------------------------------------
# 3. weapon_broken / weapon_stolen listed
# ---------------------------------------------------------------------------
def test_observation_weapon_broken_stolen() -> None:
"""Given: a combatant with a broken and a stolen weapon
When: an Observation is built
Then: weapon_broken and weapon_stolen list the ids (sorted)."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
hero_state.weapon_broken = {"hero-w"}
hero_state.weapon_stolen = {"hero-w"}
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
c = _snapshot(engine, hero_state)["combatants"][0]
assert c["weapon_broken"] == ("hero-w",)
assert c["weapon_stolen"] == ("hero-w",)
# ---------------------------------------------------------------------------
# 4. grappled_with / moved_this_turn / power_attack reflected
# ---------------------------------------------------------------------------
def test_observation_grappled_moved_power_attack() -> None:
"""Given: a combatant that is grappled, has moved, and uses power attack
When: an Observation is built
Then: the three flags are reflected."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
hero_state.grappled_with = "foe"
hero_state.moved_this_turn = True
hero_state.power_attack = True
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
c = _snapshot(engine, hero_state)["combatants"][0]
assert c["grappled_with"] == "foe"
assert c["moved_this_turn"] is True
assert c["power_attack"] is True
# ---------------------------------------------------------------------------
# 5. Pairs: distance_ft, weapon_for, range_penalty
# ---------------------------------------------------------------------------
def test_observation_pairs_distance_weapon_range_penalty() -> None:
"""Given: a melee attacker adjacent to a target and a ranged attacker at
the second range increment
When: an Observation is built
Then: distance_ft is 5 for adjacent, weapon_for is the weapon name,
range_penalty is -2 for a ranged weapon at the second increment."""
# Adjacent melee pair
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 1))
engine = make_engine([hero_state, foe_state])
pairs = _snapshot(engine, hero_state)["pairs"]
assert len(pairs) == 1
p = pairs[0]
assert p["target_index"] == 1
assert p["distance_ft"] == 5
assert p["weapon_for"] == "short sword"
assert p["range_penalty"] == 0
# Ranged attacker at 15 cells (75 ft) with 60 ft increment → second increment
archer = make_combatant("archer", ranged=True)
target = make_combatant("target")
archer_state = make_state(archer, "players", (0, 0))
target_state = make_state(target, "monsters", (0, 15))
engine2 = make_engine([archer_state, target_state])
p2 = _snapshot(engine2, archer_state)["pairs"][0]
assert p2["distance_ft"] == 75
assert p2["weapon_for"] == "short bow"
assert p2["range_penalty"] == -2
# ---------------------------------------------------------------------------
# 6. Pairs: can_charge and has_line_of_effect
# ---------------------------------------------------------------------------
def test_observation_pairs_can_charge_and_los() -> None:
"""Given: a clear straight line vs a wall blocking LoS
When: an Observation is built
Then: can_charge is True on the clear line, False behind the wall, and
has_line_of_effect is False behind blocks_los."""
# Clear straight line: 3 cells apart, no obstacles
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
p = _snapshot(engine, hero_state)["pairs"][0]
assert p["can_charge"] is True
assert p["has_line_of_effect"] is True
# Wall between them blocks LoS and charge
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
walled = MapSpec(
name="test",
terrain=(
"........",
"........",
"..####..",
"..#..#..",
"..#..#..",
"..####..",
"........",
"........",
),
legend=legend,
)
grid = Grid.from_spec(walled)
hero2 = make_combatant("hero2")
foe2 = make_combatant("foe2")
hero2_state = make_state(hero2, "players", (0, 0))
foe2_state = make_state(foe2, "monsters", (3, 3))
engine2 = CombatEngine(ScriptedRng([]), grid, [hero2_state, foe2_state])
p2 = _snapshot(engine2, hero2_state)["pairs"][0]
assert p2["can_charge"] is False
assert p2["has_line_of_effect"] is False
# ---------------------------------------------------------------------------
# 7. Pairs: flanking
# ---------------------------------------------------------------------------
def test_observation_pairs_flanking() -> None:
"""Given: an ally on the opposite side of the target
When: an Observation is built
Then: flanking is True for the melee attacker."""
hero = make_combatant("hero")
foe = make_combatant("foe")
ally = make_combatant("ally")
hero_state = make_state(hero, "players", (3, 3))
foe_state = make_state(foe, "monsters", (3, 4))
ally_state = make_state(ally, "players", (3, 5))
engine = make_engine([hero_state, foe_state, ally_state])
p = _snapshot(engine, hero_state)["pairs"][0]
assert p["target_index"] == 1
assert p["flanking"] is True
# ---------------------------------------------------------------------------
# 8. Terrain / elevation layers
# ---------------------------------------------------------------------------
def test_observation_terrain_elevation() -> None:
"""Given: a map with and without an elevation layer
When: an Observation is built
Then: terrain is filled with type names; elevation has values when present
and () when absent."""
# With elevation
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="test",
terrain=("..", ".."),
legend=legend,
elevation=("01", "00"),
)
grid = Grid.from_spec(spec)
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (1, 1))
engine = CombatEngine(ScriptedRng([]), grid, [hero_state, foe_state])
obs = _snapshot(engine, hero_state)
assert obs["terrain"] == (("floor", "floor"), ("floor", "floor"))
assert obs["elevation"] == ((0, 1), (0, 0))
# Without elevation
hero2 = make_combatant("hero2")
foe2 = make_combatant("foe2")
hero2_state = make_state(hero2, "players", (0, 0))
foe2_state = make_state(foe2, "monsters", (1, 1))
engine2 = make_engine([hero2_state, foe2_state])
obs2 = _snapshot(engine2, hero2_state)
assert obs2["terrain"] == (("floor",) * 8,) * 8
assert obs2["elevation"] == ()
# ---------------------------------------------------------------------------
# 9. to_dict() is JSON-serializable and exposes no engine.* callables
# ---------------------------------------------------------------------------
def test_observation_to_dict_json_serializable_no_engine_ref() -> None:
"""Given: a built Observation
When: to_dict() is called
Then: json.dumps succeeds and the dict contains only builtins (no
engine.* callables, no mutable shared references)."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = engine._build_observation(hero_state)
data = obs.to_dict()
json.dumps(data) # must not raise
# No callable values anywhere in the serialized dict
def assert_no_callables(value: object) -> None:
assert not callable(value), f"callable exposed in snapshot: {value!r}"
if isinstance(value, dict):
for v in value.values():
assert_no_callables(v)
elif isinstance(value, (list, tuple)):
for v in value:
assert_no_callables(v)
assert_no_callables(data)
# ---------------------------------------------------------------------------
# 10. Frozen copies: mutating live state after construction
# ---------------------------------------------------------------------------
def test_observation_frozen_copies() -> None:
"""Given: an Observation already built
When: the live state's weapon_broken, resources_left, and the grid change
Then: the already-built Observation does not change."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = engine._build_observation(hero_state)
data_before = obs.to_dict()
# Mutate live state after construction
hero_state.weapon_broken.add("hero-w")
hero_state.resources_left["rage"] = 3
hero_state.spell_slots_left[1] = 2
hero_state.hp = 5
hero_state.pos = (5, 5)
data_after = obs.to_dict()
assert data_after == data_before
assert data_after["combatants"][0]["hp"] == 20
assert data_after["combatants"][0]["pos"] == (0, 0)
assert data_after["combatants"][0]["weapon_broken"] == ()
assert data_after["combatants"][0]["resources_left"] == {}
# ---------------------------------------------------------------------------
# 11. LLMPolicy adapter e2e
# ---------------------------------------------------------------------------
def test_llm_policy_wrapper_e2e() -> None:
"""Given: a CombatEngine with an llm_policy that reads ONLY the observation
When: the battle runs
Then: the policy's decisions are honored and the transcript is coherent."""
def llm_policy(obs: Observation, state: CombatantState) -> tuple:
# Read only the observation: full_attack if the target is adjacent,
# otherwise move toward it.
target_id = obs.combatants[obs.pairs[0]["target_index"]]["id"]
if obs.pairs and obs.pairs[0]["distance_ft"] <= 5:
return (Action(kind="full_attack", target_id=target_id),)
return (Action(kind="move", target_id=target_id),)
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(),
[hero_state, foe_state],
round_cap=1,
llm_policy=llm_policy,
)
result = engine.run()
# The hero is 3 cells away → moves first, then attacks on a later turn.
assert any("move" in line for line in result.transcript)
# ---------------------------------------------------------------------------
# 12. policy + llm_policy → ValueError
# ---------------------------------------------------------------------------
def test_llm_policy_and_policy_value_error() -> None:
"""Given: both policy and llm_policy provided
When: the engine is constructed
Then: ValueError is raised."""
hero = make_combatant("hero")
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
def llm_policy(obs: Observation, state: CombatantState) -> tuple:
return ()
with pytest.raises(ValueError, match="mutually exclusive"):
CombatEngine(
ScriptedRng([]),
make_grid(),
[hero_state, foe_state],
policy=lambda _e, _s: (),
llm_policy=llm_policy,
)
+1 -1
View File
@@ -115,7 +115,7 @@ class TestGoldenCombats:
"(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 "
("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 16 -> HIT 25 damage "
"(20->-5) paralyzed (11r)"),
"round 6 lich: tammara down",
"battle over: monsters win in 6 rounds",
+292
View File
@@ -0,0 +1,292 @@
"""Tests for B5.3: melee reach depends on weapon reach and creature size.
Covers the PF1e reach rules implemented in this phase:
- non-reach weapons (``reach_ft == 5``) threaten the disk of effective reach;
- reach weapons (``reach_ft > 5``) threaten only the ring at exactly their
reach (the PF1e reach dead zone: a longspear cannot hit an adjacent target);
- a Large creature's natural reach (10 ft) extends its non-reach weapons;
- ``weapon_for`` respects the reach dead zone;
- AoOs trigger from ring squares threatened by a reach weapon.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, 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 make_combatant(
cid: str,
*,
size: str = "Medium",
reach_ft: int = 5,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
) -> Combatant:
"""Build a single-melee-weapon combatant (used by most reach tests)."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_ft > 5 else "short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
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=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), make_grid(), states)
def _grid_distance(a: tuple[int, int], b: tuple[int, int]) -> int:
"""5-10-5 diagonal distance in squares (mirrors Grid.distance)."""
d_row = abs(a[0] - b[0])
d_col = abs(a[1] - b[1])
return max(d_row, d_col) + min(d_row, d_col) // 2
def _ring(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
"""Cells at exactly ``radius`` squares from ``pos`` (5-10-5 distance)."""
r, c = pos
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) == radius
}
def _disk(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
"""Cells within ``radius`` squares of ``pos`` (5-10-5 distance), excluding pos."""
r, c = pos
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) <= radius
}
def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
"""The 8 cells adjacent to ``pos``."""
return _disk(pos, 1)
def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
"""A Medium combatant with a 5 ft weapon threatens exactly the 8 adjacent squares."""
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
"""A longspear (reach 10 ft) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10)
state = make_state(spearman, "players", (3, 3))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
assert threatened == frozenset(_ring((3, 3), 2))
assert (3, 2) not in threatened # adjacent orthogonal
assert (3, 4) not in threatened
assert (2, 2) not in threatened # adjacent diagonal
assert (4, 4) not in threatened
def test_sword_plus_reach_weapon_union() -> None:
"""A combatant with a 5 ft sword and a reach weapon threatens disk(1) plus ring(2)."""
sword = AttackSpec(
id="sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
reach_ft=5,
)
spear = AttackSpec(
id="spear",
name="longspear",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=10,
)
combatant = Combatant(
id="dual",
name="dual",
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=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=5,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[sword, spear],
)
state = make_state(combatant, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(
_adjacent((3, 3)) | _ring((3, 3), 2)
)
def test_large_creature_sword_threatens_disk_radius_2() -> None:
"""A Large creature (2x2 footprint, natural reach 10 ft) with a 5 ft sword
threatens the union of radius-2 disks around each occupied cell."""
giant = make_combatant("giant", size="Large")
state = make_state(giant, "players", (3, 3))
engine = make_engine([state])
footprint = state.occupied_cells()
expected = set()
for cell in footprint:
expected |= _disk(cell, 2)
expected -= footprint
assert engine.threatened_squares(state) == frozenset(expected)
def test_unknown_size_defaults_to_5ft() -> None:
"""An unlisted size falls back to the current 5 ft natural reach."""
weird = make_combatant("weird", size="Weird")
state = make_state(weird, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
"""weapon_for selects a reach weapon at distance 2 but not at distance 1 (dead zone)."""
spearman = make_combatant("spearman", reach_ft=10)
target = make_combatant("target")
attacker = make_state(spearman, "players", (3, 3))
foe_at_ring = make_state(target, "monsters", (3, 5)) # distance 2
engine_ring = make_engine([attacker, foe_at_ring])
weapon = engine_ring.weapon_for(attacker, foe_at_ring)
assert weapon is not None
assert weapon.reach_ft == 10
foe_adjacent = make_state(target, "monsters", (3, 4)) # distance 1
engine_adjacent = make_engine([attacker, foe_adjacent])
assert engine_adjacent.weapon_for(attacker, foe_adjacent) is None
def test_weapon_for_large_creature_sword_at_distance_2() -> None:
"""A Large creature's 5 ft sword is selectable at distance 2 (natural reach 10 ft)."""
giant = make_combatant("giant", size="Large")
target = make_combatant("target")
attacker = make_state(giant, "players", (3, 3))
foe = make_state(target, "monsters", (3, 5)) # distance 2
engine = make_engine([attacker, foe])
weapon = engine.weapon_for(attacker, foe)
assert weapon is not None
assert weapon.reach_ft == 5
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Test policy: always move toward the 'dest' combatant."""
return (Action(kind="move", target_id="dest"),)
def test_aoo_from_reach_ring() -> None:
"""Moving through a ring square threatened by a longspear provokes an AoO."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 3)),
make_state(spearman, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: spearman d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 spearman: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 runner: move (4,3)->(3,4)",
"round 1 spearman: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
"""A longspear does not threaten adjacent squares: no AoO when leaving one."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),
make_state(spearman, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 3)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: spearman d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 runner: move (4,1)->(3,2)",
"round 1 spearman: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the ``repair_weapon`` action (repairing a broken weapon).
L8 of the ``llm-policy-contract`` plan: ``Action`` gains a ``repair_weapon``
kind (reusing the existing ``weapon_id`` field) so a policy can restore a
weapon that a sunder maneuver broke. The weapon is removed from
``state.weapon_broken``, its accumulated damage is reset, and the transient
``disarmed`` condition originating from the sunder is removed so the repaired
weapon is fully usable again via ``usable_attacks()``.
No default policy emits ``repair_weapon``; it is only accepted by ``_execute``
(usage via custom policy / LLM).
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import Condition
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, SeededRng
def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
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 _weapon_ids(state: CombatantState) -> list[str]:
return [w.id for w in state.usable_attacks()]
def _sunder_disarmed_condition() -> ActiveCondition:
return ActiveCondition(
condition=Condition(
name="disarmed",
modifiers=(StatModifier(target="attack", value=-4),),
),
rounds_left=1,
source="maneuver: sunder",
)
# --- Test 1: repair restores a broken weapon ---------------------------------
def test_repair_restores_broken_weapon() -> 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))
b.weapon_damage = {"b-w": 10}
b.weapon_broken = {"b-w"}
b.active_conditions.append(_sunder_disarmed_condition())
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken
assert not b.weapon_damage
assert "b-w" in _weapon_ids(b)
assert not any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
assert "repair b-w (restored)" in engine._transcript[-1]
# --- Test 2: weapon_id=None -> nothing to repair, state unchanged ------------
def test_repair_none_logs_nothing_to_repair() -> 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))
b.weapon_damage = {"b-w": 10}
b.weapon_broken = {"b-w"}
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="repair_weapon"))
assert "nothing to repair" in engine._transcript[-1]
assert b.weapon_broken == {"b-w"}
assert b.weapon_damage == {"b-w": 10}
# --- Test 3: id never damaged -> log, state unchanged ------------------------
def test_repair_unknown_id_logs_nothing_to_repair() -> 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([], [a, b])
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert "nothing to repair" in engine._transcript[-1]
assert not b.weapon_broken
assert not b.weapon_damage
# --- Test 4: real sunder then repair restores the weapon ---------------------
def test_sunder_then_repair_restores_weapon() -> 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] * 5, [a, b])
for _ in range(5):
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken
assert "b-w" not in _weapon_ids(b)
assert any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken
assert not b.weapon_damage
assert "b-w" in _weapon_ids(b)
assert not any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
assert "repair b-w (restored)" in engine._transcript[-1]
# --- Test 5: determinism -----------------------------------------------------
def _run_with_repair() -> tuple[str, ...]:
a_spec = make_combatant("a", attack_bonus=5)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
b.weapon_damage = {"b-w": 10}
b.weapon_broken = {"b-w"}
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "b":
return (Action(kind="repair_weapon", weapon_id="b-w"),)
return (Action(kind="wait"),)
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
return engine.run().transcript
def test_determinism_same_seed_same_transcript() -> None:
assert _run_with_repair() == _run_with_repair()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for the ``retrieve_weapon`` action (recovering a stolen weapon).
L7 of the ``llm-policy-contract`` plan: ``Action`` gains a
``retrieve_weapon`` kind and a ``weapon_id`` field so a policy can restore a
weapon that a steal maneuver took from its owner. The weapon is removed from
``state.weapon_stolen`` and becomes usable again via ``usable_attacks()``.
No default policy emits ``retrieve_weapon``; it is only accepted by
``_execute`` (usage via custom policy / LLM).
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
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, SeededRng
def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
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 _weapon_ids(state: CombatantState) -> list[str]:
return [w.id for w in state.usable_attacks()]
# --- Test 1: retrieve restores a stolen weapon -------------------------------
def test_retrieve_restores_stolen_weapon() -> 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], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert "b-w" not in _weapon_ids(b)
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
assert not b.weapon_stolen
assert "b-w" in _weapon_ids(b)
# --- Test 2: weapon_id=None -> nothing to retrieve, state unchanged ----------
def test_retrieve_none_logs_nothing_to_retrieve() -> 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))
b.weapon_stolen.add("b-w")
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="retrieve_weapon"))
assert "nothing to retrieve" in engine._transcript[-1]
assert b.weapon_stolen == {"b-w"}
# --- Test 3: unknown id (never stolen) -> log, state unchanged ---------------
def test_retrieve_unknown_id_logs_nothing_to_retrieve() -> 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))
b.weapon_stolen.add("b-w")
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="ghost"))
assert "nothing to retrieve" in engine._transcript[-1]
assert b.weapon_stolen == {"b-w"}
# --- Test 4: real steal then retrieve restores the weapon --------------------
def test_steal_then_retrieve_restores_weapon() -> 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], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
assert not b.weapon_stolen
assert engine.weapon_for(b, a) is not None
assert "retrieve b-w (restored)" in engine._transcript[-1]
# --- Test 5: determinism -----------------------------------------------------
def _run_with_retrieve() -> tuple[str, ...]:
a_spec = make_combatant("a", attack_bonus=5)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
b.weapon_stolen.add("b-w")
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "b":
return (Action(kind="retrieve_weapon", weapon_id="b-w"),)
return (Action(kind="wait"),)
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
return engine.run().transcript
def test_determinism_same_seed_same_transcript() -> None:
assert _run_with_retrieve() == _run_with_retrieve()
+6 -2
View File
@@ -37,15 +37,19 @@ 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
Wounds); with enough damage taken it heals via cast_spell. Channel energy is
stripped from the resource pool: it now heals living creatures in the burst,
so below 50% HP oni self-heals by channeling and never reaches the spell
branch — this test stays a pure spell-wiring check. The Monte Carlo
loop mirrors run_encounter: build fresh states and one CombatEngine per run
with the registry from SPELLS_DIR.
"""
oni = load_combatant(ONI).model_copy(update={"resources": {}})
spec = EncounterSpec(
map=load_map(MAP),
round_cap=100,
sides=(
Side(name="players", combatants=(load_combatant(ONI),), zone="A"),
Side(name="players", combatants=(oni,), zone="A"),
Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"),
),
)
+80
View File
@@ -32,6 +32,21 @@ from pf1e_simulator.spells import (
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
_SAVE_NEGATES_BOLT = SpellSpec(
name="Save Negates Bolt",
level=1,
school="evocation",
range="medium",
save="ref",
effects=(
DamageEffect(
formula=parse_dice("2d4"),
types=("force",),
half_on_save=False,
),
),
)
def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec(
@@ -244,6 +259,71 @@ class TestFireballSave:
assert any("SAVED" in line for line in engine._transcript)
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save
Then: full damage on failure, ZERO damage on success (save negates)."""
def test_successful_save_deals_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 20 = success) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [20, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before
assert any("0 damage" in line for line in engine._transcript)
def test_failed_save_deals_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 1 = failure) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [1, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before - 6
class TestLightningBoltSave:
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True)
When: the target succeeds its save
Then: damage is still halved (half-on-save is declared for line effects)."""
def test_successful_save_still_halves(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# 1 save roll (natural 20 = success) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
class TestDamageEffectHalfOnSaveDefault:
"""Given: a DamageEffect constructed without half_on_save
When: the field is read
Then: it defaults to False (save negates for targeted effects)."""
def test_defaults_false(self) -> None:
effect = DamageEffect(formula=parse_dice("1d4"), types=("fire",))
assert effect.half_on_save is False
def test_loader_defaults_false(self, tmp_path) -> None:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
' "range": "medium", "save": "ref", "effects": ['
'{"type": "damage", "formula": "1d4", "types": ["fire"]}]}'
)
spell = load_spell(spell_file)
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is False
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)
+279
View File
@@ -0,0 +1,279 @@
"""Tests for touch attacks and firearms (B4).
Touch weapons (kind "touch") resolve against the target's touch AC, dropping
the Dexterity bonus when the target denies Dexterity to AC. Touch weapons with
a range increment are usable up to 5 increments (like thrown weapons) and
suffer the standard -2 range penalty; a firearm marker additionally resolves
against the full AC beyond its first range increment. Ranged-style touch
attacks provoke opportunity attacks, are penalized by prone, and benefit from
soft cover, while melee touch attacks (no increment) do none of those.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import FLAT_FOOTED, PRONE
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def make_combatant(
cid: str,
*,
hp: int = 20,
ac_total: int = 20,
ac_touch: int = 12,
ac_flat_footed: int = 18,
dex: int = 10,
attack_bonus: int = 5,
damage: str = "1d6",
kind: Literal["melee", "ranged", "touch"] = "melee",
range_increment_ft: int | None = None,
firearm: bool = False,
speed: int = 30,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="test weapon",
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
range_increment_ft=range_increment_ft,
firearm=firearm,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=dex,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac_total, touch=ac_touch, flat_footed=ac_flat_footed),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
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(width: int = 8) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * width] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(
queue: list[int], states: list[CombatantState], *, width: int = 8
) -> CombatEngine:
return CombatEngine(ScriptedRng(queue), make_grid(width), states)
def test_melee_touch_attack_resolves_against_touch_ac() -> None:
"""Melee touch (lich-style) hits against touch AC, not full AC."""
lichy = make_combatant("lichy", kind="touch", attack_bonus=10, damage="1d8", hp=30)
victim = make_combatant("victim", hp=20)
a = make_state(lichy, "monsters", (1, 1))
t = make_state(victim, "players", (1, 2))
engine = make_engine([13, 3], [a, t])
result = engine.resolve_attack(a, t, lichy.attacks[0])
# 13 + 10 = 23 >= touch AC 12 (and >= total AC 20)
assert result.hit is True
assert result.ac == 12
assert result.total == 23
assert t.hp == 17 # 20 - 1d8(3)
def test_touch_attack_flat_footed_drops_dex_bonus() -> None:
"""Flat-footed touch target: touch AC minus its Dexterity bonus."""
attacker = make_combatant("attacker", kind="touch", attack_bonus=5)
target = make_combatant("target", dex=16, hp=10) # dex mod +3
a = make_state(attacker, "players", (1, 1))
t = make_state(target, "monsters", (1, 2))
t.active_conditions.append(
ActiveCondition(condition=FLAT_FOOTED, rounds_left=5, source="test")
)
engine = make_engine([6, 2], [a, t])
result = engine.resolve_attack(a, t, attacker.attacks[0])
# touch 12 - max(0, 3) = 9; 6 + 5 = 11 >= 9
assert result.hit is True
assert result.ac == 9
assert t.hp == 8 # 10 - 1d6(2)
def test_touch_weapon_with_increment_selectable_at_range() -> None:
"""A touch weapon with a range increment is selectable at range, with -2/increment."""
thrower = make_combatant(
"thrower", kind="touch", range_increment_ft=10, attack_bonus=5
)
target = make_combatant("target", speed=0)
a = make_state(thrower, "players", (0, 0))
t = make_state(target, "monsters", (0, 3)) # 15 ft -> 2 increments
engine = make_engine([12, 2], [a, t])
picked = engine.weapon_for(a, t)
assert picked is thrower.attacks[0]
result = engine.resolve_attack(a, t, thrower.attacks[0])
# 12 + 5 - 2 = 15 >= touch AC 12
assert result.penalty == -2
assert result.hit is True
assert result.ac == 12
assert t.hp == 18 # 20 - 1d6(2)
def test_touch_weapon_usable_at_exactly_five_increments() -> None:
"""Touch weapons (thrown-like) are usable up to 5 increments, at -8."""
thrower = make_combatant(
"thrower", kind="touch", range_increment_ft=10, attack_bonus=5
)
target = make_combatant("target", speed=0)
a = make_state(thrower, "players", (0, 0))
t = make_state(target, "monsters", (0, 10)) # 50 ft -> exactly 5 increments
engine = make_engine([8, 7], [a, t], width=24)
picked = engine.weapon_for(a, t)
assert picked is thrower.attacks[0]
result = engine.resolve_attack(a, t, thrower.attacks[0])
assert result.penalty == -8 # -2 * (5 - 1)
def test_touch_weapon_unusable_beyond_five_increments() -> None:
"""At 6 increments the touch weapon is dropped: max 5 increments."""
thrower = make_combatant(
"thrower", kind="touch", range_increment_ft=10, attack_bonus=5
)
target = make_combatant("target", speed=0)
a = make_state(thrower, "players", (0, 0))
t = make_state(target, "monsters", (0, 11)) # 55 ft -> 6 increments
engine = make_engine([8, 7], [a, t], width=24)
assert engine.weapon_for(a, t) is None
def test_firearm_within_first_increment_resolves_touch_ac() -> None:
"""Firearm at <= its first range increment: touch AC, no range penalty."""
gunner = make_combatant(
"gunner", kind="touch", range_increment_ft=20, firearm=True, attack_bonus=5
)
target = make_combatant("target", speed=0)
a = make_state(gunner, "players", (0, 0))
t = make_state(target, "monsters", (0, 3)) # 15 ft <= first 20 ft increment
engine = make_engine([13, 2], [a, t])
result = engine.resolve_attack(a, t, gunner.attacks[0])
# 13 + 5 = 18 >= touch AC 12 but < total AC 20
assert result.hit is True
assert result.ac == 12
assert t.hp == 18 # 20 - 1d6(2)
def test_firearm_beyond_first_increment_resolves_full_ac() -> None:
"""Firearm beyond the first range increment: full AC plus range penalty."""
gunner = make_combatant(
"gunner", kind="touch", range_increment_ft=20, firearm=True, attack_bonus=5
)
target = make_combatant("target", speed=0)
a = make_state(gunner, "players", (0, 0))
t = make_state(target, "monsters", (0, 9)) # 45 ft -> 3 increments
engine = make_engine([15, 3], [a, t], width=24)
result = engine.resolve_attack(a, t, gunner.attacks[0])
# 15 + 5 - 4 = 16: >= touch AC 12 but < full AC 20 -> misses on full AC
assert result.penalty == -4 # -2 * (3 - 1)
assert result.hit is False
assert result.ac == 20 # full AC, not touch
def test_touch_ranged_attack_provokes_aoo() -> None:
"""Firing a ranged-style touch weapon from a threatened square provokes an AoO."""
thrower = make_combatant(
"thrower", kind="touch", range_increment_ft=10, attack_bonus=5
)
foe = make_combatant("foe", attack_bonus=2, damage="1d4", hp=10) # melee, threatens
a = make_state(thrower, "players", (1, 1))
f = make_state(foe, "monsters", (1, 2))
engine = make_engine([4, 3], [a, f]) # AoO roll 4 + 2 = 6 < 20 -> miss
assert engine._check_provoked_aoo(a, thrower.attacks[0]) is False # survives
assert any("AoO vs thrower" in line for line in engine._transcript)
def test_melee_touch_attack_does_not_provoke_aoo() -> None:
"""Melee touch (no increment) never provokes an AoO."""
lichy = make_combatant("lichy", kind="touch", attack_bonus=10, hp=30)
foe = make_combatant("foe", hp=10)
a = make_state(lichy, "monsters", (1, 1))
f = make_state(foe, "players", (1, 2))
engine = make_engine([4, 3], [a, f])
assert engine._check_provoked_aoo(a, lichy.attacks[0]) is False
assert not any("AoO vs lichy" in line for line in engine._transcript)
def test_prone_touch_ranged_attack_penalty_minus4() -> None:
"""A prone attacker takes -4 on ranged-style touch attacks."""
hero = make_combatant(
"hero", kind="touch", range_increment_ft=10, attack_bonus=5
)
foe = make_combatant("foe")
a = make_state(hero, "players", (1, 1))
f = make_state(foe, "monsters", (1, 2))
a.active_conditions.append(
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
)
engine = make_engine([15, 4], [a, f])
result = engine.resolve_attack(a, f, hero.attacks[0])
# 15 + 5 - 4 (prone) = 16, no range penalty at 5 ft
assert result.total == 16
def test_touch_ranged_attack_gets_soft_cover() -> None:
"""Ranged-style touch attacks are blocked by soft cover (+4 AC)."""
thrower = make_combatant(
"thrower", kind="touch", range_increment_ft=10, attack_bonus=5
)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", speed=0)
a = make_state(thrower, "players", (1, 1))
b = make_state(blocker, "players", (1, 2))
t = make_state(target, "monsters", (1, 4))
engine = make_engine([12, 3], [a, b, t])
result = engine.resolve_attack(a, t, thrower.attacks[0])
# touch 12 + 4 soft cover = 16; 12 + 5 - 2 = 15 < 16
assert result.ac == 16
assert result.hit is False
def test_tammara_pistols_are_firearm_touch_attacks() -> None:
"""Tammara's pistols are touch attacks with the firearm marker; the AF is not."""
tammara = load_combatant(
Path(__file__).resolve().parents[1] / "data" / "pcs" / "tammara.json"
)
pistols = [w for w in tammara.attacks if "pistolet" in w.name.lower()]
assert len(pistols) >= 2
for weapon in pistols:
assert weapon.kind == "touch"
assert weapon.firearm is True
assert weapon.range_increment_ft == 20
alchemist_fire = next(w for w in tammara.attacks if w.name.startswith("Alchemist"))
assert alchemist_fire.kind == "touch"
assert alchemist_fire.firearm is False