Compare commits
23 Commits
83a5250ca6
...
cd796ecffb
| Author | SHA1 | Date | |
|---|---|---|---|
| cd796ecffb | |||
| e75ab58055 | |||
| 7843756de2 | |||
| 56c8970d5b | |||
| 9c49f85af8 | |||
| fdcd103e8a | |||
| 8e957c9fac | |||
| b640e29a91 | |||
| 7fbf3988e9 | |||
| febd408f29 | |||
| 66b6d1f387 | |||
| ab99ab67e3 | |||
| 30f8905a3e | |||
| 7f57df16a6 | |||
| 7cb3653750 | |||
| cd75a4f2bf | |||
| 2f2acd992f | |||
| 31d970dd5f | |||
| 8e2753e67a | |||
| 2929045e4b | |||
| 22d76e9677 | |||
| 7591614460 | |||
| cedff5b932 |
@@ -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.)
|
||||
|
||||
@@ -20,8 +20,8 @@ legend:
|
||||
|
||||
elevation: |
|
||||
....................
|
||||
..1111..............
|
||||
..1111..............
|
||||
....................
|
||||
....................
|
||||
....................
|
||||
....................
|
||||
....................
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "Lich",
|
||||
"level": 11,
|
||||
"size": "Medium",
|
||||
"creature_type": "undead",
|
||||
"abilities": {
|
||||
"str_score": 12,
|
||||
"dex_score": 14,
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user