Compare commits

...

101 Commits

Author SHA1 Message Date
ctan a144423522 fix(rules): total defense and fighting defensively cannot be combined (SRD Total Defense) - no stacking, second declaration rejected 2026-09-17 23:13:22 +02:00
ctan 980d53d80b fix(rules): aid another bonuses expire at the beginning of the aider's next turn, not the beneficiary's (SRD Aid Another) 2026-09-17 23:09:44 +02:00
ctan e253b03ffe fix(rules): restricted charge - a creature limited to a standard action (staggered/disabled, surprise round) can still charge at 1x speed 2026-09-17 23:06:03 +02:00
ctan a5bd3c5a85 fix(rules): total defense denies attacks of opportunity (SRD Total Defense: allowance drops to 0 while the bonus is active) 2026-09-17 23:00:10 +02:00
ctan 6d96779ded fix(rules): spear of purity and shard of chaos deliver their SRD 1d8-per-two-levels ranged-touch damage (Will half); alignment gate documented as unmodeled 2026-09-17 22:45:38 +02:00
ctan bee224ab37 fix(rules): divine favor grants its SRD luck bonus on attack and weapon damage (+1 per three caster levels, max +3) for 1 minute 2026-09-17 22:41:00 +02:00
ctan 47f563e11a fix(rules): shield of faith grants its SRD deflection AC buff (+2, +1 per six caster levels, max +5) via a new BuffEffect flat scaling 2026-09-17 22:36:18 +02:00
ctan 27f962920f fix(rules): small SRD data corrections - alarm close, air bubble/endure elements touch, delay poison conjuration, 10-ft/0-ft range conventions documented, daze 4 HD or fewer 2026-09-17 21:44:19 +02:00
ctan 48c816b303 fix(rules): delay disease is a level-2 conjuration (healing) spell, not level-3 abjuration 2026-09-17 21:41:38 +02:00
ctan 79d9ff0c30 fix(rules): drunkard's breath is a level-2 conjuration cone that nauseates (Fort negates), not an invented abjuration DR-vs-alcohol buff 2026-09-17 21:40:27 +02:00
ctan 83bbcae952 fix(rules): ray of enfeeblement applies a rolled Strength penalty (Fort half) instead of fatigued
SRD ray-of-enfeeblement.md: 1d6 + 1 per 2 caster levels (max 1d6+5) Strength
penalty, Fortitude half, 1 round/level. Adds a PenaltyEffect type (rolled
formula, scaling, half on save, level-scaled duration) applied as negative
untyped modifiers on the rolls Strength feeds (attack, damage), since the
engine has no ability-score track. Formerly modeled as a fatigued condition
with Fort negates.
2026-09-17 21:28:00 +02:00
ctan 611952cbf7 fix(rules): misty - add sheet feat Plot Armor as traced no-op so data matches the Foundry sheet 2026-09-16 22:18:54 +02:00
ctan 2b5b39a9f7 fix(rules): jeanne + esha mount hooves 1d4 (SRD light horse) marked natural, add Jeanne's sheet Bite as secondary 2026-09-16 22:06:59 +02:00
ctan b9477a2ba1 fix(rules): ierlieth - bite damage_bonus -1 per sheet STR, add secondary Tail (stored +4, runtime -5 = sheet -1) 2026-09-16 22:02:10 +02:00
ctan 1c94d48803 fix(rules): harvie - Wis 14 per sheet, Talons primary +2 1d4 (2x), Bite secondary +2 with runtime -5 2026-09-16 21:59:29 +02:00
ctan feaab5ee41 fix(rules): celestial wolf - SRD bestiary wolf baseline (hp 13, Con 15, touch 12, Fort +5, Will +1, bite +2) + celestial DR 5/magic, ER 5, SR 6 2026-09-16 21:55:09 +02:00
ctan b3a7c3787d fix(rules): add alchemical/inherent/resistance/size bonus types (UM Table 2-7) as non-stacking 2026-09-16 21:48:40 +02:00
ctan ae037c984f fix(rules): minimum damage - a landed hit reduced below 1 by DR/energy resistance deals 1 (CRB Damage - Minimum Damage, nonlethal approximated as normal) 2026-09-16 21:46:04 +02:00
ctan 1c2663d21c fix(rules): non-stacking types keep best bonus and worst penalty separately (CRB Common Terms - Stacking) 2026-09-16 21:40:30 +02:00
ctan fa63829385 fix(rules): trait and racial are non-stacking bonus types (APG Traits, CRB Combining Magic Effects) 2026-09-16 21:37:03 +02:00
ctan cc9fba3b52 fix(rules): helpless Dex-0 AC adds exactly -5 on flat-footed base; touch base strips full Dex mod when helpless (no negative-Dex double count) 2026-09-16 21:32:19 +02:00
ctan e0bf484394 fix(rules): firing into melee exemption measured target-to-nearest-ally, not attacker-to-target (SRD Shooting into a Melee) 2026-09-16 21:24:48 +02:00
ctan e6f7b1988b docs(rules): conformance journal - round 2 found 4 data/conditions/feats/maneuvers fixes and 16 engine commits
Round-2 audits (feats/class abilities, combat maneuvers, conditions,
spell data) surfaced 6 High / ~28 Med / ~45 Low findings. This commit
records them with a per-finding disposition (fixed / out of scope /
assumed residual) plus the 17 fix commits (b03a61d..e89ea42) and the
single justified golden update (Weapon Focus now applies).
2026-09-16 20:45:51 +02:00
ctan e89ea42168 fix(rules): README/abilities wording - grapple applies only grappled (pin out of scope), pinned/nauseated cant_attack, confused no-AoO, Weapon Focus group matching 2026-09-16 20:32:15 +02:00
ctan 3a5948c35c fix(rules): CMD never adds Dex on top of a Tiny Dex-substituted pre-baked CMB 2026-09-16 20:25:34 +02:00
ctan 0cccea2fa1 fix(rules): combat maneuvers auto-succeed (natural 20) vs incapacitated targets 2026-09-16 20:21:41 +02:00
ctan 06da519fc6 fix(rules): dirty trick RAW list extended to shaken and sickened (APG), with runtime validation 2026-09-16 20:16:35 +02:00
ctan 2b5920c85c fix(rules): disarm drops the wielded weapon (weapon_dropped) instead of a synthetic -4 penalty 2026-09-16 20:08:10 +02:00
ctan fdc9127483 fix(rules): Toughness is +3 hp below 4 Hit Dice (max(3, level)) 2026-09-16 19:58:30 +02:00
ctan 350844fc68 fix(rules): Up Close and Deadly deed requires a firearm attack (Ultimate Combat) 2026-09-16 19:55:53 +02:00
ctan 2807f719ca fix(rules): any concealment denies sneak attack (SRD rogue: 'a creature with concealment') 2026-09-16 19:50:40 +02:00
ctan 4d45409941 fix(rules): Weapon Focus filter matches weapon names case-insensitively (substring)
The filter 'Pistol' now matches 'Braises d'Automne (pistolet ?)' via
case-insensitive substring matching, so the feat is no longer dead.
Golden test_tammara_vs_goblin updated: round-4 pistol shot gains the +1
feat bonus (total 15 -> 16 vs AC 16), turning the MISS into a lethal HIT
(17 damage, goblin 6 -> -11); the goblin dies on round 4 so the old
rounds 4-10 exchange and the draw are gone.
2026-09-16 19:45:56 +02:00
ctan f31cc88124 fix(rules): pinned creatures cannot attack (definition corrected; apply-gap documented) 2026-09-16 19:38:49 +02:00
ctan cf8a07311d fix(rules): grappled/pinned -2 applies to non-grapple/escape combat maneuver checks 2026-09-16 19:33:38 +02:00
ctan 68a03a34f6 fix(rules): confused creatures make no attacks of opportunity (simplification documented) 2026-09-16 19:30:08 +02:00
ctan def8533197 fix(rules): nauseated creatures are denied attacks of opportunity 2026-09-16 19:25:35 +02:00
ctan 6220edcfd7 fix(rules): fatigued -2 Dex now costs -1 AC and -1 Reflex 2026-09-16 19:22:00 +02:00
ctan bfe0b767a9 fix(rules): exhausted -6 Dex now costs -3 AC and -3 Reflex 2026-09-16 19:20:00 +02:00
ctan 317f91a07f fix(rules): entangled casting needs DC 15+level concentration; add -4 dex_skills 2026-09-16 19:17:10 +02:00
ctan b03a61d8ab fix(spells): correct SRD levels, saves, schools, ranges and SR flags (11 spells)
Verified field-by-field against the SRD knowledge base (pathfinder-1e-rules,
spells/):
- levels: abundant_ammunition 0->1, comprehend_languages 0->1, deja_vu 0->1,
  create_water 1->0, enhance_water 2->1
- saves: blindness_deafness will->fort, enhance_water none->fort
- spell resistance: summon_monster_i/ii declared false (SRD RM: no)
- schools: arcane_mark ->universal, color_spray ->illusion,
  dancing_lights ->evocation, deja_vu ->enchantment,
  enhance_water ->transmutation, abundant_ammunition ->conjuration
- ranges: deja_vu personal->medium, dancing_lights close->medium,
  enhance_water personal->touch, create_water medium->close

Registry pins in test_spell_registry_batch1.py updated to match; descriptions
that stated the old values reworded consistently.
2026-09-16 19:04:14 +02:00
ctan 99e9203d8c docs(rules): add the SRD conformance audit journal
Consolidates the round-1 audit: 15 fixed divergences (one commit each,
312f9fd..ea60b2e), the deliberate out-of-scope rules, the assumed
residuals, and the verified-conformant guard rails. Linked from README.
2026-09-16 18:10:05 +02:00
ctan ea60b2e4b3 docs(rules): README - undead death, pass-through movement, squeezing limit
- Paragraphe « Mourir et stabilisation » : les morts-vivants et les
  constructeurs sont detruits des hp <= 0 (SRD traits Undead/Construct :
  'immediately destroyed when reduced to 0 hit points or fewer'), sans
  bande d'invalidite ni agonie - conformance deja codee (59caf67).
- Paragraphe « Mouvement » : la traverse des ennuis a 3 categories de
  taille d'ecart avec AoO d'entree (SRD 'Moving Through a Square' : 'Any
  creature can move through a square occupied by a creature three size
  categories larger than itself [...] provoke attacks of opportunity
  from those creatures' ; 'You can't end your movement in the same
  square as another creature unless it is helpless') ; la mention
  'les ennemis bloquent toujours le chemin' est corrigee (blocage total
  seulement a 0-2 categories).
- « Limites hors perimetre » : nouvelle entree squeeze (largeur de
  corridor vs taille non modelisee), entree des approximations du
  pass-through (Acrobatics Tumbling et la clause 'counts as 2 squares'
  des tres grosses creatures inertes non modelisees), et entree de
  l'echec d'incantation du sourd (tache 11, differe).
- « Attaques multiples par action » : verifie conforme aux armes
  naturelles apres e5691bd (pas d'iteratifs, secondaires a -5,
  Multiattack -2) - aucune modification necessaire.
- Compteur de tests de la section « Developpement » : 1408 -> 1491.
2026-09-16 15:53:46 +02:00
ctan cad35e2e13 fix(rules): cure spells damage undead instead of healing
SRD Cure Light Wounds: 'Since undead are powered by negative energy,
this spell deals damage to them instead of curing their wounds. An
undead creature can apply spell resistance, and can attempt a Will save
to take half damage.'

- _apply_heal_effect takes the rolled save; an undead target takes the
  rolled healing as damage (halved on a successful save; SR already
  resolved upstream, DR never applies to spell energy, no bleeding stop
  or stabilization). Living targets are bit-identical.
- cure_light/cure_moderate descriptions fixed: the SRD save line is
  'Will half (harmless)', not 'negates'.
- Deafened 20% verbal spell failure DEFERRED to the README « Limites
  hors perimetre »: SpellSpec carries no components data (would need a
  verbal flag on all 108 spells) and sound-based Perception is not
  modeled either.
2026-09-16 15:48:36 +02:00
ctan 3e19d2974e feat(rules): SRD Mage Armor spell + non-stacking armor bonus type
SRD spells/mage-armor.md: 'An invisible but tangible field of force
surrounds the subject of a mage armor spell, providing a +4 armor bonus
to AC' - 'School conjuration (creation) [force]', level 1, 'Casting Time
1 standard action', 'Range touch', 'Duration 1 hour/level (D)',
'Spell Resistance no'.

- data/spells/mage_armor.json: buff +4 armor to AC, 600 rounds (1
  hour/level modeled fixed, the shield.json convention). Description
  documents the known simplification: the engine bakes worn armor into
  the AC total and cannot see it, so the armor bonus stacks additively
  with that baked value (two mage armors still do not stack).
- effects.py: 'armor' added to BonusType; absent from _STACKING_TYPES it
  behaves as a keep-the-highest type (verified by resolve_modifiers
  tests).
- Spell-count pins 107 -> 108 (test_runner_spells, batch3, test_spells)
  and the README references.
- Golden transcript untouched (buff branch only fires when no offensive
  spell is selected; the lich still opens with Magic Missile).
2026-09-16 15:36:43 +02:00
ctan 881f2ef9b2 fix(rules): lich data conforms to the KB stat block
Verified against the KB (bestiary/lich.md, Bestiary Lich CR 12):
- bab 8 -> 5 ('Base Atk +5')
- Paralyzing Touch attack_bonus 10 -> 5 ('Melee touch +5 (1d8+5 plus
  paralyzing touch)') and marked natural: true (template: 'A lich has a
  touch attack that it can use once per round as a natural weapon [...]
  treated as a primary natural weapon' - no BAB iteratives per e5691bd)
- damage_bonus 5 -> 0: the +5 (1 point per 2 HD) is already in the
  1d8+5 formula and _resolve_swing adds damage_bonus on top (double
  counting: the old data dealt 1d8+10)
- ac 25/14/23 -> 23/14/21 ('AC 23, touch 14, flat-footed 21'; touch was
  already correct)
- saves 10/10/14 -> 6/7/11 ('Fort +6, Ref +7, Will +11')
- con_score 14 -> 10: 'Being undead, a lich has no Constitution score'
  (Con '—'); the int-only schema encodes 'no score' as the neutral 10
  (modifier +0), inert anyway since undead are destroyed at hp <= 0.
Left as intentional engine tuning (residual divergences, documented in
the final report): hp 100 vs 111, Str 12 vs 10, Int 20 vs 22,
initiative +6 vs +2, fear aura 30 ft/DC 22 vs 60 ft/DC 18.
Golden transcript untouched (the lich wins via Magic Missile before any
weapon swing; MM DC depends on Int, unchanged). Pinned a new
test_json_matches_srd_stat_block.
2026-09-16 15:22:10 +02:00
ctan 9a16ca0388 fix(rules): pass through 3+ size categories apart, entry provokes AoO
SRD Moving Through a Square: 'Any creature can move through a square
occupied by a creature three size categories larger than itself. A big
creature can move through a square occupied by a creature three size
categories smaller than it is. Creatures moving through squares occupied
by other creatures provoke attacks of opportunity from those creatures.'

- _can_pass_through: size ranks differ by >= 3 opens the through-gate
  (_blocked_hostile) while _blocked/_cells_free keep the end-gate
  ('You can't end your movement in the same square as another creature
  unless it is helpless').
- _check_enter_aoo wired into _walk_steps (move, explicit-destination
  move, and run all commit steps there): the occupant strikes once per
  fresh entry, capped by the per-round allowance; a dropped mover stops
  the walk. S4c co-occupiable pairs (Tiny-or-smaller) keep no-AoO sharing.
- _has_free_descent generalized to a descending BFS so a walk can cross
  a multi-cell Huge+ creature (one-step lookahead dead-ended inside).
- Golden transcript untouched (all 1480 tests green).
2026-09-16 15:10:21 +02:00
ctan 437507ee2c conformance: block 5-foot step on difficult terrain (SRD Take 5-Foot Step)
A 5-foot step is hampered by difficult terrain: stepping out of OR into a
difficult cell is blocked. _step5 (auto-directed) logs 'wait (cannot step)';
_step5_to (explicit destination) rejects with unreachable_destination.
Engine-level tests cover destination, auto-path, and origin cases.
2026-09-16 13:47:10 +02:00
ctan 7fc7a1d6ec fix(combat): Block run action when cant_charge flag is set
SRD Conditions Fatigued: A fatigued character can neither run nor
charge. _execute_charge already checked cant_charge; _execute_run did
not -- a fatigued/exhausted/entangled combatant could still run.

Added cant_charge guard to _execute_run (mirrors _execute_charge),
logging wait (cannot run) and returning ok. Two engine-level tests:
fatigued cannot run, exhausted cannot run.
2026-09-16 13:28:17 +02:00
ctan 2cc2ba1907 fix(combat): Thread 5-10-5 diagonal parity through all budget-consuming paths
The greedy move/run/flee/charge descent helpers hardcoded diagonal parity 0,
letting a speed-30 walker cover 6 diagonal cells (cell-count budget). Under
5-10-5 (SRD Movement, Tactical) the same walker covers only 4 cells
(1+2+1+2=6 squares).

Threading the running diagonal count:
- _best_move_step / _best_flee_step: accept diagonals_used param
- _descend_path / _run_line_path / _flee_path: track and pass parity
- _charge_end: use _path_cost_5105() instead of len(path) for max check
- _path_cost_5105: new helper; computes total 5-10-5 cost of any path

Updated golden paths in test_aoo_limit_one_per_round (5-cell path now
takes an orthogonal detour to avoid expensive 2nd diagonal), test_run_*
 (speed-30 diagonal move stops at 4 cells), and staggered_withdraw
(single-speed diagonal withdraw stops at 4 cells).
2026-09-16 13:15:49 +02:00
ctan e5691bdb6f fix(rules): natural weapons get no BAB iteratives, secondary attacks at -5
PF1e (CRB "Natural Attacks"):
- Natural weapons (natural=true) get exactly one swing per full attack,
  regardless of BAB (no BAB iteratives for natural attacks).
- Secondary natural attacks (secondary=true) attack at BAB -5; the
  Multiattack feat reduces this to -2.
- Manifested in combat.py (_full_attack_weapon, _resolve_swing),
  models.py (AttackSpec.natural/secondary), 6 data JSONs, 4 new tests,
  and README documentation.

1461 passed, ruff clean, basedpyright 0 errors.
2026-09-16 12:25:30 +02:00
ctan 59caf67909 fix(rules): undead and constructs are destroyed at hp <= 0
SRD Undead/Construct traits: 'immediately destroyed when reduced to 0 hit
points or fewer' — undead and constructs have no Constitution score, so
the PF1e hp <= -CON death rule does not apply to them.

- combat.py: CombatantState.death_threshold returns 0 for creature_type
  undead/construct (in addition to the opt-in dies_at_zero flag)
- tests: TestUndeadDeathThreshold pins undead dead at 0 and below, living
  keeps the disabled band at 0 and the dying band down to -CON
- golden unchanged: tammara-vs-lich has the lich winning in 1 round
  without taking damage
2026-09-16 11:45:23 +02:00
ctan 5e3505dc8c fix(rules): convert bane to a 50-ft enemies-only burst
SRD spells/bane.md: enemies within a 50-ft burst centered on the caster
take a -1 penalty on attack rolls and a -1 penalty on saving throws
against fear effects; Will negates. The -1 penalty is approximated with
the shaken condition (-2), matching the existing Doom approximation.

- bane.json: single-target condition -> AreaEffect burst radius_ft 50,
  targets "enemies", ConditionEffect shaken 10 rounds save_negates
  (range/save kept as medium/will for the registry pin)
- combat.py: _metamagic_transform_sub now copies AreaEffect.targets so
  an Extend Spell on an area spell preserves the allies/enemies filter
- tests: TestBaneEnemiesOnly (enemy in burst debuffed, allies/caster
  unaffected, successful Will save negates)
2026-09-16 11:37:23 +02:00
ctan e43066e0ef feat(rules): restrict bless to allies in a 50-ft burst
SRD spells/bless.md: 'The caster and all allies within a 50-ft. burst,
centered on the caster'. Add an AreaEffect.targets filter (all/allies/
enemies) applied where area creatures are collected; bless.json now
declares radius_ft 50 and targets allies (the caster included).
2026-09-16 11:14:46 +02:00
ctan 312f9fd6b8 fix(rules): conform aid, bladed dash, doom, bulls/bears strength to the SRD 2026-09-13 18:56:17 +02:00
ctan 6c4bdc5d14 docs(rules): document the bladed dash approximation
Bladed Dash's description now matches the SRD (standard action, move up
to 30 ft in a straight line without provoking attacks of opportunity,
single melee attack at highest BAB against any creature adjacent along
the path) and the README's spell-limits section records the engine
approximation: a +30 ft speed buff for 1 round, with the free melee
attack, the no-AoO movement, the straight-line constraint and the
circumstance attack bonus left unmodeled.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 17:43:09 +02:00
ctan 0c141414a9 feat(rules): conform bulls strength, bears endurance and shield to the SRD
Bull's Strength now grants the SRD +4 enhancement bonus to Strength as
+2 melee attack, +2 melee damage and +2 CMB (three modifiers instead of
the previous +1/+1 approximation). Shield now uses a real shield bonus
type (+4 AC) instead of approximating it as deflection: the engine's
BonusType literal and stacking docs gain the shield type, which stays
out of the CMD bonus list (SRD Combat Maneuver Defense). Bear's
Endurance keeps its +2 Fortitude / +2 hp per HD and its description now
cites the SRD and flags the hp modifier as informational. Golden
transcripts updated: the buff log reports 3 mods and Tammara's pistol
attacks gain the extra +1 enhancement (d20=10+9-6=15).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 17:39:52 +02:00
ctan b84238792c feat(rules): scale healing spells with caster level
PF1e (SRD spells/cure-light-wounds.md): 'cures 1d8 points of damage +1
point per caster level (maximum +5)'; (spells/cure-moderate-wounds.md):
'cures 2d8 points of damage +1 point per caster level (maximum +10)'.
A HealEffect now carries the same DamageFlatScaling spec as damage
effects (S1b) and resolves it through effective_formula(caster_level)
at cast time, so the flat bonus grows with the caster and stops at the
stated cap. The engine passes the caster's level into _apply_heal_effect
(required keyword: no silent level-0 under-heal); Maximize Spell
maximizes the scaled formula. Cure Light/Moderate Wounds ship the SRD
values; inflict_light_wounds already carried its scaling_flat.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 17:19:58 +02:00
ctan a2a80b8690 feat(rules): add firearm misfire and the quick clear deed
Ultimate Combat Firearm Rules: a firearm attack rolling a natural 1 gains the
broken condition (weapon_broken, logged MISFIRE). Quick Clear now actually
clears the first misfired held firearm for 1 grit (reported no-op without
grit), and occupies the standard budget slot via _effective_slot; the
swift-consumed filter is slot-based so the standard deed survives.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 16:41:40 +02:00
ctan b579cb4934 fix(rules): give Tiny-or-smaller creatures zero reach and square sharing 2026-09-13 15:35:23 +02:00
ctan 7fb7ec55dd fix(rules): update initiative when a readied action fires
SRD Ready - Initiative Consequences of Readying: 'your initiative result becomes the count on which you took the readied action' and you 'act immediately ahead of the character whose action triggered your readied action'. _check_readied fired the payload off-turn but never touched the readier's initiative. It now calls _ready_shift_initiative, which rewrites the readier's _initiative_keys entry (trigger total + 0.5, keeping its mod and prior index) using the same tuple shape _execute_delay writes, so _build_round_order holds the new position for all following rounds. Deterministic: no RNG consumed, current round order untouched.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 14:54:15 +02:00
ctan 6702efc652 fix(rules): constrain a run to a straight line
SRD Full-Round Actions - Run: 'you can move up to four times your speed in a straight line'. _execute_run reused the Dijkstra move machinery, so it turned corners around walls. It now walks the straight Bresenham line (_run_line_path) and stops at the first obstacle (impassable terrain, difficult terrain, or a blocking creature), mirroring the charge clear-path logic. Keeps the 4x budget, step-by-step AoO provocation, moved_this_turn and the running/Dex-loss flag; a destination the straight line cannot honour is rejected (unreachable_destination).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 14:48:25 +02:00
ctan f5dc9da0c4 feat(rules): resolve one-round casts on the following turn
SRD Casting Time: a spell that takes 1 round is a full-round action
that comes into effect just before the caster's turn in the next round;
the caster then acts normally. _cast_spell now spends the slot and runs
the start-of-casting gates, then records a PendingCast (spell, target,
center, metamagic, slot level, hp at cast); delivery (touch attack, SR,
saves, effects) resolves at the start of the caster's next turn, before
the policy runs. Net hp lost in between forces the injured-while-casting
concentration check (DC 10 + damage + spell level, no Combat Casting) or
the spell fizzles; a caster knocked out of the fight loses it. Exposed
in the observation via pending_cast. Bounded model documented: net-hp
measurement instead of a per-damage-site hook; long (1 minute+) casting
stays out of scope.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 13:39:44 +02:00
ctan 3cc0502228 feat(rules): add the surprise round
SRD Surprise: when some but not all combatants are aware of their
opponents, a surprise round precedes round 1. Aware combatants take a
single standard OR move action plus free actions (swift and full-round
are dropped, full_attack collapses to one standard attack, withdraw
survives as the Restricted Withdraw standard); unaware combatants skip
their turn. New Combatant.aware flag (default true, parsed at the JSON
boundary) keeps every existing encounter bit-identical, exposed in the
observation snapshot. Unaware combatants are not flagged flat-footed
(documented simplification).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 13:20:37 +02:00
ctan 7a8351e3b2 docs(readme): update the test count after the spell feature batches 2026-09-13 12:50:54 +02:00
ctan 9768f5ae1d feat(spells): allow spells to ignore spell resistance
SpellSpec gains spell_resistance: bool = True (JSON 'spell_resistance'). When false, _spell_delivery_blocked skips the targeted SR check and _apply_area_effect skips the per-creature SR check (threaded through the effect chain). Verified each imported spell against the local KB and set false on the 28 whose SRD block says 'Spell Resistance: no' (Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, the Protection from X spells, etc.). Added no-SR targeted/area tests and a high-SR control in test_spell_resistance.py, and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 12:44:35 +02:00
ctan c96b36d3f9 fix(spells): apply a shaken effect when a fear save succeeds
ConditionEffect gains save_partial (JSON 'on_success': condition_name + duration_rounds). On a successful save that would otherwise negate, _apply_condition_effect now imposes the weaker condition (SRD fear.md: panicked on a failed Will save, shaken for 1 round on a success). Extended _metamagic_transform_sub to preserve/double it; extracted _impose_condition helper. Updated fear.json, added failed/successful-save and loader tests in test_spells.py, and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 12:32:45 +02:00
ctan a59ebee37a feat(rules): add ongoing spell damage
Acid Arrow (SRD spells/acid-arrow.md) deals 2d4 acid, then 2d4 again at the start of each of the target's turns for 1 round + 1 per three caster levels (max 6). DamageEffect gains a RecurringDamageSpec (JSON 'recurring'); a landed hit attaches a RecurringDamage source to CombatantState.recurring_damage, ticked in _turn_gate (after bleed), reduced by energy resistance/immunity but never DR (spell damage), cleared at expiry, and exposed in the observation snapshot. Extracted _resolve_one_projectile to keep _apply_damage_effect under the complexity ceiling. Updated acid_arrow.json, added tests/test_recurring_damage.py, and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 12:25:38 +02:00
ctan 88ef9f39be feat(rules): support multi-projectile spells
SRD Magic Missile fires one missile +1 per two caster levels beyond 1st (max 5, 1d4+1 force, auto-hit); Scorching Ray fires one ray +1 per four levels beyond 3rd (max 3, 4d6 fire, ranged touch each). DamageEffect gains a ProjectileScaling spec (JSON 'projectiles'); _apply_damage_effect loops count_for(caster_level) projectiles, each rolling its own damage and (for a declared-projectile ray) its own ranged touch attack, with the target-drops-mid-sequence convention losing the rest. Updated magic_missile.json/scorching_ray.json, the golden lich transcript (CL 11 now fires 5 missiles, Tammara dies round 1: 43 damage), the scripted MM queues in test_spells/test_conformance_spellflow, added tests/test_multi_projectile.py, and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 12:05:56 +02:00
ctan 642f449b9e fix(rules): exclude the caster from a line spell's area
CRB Magic - Aiming a Spell: a line 'starts from any corner of your square', so the caster's own square is outside the template (SRD Lightning Bolt: 'The bolt begins at your fingertips' - it leaves the square, it does not fill it). line_cells now drops the origin and takes the length_cells squares beyond it; a degenerate aim affects nothing. Updated the Lightning Bolt and Line Sparks tests (caster no longer caught) and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 11:30:30 +02:00
ctan ee8c8f80ff fix(spells): set the correct area shapes
SRD-verified area templates: Burning Hands 15-ft cone, Color Spray 15-ft cone, Fear 30-ft cone, Flurry of Snowballs 30-ft cone, Lightning Bolt 120-ft line (all converted from single-target/burst approximations to proper AreaEffect shapes); Fireball 20-ft burst, Sound Burst 10-ft spread, Sleep 10-ft burst, Web 20-ft spread kept. Close-range spells note the 15/30-ft RAW ranges as an approximation in their descriptions. README: cone/scaling limitations moved out of the limitations list, new area-shape + caster-level scaling rules documented, areas.py added to the architecture.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 11:01:07 +02:00
ctan a6cc5aef36 feat(rules): scale spell damage with caster level
DamageEffect gains optional scaling (1dX per N levels, capped) and scaling_flat (+X per N levels, capped) specs; effective_formula resolves them against the caster's level at cast time, so scaled dice flow through crits, energy resistance and metamagic like the base formula (CRB Magic - Caster Level). Loader rejects mismatched die types. Data: Fireball/Lightning Bolt 1d6/lvl max 10d6, Burning Hands 1d4/lvl max 5d4, Shocking Grasp 1d6/lvl max 5d6, Inflict Light Wounds +1/lvl max +5 (SRD-verified). Cure Light Wounds stays flat: its golden transcript pins 1d8+1 and HealEffect scaling is out of scope.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 10:48:09 +02:00
ctan 4659fae9f6 feat(rules): support cone and line spell areas
AreaEffect gains shape: burst|cone|line (CRB Magic - Aiming a Spell). Cones (90 deg, length = radius_ft) and lines (5 ft wide, Bresenham ray) originate at the caster's cell and are aimed at the resolved target or the U7b point-cast center; bursts are unchanged. Shared per-creature save + SR application; the observation area dict already exposes the shape.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 10:32:37 +02:00
ctan 734cfa4e55 docs(rules): document quick clear and doom description
README: the quick_clear deed is classified swift in the engine (SRD: standard action, or move with All Firearms) and is a functional no-op without a misfire model - documented rather than half-implemented. doom.json: the description mentioned a '-1 on a successful save' (a D&D 3.5 rule); PF1e Doom is Will negates with no partial success - description text corrected, effects unchanged (save_negates was already correct).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 15:11:56 +02:00
ctan 5bf2850e2f fix(rules): use the attacker's best corner for cover bonus
SRD 'Cover': 'To determine whether your target has cover from your ranged attack, choose a corner of your square.' The ranged scorer in cover_bonus_cells took the MAX over all attacker corners (worst corner); the cover gate has_cover_cells and RAW use the attacker's best corner. Ranged cells are now scored min over attacker corners of (max over target-corner lines), then min over attacker cells - the bonus can no longer exceed what the gate admits. Melee keeps the worst-corner rule ('any line from ANY corner of your square'). Updated the two pins that locked the old worst-corner scores.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:57:56 +02:00
ctan a989266747 fix(rules): require adjacency or pull the target when grappling
SRD 'Grapple': 'If you successfully grapple a creature that is not adjacent to you, move that creature to an adjacent open space (if no space is available, your grapple fails).' _maneuver_grapple now pulls a non-adjacent target to the nearest free anchor whose footprint is adjacent to the grappler (5-10-5 distance, (row,col) tie-break, new _nearest_anchor_adjacent_to helper) and fails outright when no adjacent open space exists. Three tests: pull, no-space failure, adjacent no-move.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:45:55 +02:00
ctan 25149c64ba fix(rules): allow a free weapon draw with a move at BAB +1
SRD 'Move Actions': 'If you have a base attack bonus of +1 or higher, you may draw a weapon as a free action combined with a regular move.' _filter_turn_actions computes free_draw (BAB >= 1 AND a move action in the turn sequence) and threads it through _effective_slot/_action_economy_filter/_staggered_filter: retrieve_weapon then occupies the free slot instead of the move slot. Static classification stays 'move' (repair_weapon and no-move draws unchanged). Filter-level and engine-level (BAB +2 vs BAB 0) tests added.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:35:42 +02:00
ctan 317c68ecdb fix(rules): allow a staggered creature to withdraw as a standard action
SRD 'Full-Round Actions - Restricted Withdraw': 'If you are limited to taking only a standard action each round you can withdraw as a standard action. In this case, you may move up to your speed.' _staggered_filter now keeps withdraw as the one move-or-standard action (spending the slot) instead of dropping it, and _flee_path halves the budget to single speed for a staggered/disabled creature (new _is_staggered_limited helper). Filter-level and engine-level tests added.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:21:04 +02:00
ctan a4487a85f9 fix(rules): block immediate actions while flat-footed
SRD 'Immediate Actions': 'You also cannot use an immediate action if you are flat-footed.' _check_reactions now skips states carrying the flat_footed condition before consulting the reaction policy. The engine never applies flat_footed itself (latent gate), so the unit test injects the condition via active_conditions, with a control test proving the same setup fires when not flat-footed.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:08:44 +02:00
ctan 7810d88488 fix(rules): expire a readied action at the combatant's next turn
SRD 'Ready - Initiative Consequences of Readying': 'If you come to your next action and have not yet performed your readied action, you don't get to take the readied action (though you can ready the same action again).' _take_turn's start-of-turn block now clears state.readied/readied_trigger (logged 'ready expired'), before the policy call; the ready still survives the round boundary and can fire in the next round as long as the readier's own turn has not arrived. Updated the two tests that pinned the old infinite persistence and added cross-round fire + expiry tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:59:11 +02:00
ctan ec2088ab90 fix(rules): apply cover concealment and criticals to touch attacks
CRB 'Cast a Spell - Touch Attacks': 'You can score critical hits with either type of attack as long as the spell deals damage'; CRB 'Range - Touch': a touch spell threatens on a natural 20 and deals double damage on a successful confirmation. The melee and ranged spell-touch gates now add the target's cover bonus to touch AC (new _attack_cover_bonus helper shared with resolve_attack), roll the concealment miss chance after the hit (blinded attacker = total concealment convention), and resolve crit threat/confirm with damage doubling for damage-dealing touch spells. Non-damage touch spells do not threaten (no die consumed). Golden transcript unaffected.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:47:03 +02:00
ctan 35c8d101b5 fix(rules): treat a blinded attacker as facing total concealment
CRB 'Conditions - Blinded': 'All opponents are considered to have total concealment (50% miss chance) against the blinded character.' The sneak-attack gate and the AoO suppression gate now apply the same max(target_concealment, 50) convention as _resolve_hit_damage: a blinded attacker cannot sneak attack and its AoOs are suppressed without spending the budget.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:26:52 +02:00
ctan d152d42af9 fix(rules): avoid the firing-into-melee penalty at 10 feet
SRD 'Attack - Shooting or Throwing into a Melee': a target at least 10 ft from the nearest friendly character lets you avoid the -4 penalty entirely (0, not -2). The -2 is reserved for targets two size categories larger than the engaged friendlies (0 at three+), which is not modeled (documented). Golden transcript unaffected (shot still misses).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:21:26 +02:00
ctan 84bcf004c3 docs(rules): document missile count and per-spell SR limits
Two documented limitations, no mechanical change: (a) Magic Missile fires a single 1d4+1 missile - the SRD scaling to up to five missiles per caster level and multi-target splitting is not modeled; (b) the engine applies spell resistance to every targeted spell, so the few spells whose SRD entry says "SR: no" (e.g. Acid Arrow) are not distinguished - a per-spell SR flag was deliberately not added.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:08:27 +02:00
ctan 5b64fbee48 fix(spells): correct the fear condition
PF1e (SRD spells/fear.md): creatures become panicked unless they succeed on a Will save - the JSON applied frightened instead. Condition fixed to panicked. The shaken-for-1-round on a successful save needs partial-save support for conditions and the 30-ft cone needs a cone template, neither of which the engine has; both documented in README as out of scope rather than built.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:05:49 +02:00
ctan b5b1bfd395 fix(spells): correct the acid arrow range
PF1e (SRD spells/acid-arrow.md): "Range long (400 ft. + 40 ft./level)" - the JSON declared medium. Fixed to long. The spell's ongoing acid (2d4 per extra round, one per three caster levels, unless neutralized) is not modeled: the engine has no per-spell damage-over-time subsystem, documented in README rather than built.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:03:11 +02:00
ctan 5c48579768 fix(spells): correct the burning hands damage
PF1e (SRD spells/burning-hands.md): "1d4 points of fire damage per caster level (maximum 5d4)" — the JSON encoded 1d4+1, a flat bonus the spell never grants. Base is now 1d4. Per-caster-level scaling and the 15-ft. cone are not modeled by the engine (no CL-scaled dice, no cone template); documented in README rather than built.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:01:12 +02:00
ctan b0fe553782 fix(rules): correct the cackle hex
PF1e (APG Cackle): "(Su) Free Action; ... extends the duration of all evil eye, fortune or misfortune hexes on all creatures within 30 feet by 1 round." The engine spent a hex use, acted only on the resolved target, and extended every hex-sourced condition (including slumber, which RAW cackle does not touch). Cackle is now dispatched before the resource/target gate (no cost), occupies the FREE budget slot via _effective_slot (the hex kind stays standard for evil_eye/slumber), and rebuilds the evil_eye buff list of every creature within 30 ft in a single bounded pass. Fortune/misfortune are not implemented, so evil_eye is the only extendable family member — documented in README. test_cackle.py rewritten for the new semantics (extension, range, free slot, no cost).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 12:58:32 +02:00
ctan 68699ad6c7 fix(rules): set the slumber hex duration to the witch level
PF1e (APG Slumber): the target "remains asleep for a number of rounds equal to the witch's level" (Will negates). _hex_slumber hardcoded 1 round; it now uses the caster's level. The RAW clause that the hex ends when the target takes damage is not modeled (no wake-on-damage hook in the engine) — documented in README. Two behavioral tests pin the failed-save duration (= level) and the saved case.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 12:52:52 +02:00
ctan f9c369380d fix(rules): correct the evil eye hex
PF1e (APG Evil Eye): the target takes -2 on ONE chosen option (AC, ability checks, attack rolls, saving throws or skill checks) for 3 rounds, and "A Will save reduces this to just 1 round" — it never negates. _hex_evil_eye applied shaken (-2 attack AND saves) for 1 round with the save negating entirely. It now adds a targeted -2 attack ActiveBuff for 3 rounds, or 1 round on a successful Will save. Simplification documented in README: the option choice is not modeled (attack rolls are the engine's target). The hex-DC behavioral test now reads the -2 attack (3r) transcript line.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 12:49:48 +02:00
ctan 910ed1dc7d fix(spells): make the bleed spell bleed
PF1e (SRD spells/bleed.md): "Saving Throw Will negates" and the target takes 1 point of damage per round. Two divergences: the JSON declared a Fortitude save, and the bleeding condition never set the engine's bleed_damage field, so the spell was a no-op. Bleed now saves Will; _apply_condition_effect sets bleed_damage = 1 when a spell applies bleeding, and _decrement_durations lands the final tick as the last bleeding condition expires before clearing the field (heal-path clearing already existed in _stop_bleeding). README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 12:46:53 +02:00
ctan 8c7e7075ad fix(spells): correct the sound burst save
PF1e (SRD spells/sound-burst.md): "Every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save to avoid being stunned for 1 round" — the Fortitude partial negates the stun only, never the dice. The damage component declared half_on_save: true, halving the sonic damage on a successful save. DamageEffect gains a save_negates flag (default True, existing behavior unchanged); sound_burst.json declares half_on_save: false + save_negates: false so the 1d8 is full regardless while the condition keeps save_negates. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 12:31:06 +02:00
ctan e3818fa6c9 fix(rules): make the run reachability check avoid difficult terrain
PF1e (CRB Movement): "You can't run or charge across difficult
terrain". _execute_run checked the explicit destination against the full
cost field (which routes through difficult terrain at 2x) but descended
with avoid_difficult=True, so a destination beyond rubble was accepted
(ok=True) yet silently truncated at the obstacle. _destination_reachable
now takes the same avoid_difficult constraint (difficult cells removed
from the field; a destination whose footprint touches difficult terrain is
not a legal run endpoint either), so verdict and path agree. The test that
pinned the old silent-stop-short convention now expects
unreachable_destination; README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:53:27 +02:00
ctan 8043dd393c fix(rules): scale reposition and drag distance with the margin
PF1e (APG Reposition / Drag): "For every 5 by which your attack exceeds
your opponent's CMD, you can move/drag the target an additional 5 feet."
Both handlers now walk 1 + margin//5 cells along the chosen (or auto-picked)
direction, stopping at the first obstacle or occupied square, mirroring the
bull rush scaling. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:43:12 +02:00
ctan f9420c7b09 fix(rules): scale dirty trick duration and drop nauseated
PF1e (APG Dirty Trick): "This condition lasts for 1 round. For every 5
by which your attack exceeds your opponent's CMD, the penalty lasts 1
additional round" — rounds_left = 1 + margin//5. The RAW condition list
(blinded, dazzled, deafened, entangled, shaken, sickened) does not include
nauseated, so it is removed from TrickCondition. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:34:20 +02:00
ctan 34be75c492 fix(rules): apply AC penalties to CMD
PF1e (SRD Combat Maneuvers): "Any penalties to a creature's AC also
apply to its CMD." _maneuver_cmd now folds negative AC modifiers into the
sum regardless of bonus type (grappled -2, other untyped penalties), and —
a maneuver being a melee attack roll — the prone -4 melee AC penalty.
Positive modifiers keep the eight SRD-listed types. The test that pinned
the old ignore-penalties convention is updated; README documents the rule.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:26:05 +02:00
ctan a4f7f4e5de fix(rules): exclude weapon special ability dice from critical multiplication
PF1e (CRB Critical Hits): "additional damage dice from weapon special
abilities (such as flaming) are not multiplied when you score a critical
hit". _resolve_hit_damage now multiplies only the physical dice plus flat
bonuses by crit_mult; energy-typed component dice are added at 1x (the
energy share on the DR path and the per-component resistance step drop
their crit factor). Sneak precision dice were already excluded. README
and the module convention list updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:21:35 +02:00
ctan a9b33e4055 fix(rules): apply the spell combat penalty to the spell attack
PF1e (CRB Magus Spell Combat): "any attack roll made as part of this
spell also takes this penalty". _execute_spell_combat now threads the -2
through _cast_spell into the melee/ranged touch attack helpers, so e.g.
Shocking Grasp delivered via Spell Combat attacks at -2 too. No global
state is touched: the penalty is a keyword threaded per cast.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:14:30 +02:00
ctan 068b49794e fix(rules): stop steal from taking held weapons
PF1e (APG Steal): "Items held in the hands (such as wielded weapons or
wands) also cannot be taken with the steal maneuver—you must use the disarm
combat maneuver instead." The model carries no non-held items, so a
successful steal now takes nothing and logs
"nothing stealable — wielded weapons require disarm". README updated; the
retrieve_weapon tests keep locking the restoration mechanism directly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:06:08 +02:00
ctan 57a597babd fix(rules): cap reposition and drag by size category
PF1e (APG Reposition / Drag): "You can only reposition/drag an opponent
that is no more than one size category larger than you" — the same cap the
SRD puts on trip/bull rush/overrun. reposition and drag join
_SIZE_RESTRICTED_MANEUVERS, so a two-categories-larger target is rejected
outright (no roll, no AoO). README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 10:58:13 +02:00
ctan 5a848fd07c fix(rules): compute CMD from Strength and Dexterity directly
PF1e (SRD Combat Maneuvers): CMD = 10 + BAB + Strength modifier +
Dexterity modifier + special size modifier. The Tiny-or-smaller
Dex-for-Str substitution is CMB-only, but _maneuver_cmd reused
_maneuver_cmb for the base, so Tiny/Diminutive/Fine creatures got
10 + BAB + 2xDex + size and dropped Strength (live bug on the Tiny PC
ierlieth). The CMD base is now computed with str_mod directly; CMB
keeps its substitution.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 10:54:21 +02:00
ctan e1c9e78b4e fix(rules): stop auto-directed movement cutting corners
PF1e (CRB, Measuring Distance): "You can't move diagonally past a corner
(even by taking a 5-foot step)". _best_move_step (the greedy descent behind
the auto-directed move/run/5-foot-step) now enforces the same
diagonal_allowed corner check _step5_to and _best_flee_step already apply.

tests/test_combat.py::test_ranged_no_line_of_effect_moves_around_wall
pinned the old corner-cutting step (0,1)->(1,2) past the wall at (0,2);
re-scripted for the legal (0,1)->(1,1) path (wall cover now applies).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 10:48:12 +02:00
140 changed files with 9814 additions and 1513 deletions
+573 -141
View File
@@ -300,10 +300,17 @@ vers la fiche Foundry d'origine.
## Règles implémentées et limites de la Phase 0
> Le suivi détaillé des écarts avec le SRD — corrigés, hors périmètre et
> résiduels assumés — est consigné dans
> [`docs/conformance-srd.md`](docs/conformance-srd.md).
Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
- Critique : (dés physiques + bonus plat) × multiplicateur ; les dés
énergétiques des spécificités d'arme (flaming, etc.) et les dés de précision
ne sont PAS multipliés (exception CRB « Critical Hits ») ; le 20 naturel au
jet de
confirmation confirme automatiquement la critique (conformance P2 — le jet
de confirmation est un jet d'attaque) ; la RD s'applique après
multiplication, un type de contournement annule la RD — une RD à plusieurs
@@ -330,7 +337,12 @@ Règles modélisées :
puis **échoue automatiquement** son jet de stabilisation (aucun d20 jeté) et
perd donc 1 pv de plus par tour en plus des dégâts continus (CRB « Stable
Characters and Recovery ») — elle meurt ainsi plus vite qu'une créature
mourante non saignante.
mourante non saignante. Les morts-vivants et les constructeurs n'ont pas de
score de Constitution et sont **détruits dès `hp ≤ 0`** — ni bande
d'invalidité à 0 pv, ni bande d'agonie (SRD traits « Undead » /
« Construct » : « undead do not risk death from massive damage, but are
immediately destroyed when reduced to 0 hit points or fewer ») ; le seuil
`hp ≤ -CON` ne s'applique qu'aux créatures vivantes.
- Mort simplifiée des monstres (option `--simple-monster-death`, défaut
désactivé) : quand l'option est présente dans la commande, un **monstre**
meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre
@@ -361,6 +373,26 @@ Règles modélisées :
appliqué.
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Round de surprise (SRD « Surprise ») : le champ booléen `aware` du JSON de
combattant (défaut `true`) encode la conscience des adversaires au début du
combat. Si certains combattants actifs sont `aware` et d'autres non, un
round de surprise (round 0) précède le round 1 : « In initiative order
(highest to lowest), combatants who started the battle aware of their
opponents each take a standard or move action during the surprise round.
You can also take free actions during the surprise round. » Le filtre
`_surprise_filter` applique cette allowance : une seule action standard
**ou** de mouvement (la première de la séquence gagne), les actions libres
conservées, les actions swift et à round complet retirées
(`downgraded:surprise`) ; un `full_attack` se dégrade en attaque standard
unique (même convention que le filtre accablé) et `withdraw` survit comme
retraite restreinte standard (SRD « Full-Round Actions »). « Combatants who
are unaware at the start of battle don't get to act in the surprise round »
— les non-`aware` sautent leur tour (journalisé). « If no one or everyone is
surprised, no surprise round occurs » — avec le défaut `aware: true`, le
comportement est bit-identique à l'absence de la règle. Simplification
documentée : les combattants non avertis ne sont pas marqués `flat_footed`
pendant le round de surprise (le moteur n'injecte jamais cette condition
lui-même — voir « Actions immédiates »).
- Économie d'action : un tour normal donne une action standard + une action de
mouvement (ou une action à round complet), plus swift/free/immediate. La
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
@@ -390,15 +422,28 @@ Règles modélisées :
`(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ;
`(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` →
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
voir « Temps d'incantation » plus bas ; standard par défaut) ;
**mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon` ; **swift** = `swift`, `deed`,
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
voir « Temps d'incantation » plus bas ; standard par défaut) ; exception
par action (conformance D5 — APG « Cackle ») : `hex` avec
`hex_name="cackle"` occupe le compartiment **libre** (résolu par
`_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ;
**mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon` ; exception conformance L7 (SRD « Move Actions ») : « If
you have a base attack bonus of +1 or higher, you may draw a weapon as a
free action combined with a regular move » — quand la séquence du tour
contient une action `move` ET que le combattant a un BAB ≥ +1,
`retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw`
résolu par `_effective_slot`) et n'entame pas le budget mouvement ;
`repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ;
exception S5a (Ultimate Combat, deed « Quick Clear ») : `deed` avec
`deed_name="quick_clear"` occupe le compartiment **standard** (résolu par
`_effective_slot`, le kind `deed` restant swift pour up_close_and_deadly) ;
**swift** = `swift`, `deed`,
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
@@ -429,24 +474,53 @@ Règles modélisées :
courage et est aussi retiré quand le swift est consommé par une immédiate,
via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste
hors-tour et consomme le prochain swift) ; `full_round` → round complet ;
`one_round` → **round complet par clamp** — approximation documentée : le
moteur ne tient pas d'incantation en cours entre les tours, le sort
d'1 round se résout au bout du tour courant au lieu du suivant ;
`one_round` → **round complet** — SRD « Casting Time » : « A spell that
takes 1 round to cast is a full-round action » ; l'emplacement est dépensé
et les gardes de début d'incantation (AoO, concentration) résolus sur le
tour courant, mais **l'effet est différé au début du tour suivant** du
lanceur (voir « Incantation d'1 round » ci-dessous) ;
`long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non
planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
jamais un sort à temps long). Le moteur résout le code via sa registry
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
filtres purs sous forme de résolveur `CastTimeResolver`. Les 107 sorts
filtres purs sous forme de résolveur `CastTimeResolver`. Les 108 sorts
importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés,
backfillés depuis le SRD) : 95 `standard`, 6 `one_round` (Enhance Water,
backfillés depuis le SRD) : 96 `standard`, 6 `one_round` (Enhance Water,
Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate`
(Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation,
Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
d'incantation à un round complet sans don Quickened — la signature
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
- Incantation d'1 round (`cast_time: one_round`) : SRD « Casting Time » —
« It comes into effect just before the beginning of your turn in the round
after you began casting the spell. You then act normally after the spell is
completed. » Sur le tour d'incantation, l'emplacement est dépensé, les
gardes de début d'incantation jouent (AoO d'incantation + jet de
concentration sur dégât, incantation défensive, agrippé), puis le sort est
consigné comme incantation en cours (`PendingCast` sur l'état du lanceur :
nom du sort, cible, point de centre, métamagie, niveau d'emplacement, pv au
moment de l'incantation) et **rien n'est appliqué** — les décisions
d'incantation (attaque de contact, RM, sauvegardes, effets) se prennent à la
résolution (« You make all pertinent decisions about a spell [...] when the
spell comes into effect »). Au début du tour suivant du lanceur, avant le
portail de début de tour et avant l'appel de politique, l'incantation en
cours se résout (journalisé `finishes casting X`), puis le lanceur agit
normalement. Interruption : toute perte nette de pv entre l'incantation et
la résolution impose le jet de concentration « blessé pendant l'incantation »
(DD `10 + dégâts + niveau du sort`, **sans** le bonus Combat Casting — même
helper `_check_concentration` que l'AoO d'incantation) ; un échec perd le
sort (`spell fizzles`, emplacement brûlé). Un lanceur mis hors de combat
avant sa résolution perd le sort (journalisé `lost (cannot act)`).
Simplifications documentées : l'interruption est mesurée en **perte nette
de pv à la résolution** et non par un crochet à chaque site de dégâts — un
soin intermédiaire peut masquer des dégâts subis ; l'incantation en cours
est exposée dans l'observation via la clé `pending_cast`
(`{spell_name, target_id, slot_level}` ou `None`). L'incantation `long`
(> 1 round) reste hors périmètre (jetée du budget, voir « Temps
d'incantation »).
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
@@ -472,13 +546,35 @@ Règles modélisées :
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ←
dégâts subis par un allié du préparant infligés par un ennemi. Quand
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
de swift** (au plus une action préparée par événement, sans cascade), puis
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
remplace la précédente ; le reste du tour (action de mouvement) reste
exécutable. Le snapshot expose l'état via la clé `readied`
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
(disponible via une politique personnalisée).
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
de swift** (au plus une action préparée par événement, sans cascade), puis
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
remplace la précédente ; le reste du tour (action de mouvement) reste
exécutable. **S4b — l'action préparée change l'initiative** (SRD « Ready —
Initiative Consequences of Readying ») : « Your initiative result becomes
the count on which you took the readied action, and you act immediately
ahead of the character whose action triggered your readied action » — quand
la préparation se déclenche, `_check_readied` appelle `_ready_shift_initiative`
qui réécrit la clé d'initiative du préparant dans `_initiative_keys` (même
forme de tuple que `_execute_delay` : total du déclencheur ± le pas de 0,5,
en conservant le modificateur d'initiative et l'index de liste du préparant),
si bien que `_build_round_order` conserve la nouvelle position pour **tous
les rounds suivants**. Le pas est **ajouté** (le préparant agit juste AVANT
le déclencheur), à l'inverse du retard qui le **retranche** (agir après) —
les deux intentions sont opposées. Déterministe : aucun dé consommé, l'ordre
du round courant (déjà construit) n'est pas modifié, seuls les rounds
suivants se réordonnent. Conformance L4 (SRD « Ready — Initiative
Consequences of
Readying ») : « If you come to your next action and have not yet performed
your readied action, you don't get to take the readied action (though you
can ready the same action again) » — la préparation non déclenchée
**expire au début du tour suivant** du préparant (bloc de début de tour de
`_take_turn`, journalisé `ready expired (trigger never occurred)`), avant
l'appel de politique ; elle survit donc à la fin du round courant et peut
se déclencher au round suivant tant que le tour du préparant n'est pas
arrivé. Le snapshot expose l'état via la clé `readied`
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
(disponible via une politique personnalisée).
- Défense totale (`Action(kind="total_defense")`, action standard) : le
combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au
début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé
@@ -542,7 +638,7 @@ Règles modélisées :
staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:cast_time` /
`downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ;
`downgraded:immediate_swift` / `downgraded:surprise` ;
retard valide → `ok=True, reason="delayed"`, second retard du même round →
`ok=False, reason="already_delayed"` ; préparation valide →
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
@@ -560,9 +656,16 @@ Règles modélisées :
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
rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des
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.
attaques supplémentaires à -5/-10/-15 pour les armes manufacturées. Les
armes naturelles (`natural: true` dans le JSON — morsures, griffes, sabots,
talons…) ne gagnent **jamais** d'itératifs : une attaque à outrance avec une
arme naturelle résout **un seul** balayage quel que soit le BAB (CRB
« Natural Attacks » : « You do not receive additional natural attacks for a
high base attack bonus »). Une attaque naturelle marquée `secondary: true`
(les attaques secondaires du bestiaire) frappe à **BAB −5** ; le don
Multiattack réduit cette pénalité à **−2** (CRB « Natural Attacks » /
« Multiattack »). Plusieurs armes naturelles en une seule attaque à outrance
ne sont pas modélisées.
Choix de l'arme : `Action(kind="attack", weapon_id=...)` et
`Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée
(l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au
@@ -574,8 +677,9 @@ Règles modélisées :
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
politique personnalisée).
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature
l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium
5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un
**disque** : la créature
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque
@@ -586,6 +690,19 @@ Règles modélisées :
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
allonge menace les cases adjacentes (disque de 5 ft).
**S4c — allonge nulle des créatures Tiny et plus petites** (CRB « Big and
Little Creatures in Combat » : « Tiny, Diminutive, and Fine Creatures [...]
They must enter an opponent's square to attack in melee ») : l'allonge
naturelle de Tiny/Diminutive/Fine vaut **0 ft** — une telle créature ne
**menace aucune case** (aucune AoO) et ne peut pas frapper une case
adjacente ; son attaque de mêlée n'est utilisable qu'à **distance 0**, la
case qu'elle partage avec sa cible (l'abstraction « entrer dans la case de
l'adversaire »). L'allonge effective reste `max(allonge naturelle,
reach_ft de l'arme)` : une attaque naturelle dont le JSON déclare
explicitement `reach_ft` (ierlieth : morsure `reach_ft: 5`) conserve cette
allonge — le `reach_ft` d'une attaque naturelle EST son allonge naturelle,
une créature Tiny peut donc être encodée avec une allonge non nulle si sa
fiche l'exige.
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 à
@@ -593,10 +710,22 @@ Règles modélisées :
d'osciller contre eux. Conformance L5 (SRD « Movement, Position and
Distance ») : « You can move through a square occupied by a friendly
character, unless you are charging » — `move` et `run` **traversent** les
cases des alliés (les ennemis bloquent toujours le chemin), mais ne
peuvent jamais **finir** sur une case occupée : entrer sur une case alliée
cases des alliés, mais ne peuvent jamais **finir** sur une case occupée :
entrer sur une case alliée
exige un prolongement libre (règle du « pass-through »), et la queue du
chemin est tronquée si le budget s'épuise sur une case alliée. La charge
chemin est tronquée si le budget s'épuise sur une case alliée. SRD
« Moving Through a Square » : « Any creature can move through a
square occupied by a creature three size categories larger than itself
[...] Creatures moving through squares occupied by other creatures
provoke attacks of opportunity from those creatures » — un ennemi séparé
du déplaceur par **trois catégories de taille ou plus** est traversé de
la même façon (jamais fini dessus : « You can't end your movement in the
same square as another creature unless it is helpless »), et l'entrée dans
sa case **provoque une AoO de l'occupant** (une seule par entrée fraîche,
plafonnée par l'allowance du round ; résolu par `_check_enter_aoo` dans la
marche pas à pas). Un ennemi à 0–2 catégories
reste un blocage total (« You can't move through a square occupied by an
opponent unless the opponent is helpless »). La charge
reste bloquée par les créatures (ligne droite dégagée), comme la retraite
(limite documentée : le passage à travers les alliés n'est pas modélisé
pour `withdraw` et le pas de placement auto-dirigé).
@@ -628,6 +757,21 @@ Règles modélisées :
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.
**S4c — co-occupation des créatures Tiny et plus petites** (CRB « Big and
Little Creatures in Combat » : « Very small creatures take up less than 1
square of space. This means that more than one such creature can fit into a
single square ») : une créature Tiny/Diminutive/Fine peut **traverser et
finir son déplacement** dans une case occupée par une créature plus grande,
et une créature plus grande peut traverser et finir dans une case occupée
par une créature Tiny ou plus petite — la case n'est pas « bloquée » pour
ce couple de tailles (`_can_cooccupy` : tailles différentes ET la plus
petite est Tiny ou moins). Deux créatures de même taille (ou Small et plus
grande) ne partagent jamais une case : le blocage reste total pour elles.
La co-occupation s'applique au mouvement (`_blocked`/`_blocked_hostile`
sont conscients du déplaceur), à la destination explicite (`_cells_free`)
et à la charge ; deux créatures partageant une case sont à **distance 0**
l'une de l'autre (ligne d'effet directe, attaque de mêlée possible — c'est
la voie d'attaque des créatures à allonge nulle, voir « Mêlée »).
- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et
`attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
@@ -654,12 +798,18 @@ Règles modélisées :
posé par le moteur sur l'acteur du tour) : tout swift encore présent dans
la séquence est retiré (`downgraded:immediate_swift`) et aucune seconde
immédiate ne part ce round ; le swift du tour **suivant** reste disponible
(contrairement à l'immédiate hors-tour). Simplification documentée :
l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour
suivant, le moteur la borne au round en cours.
Les actions préparées (`ready`) sont traitées sur ces mêmes
événements **avant** et indépendamment de la politique de réaction (voir
« Préparation » plus haut).
(contrairement à l'immédiate hors-tour). Simplification documentée :
l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour
suivant, le moteur la borne au round en cours. Conformance L5 (SRD
« Immediate Actions ») : « You also cannot use an immediate action if you
are flat-footed » — un combattant portant la condition `flat_footed` est
**sauté** par la porte de réaction (`_check_reactions`) : aucune immédiate
ne part tant qu'il est à plat (la condition est injectable via
`active_conditions` ; le moteur ne la pose jamais lui-même — voie
d'entrée future).
Les actions préparées (`ready`) sont traitées sur ces mêmes
événements **avant** et indépendamment de la politique de réaction (voir
« Préparation » plus haut).
- 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.
@@ -667,17 +817,26 @@ Règles modélisées :
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
confirmation de critique ; couvert mou : une créature active entre
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
une créature multi-cases (Large +) ne subit que le couvert de SA meilleure
case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
toute ligne vers tout coin des cases de la cible compte (maximum sur les
coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
quelle case occupée par une créature multi-cases pour déterminer le couvert
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee
attack against such a creature, you can pick any of the squares it occupies
to determine if it has cover against you ») — le score est donc le minimum
sur les cases de la cible en mêlée, le maximum à distance.
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
conformance L9 (`los.py`, `cover_bonus_cells`) : à distance, le score de
chaque case attaquante est le **minimum sur ses quatre coins** de (maximum
sur les lignes vers les coins de la cible) — « choose a corner of your
square » — puis le minimum sur les cases de l'attaquant ; un créneau
n'est donc jamais compté contre l'attaquant si l'un de ses coins l'évite
(le bonus reste cohérent avec la porte `has_cover_cells`, qui teste déjà
« tout coin de l'attaquant »). Une créature multi-cases (Large +)
ne subit que le couvert de SA meilleure
case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
toute ligne vers tout coin des cases de la cible compte (maximum sur les
coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
quelle case occupée par une créature multi-cases pour déterminer le couvert
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee
attack against such a creature, you can pick any of the squares it occupies
to determine if it has cover against you ») — le score est donc le minimum
sur les cases de la cible en mêlée, le maximum à distance. En mêlée, la
règle du pire coin attaquant est conservée (« any line from ANY corner of
your square »).
- Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
« Touch AC ») si un allié actif menace réellement la cible depuis la
bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
@@ -715,32 +874,49 @@ Règles modélisées :
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
considérée comme menacée — aucune AoO en la quittant. Les cases
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée).
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
considérée comme menacée — aucune AoO en la quittant. Les cases
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). Conformance L6 (SRD « Full-Round Actions — Restricted
Withdraw ») : « If you are limited to taking only a standard action each
round you can withdraw as a standard action. In this case, you may move up
to your speed » — un combattant `staggered`/invalidé qui reçoit `withdraw`
le conserve comme **action standard** (le filtre staggered ne le jette
plus) avec un budget de **1× vitesse** au lieu de 2× (`_flee_path` via
`_is_staggered_limited`). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
réutilisant exactement la mécanique de `move` (descente du champ de coût
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
AoO), **sauf que la course ne traverse pas le terrain difficile**
(conformance R2 — CRB « Movement, Position and Distance — Difficult
Terrain » : « You can't run or charge across difficult terrain ») : la
descente refuse tout pas dont le footprint toucherait une case de coût > 1
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que
le rejet de l'action). À l'achèvement, le combattant est `running` jusqu'au
début de son prochain tour : il **perd son bonus de Dextérité à la CA**
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
(`unreachable_destination`) ; sans cible ni destination, rejet
(`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée).
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c).
**S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » :
« When you run, you can move up to four times your speed **in a straight
line** ») : contrairement à `move` (descente Dijkstra qui contourne les
obstacles), la course suit la **ligne de Bresenham** entre la position
courante et la cible/destination, et **s'arrête au premier obstacle** —
terrain infranchissable, terrain difficile, ou créature bloquante — en
miroir de la logique de ligne dégagée de la charge (`_run_line_path`,
`_charge_path_clear`). Le déplacement reste pas à pas avec **provocation
d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le
drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements.
Comme pour `move`, la course **traverse les cases des alliés** sans jamais
y **finir** (SRD « Movement », conformance L5 — la queue du chemin est
tronquée) ; une créature hostile (la cible incluse) arrête la course une
case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**.
La course **ne traverse pas le terrain difficile** (conformance R2 — CRB
« Difficult Terrain » : « You can't run or charge across difficult
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
`destination` que la ligne droite ne peut pas honorer dans le budget 4×
(hors portée, bloquée par un obstacle/une créature, ou joignable uniquement
en tournant un coin ou en traversant le terrain difficile) est **rejetée**
(`unreachable_destination`) — le verdict et le chemin parcouru sont
cohérents (pas de contournement silencieux) ; sans cible ni destination,
rejet (`missing_target`). `default_policy` ne choisit pas la course
(disponible via une politique personnalisée).
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
avec tout autre mouvement (move, charge, retraite) dans le même tour —
@@ -834,7 +1010,19 @@ Règles modélisées :
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
ne sont pas modélisés. Conformance L3 (CRB « Cast a Spell — Touch Attacks »,
« Cover », « Concealment », « Range — Touch ») : le jet de contact (mêlée ou
distance) est une **attaque** — la CA de contact inclut le **couvert** de la
cible (terrain +2/+4/+8 et couvert mou +4 à distance, même chemin
`_attack_cover_bonus` que `resolve_attack` ; mêlée = ligne terrain seule), la
**dissimulation** peut faire rater l'attaque après la touche (jet de d100,
convention `max(concealment de la cible, 50)` si l'attaquant est `blinded`,
journalisé « spell lost » / « spell delivered »), et un sort de contact qui
**inflige des dégâts** menace le critique sur un 20 naturel et **double ses
dégâts** sur confirmation réussie (le jet de confirmation est un jet
d'attaque contre la même CA ; un sort de contact sans dégâts — Grace,
Ray of Enfeeblement — ne menace pas, aucun dé consommé). Magic Missile
(touche automatiquement), les sorts de
zone et les sorts à sauvegarde simple ne sont pas concernés. 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
@@ -852,12 +1040,90 @@ Règles modélisées :
centré sur la créature est inchangé. `default_policy` ne choisit pas de point
(disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
`AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic —
Aiming a Spell »). Le **burst** reste un cercle centré sur la case résolue
(cible ou `target_pos`, U7b). Le **cône** (90° — « a quarter-circle », la
largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large,
longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés
vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le
cône exclut la case d'origine, la ligne part d'un **coin** de la case du
jeteur et n'affecte **pas** sa propre case (CRB « Aiming a Spell » : « It
starts from any corner of your square » — SRD Lightning Bolt : « The bolt
begins at your fingertips », le trait quitte la case du jeteur au lieu de la
remplir ; un viseur dégénéré origine==cible n'affecte personne). Toute
créature dont une case occupée intersecte
le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un
burst (application partagée, géométrie dans `areas.py`). Le snapshot
d'observation expose `{shape, radius_ft}` via la clé `area`.
Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1,
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) »,
Shocking Grasp « 1d6 per level (maximum 5d6) ») et/ou
`"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}` (bonus plat par
niveau — Inflict Light Wounds « 1d8 + 1 per caster level (maximum +5) ») ;
à l'incantation, `effective_formula` résout dés additionnels =
`min(max_dice, niveau // per_levels)` et bonus plat =
`min(max, (niveau // per_levels) × amount)` avec la formule de base — le
total scalé suit ensuite le critique, la résistance énergétique et la
métamagie comme n'importe quelle formule (le chargeur refuse un dé de
scalage de type différent du dé de base). Absence de scalage = comportement
inchangé. Le scalage s'applique aussi aux soins (S5b) : un `HealEffect`
porte le même `scaling_flat` (bonus plat par niveau) résolu par
`effective_formula(niveau)` à l'incantation — Cure Light Wounds
« 1d8 + 1 per caster level (maximum +5) » et Cure Moderate Wounds
« 2d8 + 1 per caster level (maximum +10) » (SRD spells/cure-light-wounds.md,
spells/cure-moderate-wounds.md) sont désormais scalés au lieu d'épinger
une formule figée.
- Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer
`"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le
nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels`
(delta de niveau planché à 0), plafonné à `max` (SRD Magic Missile : « one
missile, plus one additional missile per two caster levels beyond 1st [...]
maximum of five » → 1 au niveau 1, 2 au niveau 4, 5 au niveau 9 ; Scorching
Ray : `per_levels` 4, `at_level` 3, `max` 3 → 1/2/3 rayons aux niveaux
1/7/11). À l'incantation, chaque projectile **jetle ses propres dégâts** et,
si le sort exige une attaque de contact à distance (`requires_ranged_touch`,
Scorching Ray), **jetle sa propre attaque de contact à distance** (couvert,
dissimulation et menace de critique incluss — un rayon qui rate ne inflige
rien, les autres partent quand même) ; les dégâts de sort ne sont pas réduits
par la RD, et la résistance énergétique s'applique à chaque projectile. La RS
est opposée une fois par cible (le moteur ne répartit pas les projectiles sur
plusieurs cibles — toutes touchent la cible résolue). Si la cible tombe en
cours de séquence (pv < 0 ou morte), les projectiles restants sont **perdus**
(même convention que les attaques multiples d'une arme `count` > 1). Magic
Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes
sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11)
tire désormais ses cinq missiles.
- Dégâts récurrents de sort (S2c) : un `DamageEffect` peut déclarer
`"recurring": {"formula": "2d4", "types": ["acid"], "base_rounds": 1,
"per_levels": 3, "max": 6}` — le nombre de rounds de récurrence vaut
`base_rounds + niveau // per_levels`, plafonné à `max` (SRD Acid Arrow :
« For every three caster levels you possess, the acid [...] lasts for another
round [...] dealing another 2d4 points of damage in each round » → 1 round au
niveau 1, 2 au niveau 3, 6 au niveau 18+). À l'attaque initiale réussie, une
source de dégâts récurrents (`RecurringDamage` sur le `CombatantState`, liste
`recurring_damage`) est posée sur la cible vivante ; au **début du tour** de
la cible (dans `_turn_gate`, après le tick de saignement), chaque source
jetle ses dés, est **réduite par la résistance/immunité énergétique** (acide)
mais **jamais par la RD** (c'est un dégât de sort), inflige ce dégât, puis
perd un round ; arrivée à 0 round elle est retirée. L'observation expose
chaque source sous `recurring_damage` (`{formula, types, rounds_left}`).
Simplification documentée : la neutralisation d'acide (immersion/eau) n'est
pas modélisée — le dégât court jusqu'à l'expiration de sa durée.
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à
zone, la SR est résolue par créature touchée (pas seulement sur la cible
primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
primaire). **Drapeau `spell_resistance` par sort (S2e)** : `SpellSpec` porte un
booléen `spell_resistance` (JSON `"spell_resistance"`, défaut `true`). Quand un
sort le déclare `false` (les sorts dont la fiche SRD indique « Spell Resistance:
no » — Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, les
`Protection from …`, etc. ; 28 sorts importés vérifiés contre le KB), la
vérification de SR est **totalement sautée**, tanto sur la cible directe que par
créature dans une zone. Les 28 sorts SRD « RM : non » portent désormais
`"spell_resistance": false` ; les 79 autres gardent le défaut `true`. Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
annulés par l'immunité (`immunities`) ou réduits par la résistance du type
correspondant, **composante par composante** (conformance B21, CRB « Energy
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
@@ -883,15 +1149,21 @@ Règles modélisées :
cible en porte une, après la touche et la confirmation de critique, le moteur
tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun
dégât, pas de critique, deed non consommé — journalisé « concealment »). Une
cible avec concealment **total** (`miss_chance ≥ 50 %`) ne peut pas être la
victime d'une attaque sournoise ; une dissimulation partielle (flou 20 %) ne
bloque plus la sournoise (conformance A7 — CRB rogue « Sneak Attack » :
« cannot sneak attack while striking a creature with total concealment »).
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
adversaires comme ayant un concealment total — le même jet unique de d100 est
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
concealment.
cible avec **n'importe quelle dissimulation** (`miss_chance > 0` — flou 20 %
comme dissimulation totale 50 %) ne peut pas être la victime d'une attaque
sournoise (conformance A7 — SRD rogue « Sneak Attack » :
« A rogue cannot sneak attack while striking a creature with concealment »).
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
adversaires comme ayant un concealment total — le même jet unique de d100 est
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
concealment. Conformance L2 (CRB « Conditions — Blinded » : « All opponents
are considered to have total concealment (50% miss chance) against the
blinded character ») : la même convention `max(cible, 50)` s'applique aux
deux autres portes de concealment de l'attaquant — l'attaque sournoise est
**impossible** pour un aveugle (dissimulation totale) et ses attaques
d'opportunité sont **supprimées** contre toute cible (`suppressed (total
concealment)`, budget d'AoO non consommé).
- Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses
jets d'attaque et de confirmation de critique** et sa cible **perd son bonus
de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course).
@@ -917,8 +1189,9 @@ Règles modélisées :
attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et
ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais
multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une
cible à dissimulation **totale** (`miss_chance ≥ 50 %` ; conformance A7 — une
dissimulation partielle comme le flou 20 % n'empêche plus la sournoise).
cible qui a une dissimulation, même partielle (`miss_chance > 0` ;
conformance A7 — SRD rogue : « A rogue cannot sneak attack while striking
a creature with concealment »).
Conformance P2 : les morts-vivants (`undead`) et les
constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible
ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW
@@ -927,8 +1200,12 @@ Règles modélisées :
ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au
jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un
ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses
cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à
≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité.
cases) ; la pénalité est **totalement évitée (0)** si la cible ou l'adversaire
menaçant est à ≥ 10 ft (2 cases) de l'attaquant (SRD « Attack — Shooting or
Throwing into a Melee » : « you can avoid the −4 penalty »). Simplification
documentée : la réduction RAW à −2 quand la cible est de deux catégories de
taille au-dessus des personnages amis (et 0 à trois ou plus) n'est pas
modélisée. Le don « Precise Shot » annule la pénalité.
Les attaques de mêlée ne sont pas concernées.
- Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le
terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
@@ -941,7 +1218,7 @@ Règles modélisées :
(`_expire_summons` décrémente puis retire).
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux, épinglé) n'autorise qu'une unique action
de mouvement — conformance L9 (SRD « Conditions — Nauseated ») : « The only
action such a character can take is a single move actions per turn » — seuls
les vraies actions de mouvement (`move`, `stand_up`, `retrieve_weapon`,
@@ -965,7 +1242,11 @@ Règles modélisées :
désormais réellement aux compétences de Dexterity (Acrobaties, Escalade,
Équilibre, Évasion, Vol, Équitation, Manipulation de corde, Discrétion,
Natation, Désamorçage — conformance P2) —, −2 attaque/CA/Réflexes, sans
déplacement ni AoO. Épinglé : même −4 `dex_skills`.
déplacement ni AoO. Épinglé : même −4 `dex_skills`, et `cant_attack` — un
épinglé ne peut pas attaquer (SRD « Conditions — Pinned » : il « peut
seulement tenter de se libérer ») ; lacune documentée : le moteur ne pose
**jamais** `pinned` (pas de boucle de maintien du grapple), la condition
n'est atteignable que par injection directe.
Aveuglé (conformance P2) : −2 CA, perte du bonus de Dextérité à la CA, −4
aux compétences de Dexterity (`dex_skills`) et **aucun malus plat
d'attaque** — la pénalité RAW est la dissimulation totale (50 % d'échec,
@@ -980,7 +1261,11 @@ Règles modélisées :
Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage`
points de dégâts au **début de son tour** (avant politique et flags) ; tout
effet restaurant des points de vie (sort de soin, channel energy) stoppe le
saignement (`bleed_damage = 0`) et retire la condition. Simplification
saignement (`bleed_damage = 0`) et retire la condition ; l'expiration de la
dernière condition `bleeding` inflige son **dernier tick** puis remet
`bleed_damage = 0` (conformance D2 — SRD « Bleed » : le cantrip, sauvegarde
de **Volonté** annule, pose la condition `bleeding` avec `bleed_damage = 1`,
donc 1 point de dégâts par round). Simplification
documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur
n'a pas d'action Soin).
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
@@ -990,7 +1275,9 @@ Règles modélisées :
ennemi, une seule si en allonge ; sinon le tour est perdu, journalisé).
Simplification documentée : la clause « si elle est blessée, riposte
l'agresseur » est réduite à « attaque la créature la plus proche » ; le coup
de grâce n'est pas modélisé.
de grâce n'est pas modélisé. Une créature confuse ne fait **aucune**
attaque d'opportunité (`cant_aoo`) — simplification de la clause SRD « not
already devoted to attacking », le moteur n'ayant pas ce concept.
- 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) : le jet de manœuvre est
@@ -1011,31 +1298,51 @@ Règles modélisées :
pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance
L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller
use their Dexterity modifier in place of their Strength modifier to
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
le modificateur de Dextérité à la place de celui de Force ; lacune
documentée : l'allonge nulle et la co-occupation des créatures Tiny et
plus petites ne sont pas modélisées (abstraction à la grille de 5 ft).
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
le modificateur de Dextérité à la place de celui de Force. Leur allonge
naturelle nulle (aucune case menacée, attaque dans leur propre case) et la
co-occupation Tiny-or-smaller avec une créature strictement plus grande
sont modélisées (conformance S4c) ; une attaque naturelle dotée d'un
`reach_ft` explicite (ex. morsure d'ierlieth) conserve cette allonge.
Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute
les bonus de CA des huit types autorisés (« A creature can also add any
circumstance, deflection, dodge, insight, luck, morale, profane, and
sacred bonuses to AC to its CMD » — bonus issus des buffs/conditions/
dons, défense totale et combat défensif inclus) et une créature au
dons, défense totale et combat défensif inclus), **toutes les pénalités de CA
(« Any penalties to a creature's AC also apply to its CMD » — pénalités sans
type comme grapplé −2 ou à plat ventre −4 contre la mêlée, la manœuvre
étant un jet d'attaque de mêlée)** et une créature au
contact pris de dos ne ajoute pas son bonus de Dextérité (« A flat-footed
creature does not add its Dexterity bonus to its CMD » — la pénalité de
Dextérité, qui n'est pas un bonus, reste appliquée).
(SRD « Trip » / « Bull Rush » / « Overrun ») : « You can only [affect] an
opponent who is no more than one size category larger than you » — conformance
L8 : trip, bull_rush et overrun contre une cible de **deux catégories de
taille ou plus au-dessus** de l'attaquant sont illégaux (tentative refusée,
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
(SRD « Trip » / « Bull Rush » / « Overrun » ; APG « Reposition » / « Drag ») :
« You can only [affect/reposition/drag] an opponent [who is] no more than
one size category larger than you » — conformance
L8 : trip, bull_rush, overrun, reposition et drag contre une cible de
**deux catégories de taille ou plus au-dessus** de l'attaquant sont illégaux
(tentative refusée,
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » :
« your target drops one item it is carrying » — `weapon_dropped`, exclue
de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou
plus fait tomber **l'arme de l'attaquant**) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; conformance L6
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
your attack exceeds your opponent's CMD you can push the target back an
additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle
(un 20 naturel sous le CMD ne donne que la poussée de base) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
grapple → `grappled` uniquement (la boucle de maintien du grapple et
l'application de `pinned` sont hors périmètre du moteur — la définition
de la condition `pinned` est conforme au SRD, mais elle n'est jamais
posée par le moteur lui-même) ; conformance L8 (SRD « Grapple ») : « If
you successfully grapple a creature that is not adjacent to you, move
that creature to an adjacent open space (if no space is available, your
grapple fails) » — une réussite à distance **tire la cible** sur la case
libre adjacente la plus proche (distance 5-10-5, départage (ligne,
colonne) ; journalisé `grapple pulls`), et **échoue** sans poser la
condition si aucune case adjacente libre n'existe (`FAILED (no adjacent
space)`) ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
@@ -1056,26 +1363,61 @@ Règles modélisées :
la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ;
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; 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
condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée
= 1 round + 1 round par 5 points dont l'attaque dépasse le CMD — APG
« Dirty Trick » ;
`blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles
(liste exacte de l'APG), `nauseated` exclu car hors de
la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le modèle ne porte
aucun objet transporté non tenu : en application de l'APG « Steal » (« Items
held in the hands (such as wielded weapons or wands) also cannot be taken
with the steal maneuver—you must use the disarm combat maneuver instead »),
une réussite ne vole rien (journalisé `nothing stealable — wielded weapons
require disarm`) ; reposition → déplacement de la cible de 5 ft plus
« For every 5 by which your attack exceeds your opponent's CMD, you can
move the target an additional 5 feet » (APG « Reposition ») — `1 +
marge//5` cases le long de la direction choisie (premier voisin libre par
défaut, direction sélectionnable via `Action.direction`), stoppées au
premier obstacle ; drag → traction de la cible de 5 ft plus 5 ft
supplémentaires par 5 points d'excès sur le CMD (APG « Drag »), l'attaquant
occupant sa case d'origine, stoppée au premier obstacle. `default_policy` ne
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
politique personnalisée).
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1).
- Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence`
(evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
(evil_eye, slumber, cackle) ; `evil_eye` (APG, conformance D3) inflige −2 à
**une seule option** — l'option choisie est modélisée par les jets d'attaque —
pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais
**réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ;
`slumber` (APG, conformance D4) endort la cible pour un **nombre de rounds
égal au niveau de la sorcière** (Volonté annule ; simplification documentée :
la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée,
le moteur n'a pas de crochet de réveil) ;
`cackle` (APG, conformance D5) est une **action libre sans coût de
ressource** qui prolonge d'un round tous les sortilèges evil-eye (et
fortune/misfortune — non implémentés, seule extension modélisée : les
buffs evil_eye) portés par **toutes les créatures à 30 ft** de la
sorcière, sur une liste instantanée pour ne pas s'étendre à l'infini.
Simplification documentée : le slot « hex » reste standard pour
evil_eye/slumber ; les autres hexes à prolonger ne sont pas implémentés.
Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
zone effectue la sauvegarde listée par le sort (Volonté / Réflexes /
Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès
annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
annule » (Sleep, Color Spray, Web) sauvent donc aussi.
annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de
dégâts peut déclarer `save_negates: false` (conformance D1 — SRD
« Sound Burst » : « Fortitude partial » n'annule que l'étourdissement,
jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts.
**Sauvegardes partielles (S2d)** : un `ConditionEffect` peut déclarer
`"on_success": {"condition_name": "shaken", "duration_rounds": 1}` (champ
`save_partial`) — sur une sauvegarde **réussie**, au lieu d'annuler
totalement l'effet, la cible subit la condition affaiblie indiquée (SRD
Fear : paniqué à l'échec, `shaken` 1 round au succès). Sans `on_success`,
le comportement reste « sauvegarde annule ». L'Extend Spell double aussi la
durée de la condition partielle.
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
@@ -1088,11 +1430,33 @@ Règles modélisées :
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
modélisé ; les créatures blessées obtiennent un jet de Volonté DD `10 +
demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est une attaque **d'arme à feu** à distance ET à ≤ 30 ft
(Ultimate Combat « Deeds — Up Close and Deadly » : « a successful attack
with a firearm against a target within 30 feet »), +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
deed `quick_clear` (S5a — conformance implémentée, Ultimate Combat « Deeds —
Quick Clear » : « As a standard action, you can spend 1 point from your grit
pool to remove the broken condition from a single firearm you are holding,
as long as that condition was gained by a firearm misfire ») — le deed
dépense 1 grit et répare la **première** arme à feu brisée par un raté
(ordre des attaques, déterministe) ; sans grit, l'action est un no-op
journalisé ; sans arme à feu brisée, aucun grit n'est dépensé. Le deed
occupe le compartiment **standard** du budget (exception par action résolue
par `_effective_slot`, comme cackle pour les hex ; le kind `deed` reste
swift pour up_close_and_deadly, et le filtre swift-consommé-par-immédiate
ne jette plus le quick_clear). Simplifications documentées : la variante
RAW « as part of a move action to clear all firearms » n'est pas modélisée
(un seul firearm par deed), et Quick Clear ne répare que le broken issu
d'un misfire — le broken d'un sunder se répare via `repair_weapon`.
Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When
a firearm misfires, it gains the broken condition ») : une attaque d'arme à
feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`,
journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la
progression de la valeur de misfire et l'explosion des early firearms
re-cassées ne sont pas modélisées (une arme brisée ne peut plus tirer ici)),
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
complet, incante un sort standard et porte toutes ses attaques de mêlée à
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
d'attaque et de confirmation ; simplification : le coût de pool arcanique
@@ -1122,8 +1486,12 @@ Règles modélisées :
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
weapon_broken/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack,
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
fraîche), casting_ability ou None pour non-jeteur
@@ -1157,7 +1525,10 @@ Règles modélisées :
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
readied ({kind, trigger} de l'action préparée ou None — préparation en
cours, consommée quand le déclencheur la fait tirer),
cours, consommée quand le déclencheur la fait tirer), pending_cast
({spell_name, target_id, slot_level} de l'incantation d'1 round en cours
ou None — résolue au début du prochain tour du lanceur, voir « Incantation
d'1 round »),
defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale
(+4) ou du combat défensif (+2), réinitialisé au début du tour du
combattant), fight_defensively (bool — combat défensif actif : −4 aux
@@ -1172,7 +1543,10 @@ Règles modélisées :
son tour), running (bool — course en cours jusqu'au prochain tour du
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`).
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
recurring_damage (S2c — liste des sources de dégâts récurrents de sort
actives, chacune `{formula, types, rounds_left}` ; tick au début du tour,
réduit par résistance énergétique, jamais par la RD).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
@@ -1231,18 +1605,67 @@ la lecture mais non appliquée dans la résolution.
Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées
plutôt que bricolées) :
- **Pas de round de surprise** : le modèle de combattant n'a pas de champ de
perception/attention (conscience du combat), donc le SRD « Surprise » ne
peut pas être résolu — le combat démarre toujours au round 1 complet.
- **Incantation `one_round` bornée à un round complet** : le moteur ne tient
pas d'incantation en cours entre les tours ; un sort d'1 round se résout à
la fin du tour courant au lieu du suivant (approximation documentée dans
« Temps d'incantation »). L'incantation multi-round vraie (> 1 round) est
jetée du budget (`long`).
- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur
allonge nulle et la co-occupation de leurs cases (plusieurs créatures
minuscules sur une même case) ne sont pas modélisées ; seule la substitution
Dextérité/Force du CMB (conformance L1) tient compte de leur taille.
- **Incantation multi-round vraie (`long`) hors périmètre** : le round de
surprise et l'incantation d'1 round sont modélisés (voir « Round de
surprise » et « Incantation d'1 round ») ; restent hors périmètre les temps
d'incantation > 1 round (3 rounds, 10 minutes, 1 heure — SRD « Casting
Time » : « These actions must be consecutive and uninterrupted, or the
spell automatically fails ») : non planifiables dans un tour, ils sont
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
données d'objectif pour ces sorts rituels.
- **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller
est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque
possible dans sa propre case, et partage de case avec une créature
strictement plus grande (déplacement à travers et fin de mouvement dessus) ;
en revanche deux créatures de même taille (Small et plus) ne partagent
jamais une case, et la co-occupation de plusieurs Tiny sur une même case
n'est pas modélisée.
- **Squeeze (se serrer dans un passage étroit) hors périmètre** (SRD
« Moving Around or Through an Open Square » / « Squeezing Through » : une
créature Large+ peut « squeeze » à travers un espace assez large pour une
créature d'une catégorie de taille en dessous, à coût de mouvement accru et
pénalités) : la largeur de corridor n'est pas modélisée par rapport à la
taille — une créature Huge (3×3) est simplement bloquée par tout endroit où
son footprint ne rentre pas ; ni le squeeze payant, ni la réduction de
footprint dans les couloirs ne sont modélisés.
- **Passage à travers une créature (3 catégories d'écart) — approximations** :
la règle est modélisée (traversée + AoO d'entrée, voir « Mouvement ») ;
restent hors périmètre le test d'Acrobatics « Tumbling » (« A trained
character can attempt to use Acrobatics to move through a square occupied
by an opponent » — la compétence n'existe pas dans le moteur) et la clause
des très grosses créatures inertes (« Some creatures, particularly very
large ones, may present an obstacle even when helpless. In such cases, each
square you move through counts as 2 squares » — les cases d'une créature
inactive ne bloquent pas et ne coûtent pas double).
- **Échec d'incantation pour un personnage sourd hors périmètre** (SRD
« Conditions — Deafened » : « a 20% chance of spell failure when casting
spells with verbal components ») : le moteur ne porte pas la composante
verbale des sorts (`SpellSpec` n'a pas de champ `components` — il
faudrait l'encoder sur les 108 sorts du registre) et ne modélise pas non
plus le jet de Perception DD 20 basé sur le son ni l'échec automatique
associé ; la condition `deafened` reste donc un porteur de modificateurs
(−4 d'initiative) sans la faillibilité d'incantation. Le soin des
morts-vivants par les sorts de soin (`cure`) est en revanche modélisé :
SRD « Cure Light Wounds » — « this spell deals damage to them instead of
curing their wounds [...] can attempt a Will save to take half damage ».
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
- **Fear — durée figée** (SRD spells/fear.md : « each living creature in the
area [...] becomes panicked unless it succeeds on a Will save [...]. If the
Will save succeeds, the creature is shaken for 1 round » ; cône de 30 ft —
modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic
à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus
bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds.
- **Bladed Dash — approximation** (SRD spells/bladed-dash.md : « When you cast
this spell, you immediately move up to 30 feet in a straight line any
direction [...] This movement does not provoke attacks of opportunity. You
may make a single melee attack at your highest base attack bonus against any
creature you are adjacent to at any point along this 30 feet ») : le sort est
modélisé comme un buff de vitesse +30 ft pour 1 round ; l'attaque de mêlée
gratuite, le déplacement sans AoO, la contrainte de ligne droite et le bonus
de circonstance à l'attaque (mod d'Int ou de Cha, le plus élevé) ne sont pas
modélisés.
## Architecture
@@ -1260,6 +1683,11 @@ plutôt que bricolées) :
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via
`cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`.
- `areas.py` — géométrie des gabarits de zone cône et ligne (CRB « Magic —
Aiming a Spell ») : `cone_cells` (quart de cercle de 90°, test d'angle ±45°
en arithmétique entière exacte, case d'origine exclue) et `line_cells`
(raie de Bresenham de 5 ft de large, longue de `radius_ft`, partant de la
case du jeteur). Les bursts restent gérés par `_creatures_in_range`.
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
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
@@ -1312,7 +1740,11 @@ plutôt que bricolées) :
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par
`effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e
(personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
@@ -1343,7 +1775,7 @@ plutôt que bricolées) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 714 tests
uv run pytest -q # 1491 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
@@ -1357,7 +1789,7 @@ uv run basedpyright src # mode strict
## Feuille de route
- **Phase 1** — magie et états : largement réalisée. Sorts par emplacements
(`spells.py` + base `data/spells/`, 107 sorts), DD et sauvegardes, RD non
(`spells.py` + base `data/spells/`, 108 sorts), DD et sauvegardes, RD non
appliquée aux dégâts de sorts, concentration par situation, invocation,
conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
+63 -49
View File
@@ -1,53 +1,67 @@
{
"name": "Celestial Wolf",
"level": 2,
"size": "Medium",
"abilities": {
"str_score": 13,
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 15,
"ac": {
"total": 14,
"touch": 13,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 50,
"saves": {
"fort": 4,
"ref": 5,
"will": 2
},
"attacks": [
"name": "Celestial Wolf",
"level": 2,
"size": "Medium",
"abilities": {
"str_score": 13,
"dex_score": 15,
"con_score": 15,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 13,
"ac": {
"total": 14,
"touch": 12,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 50,
"saves": {
"fort": 5,
"ref": 5,
"will": 1
},
"attacks": [
{
"id": "celestial_wolf:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 2,
"damage": [
{
"id": "celestial_wolf:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d6+1",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
"formula": "1d6+1",
"types": [
"piercing"
]
}
],
"source": "summon monster II",
"features": [],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
}
],
"dr": {
"amount": 5,
"bypass": ["magic"]
},
"resistances": {
"acid": 5,
"cold": 5,
"electricity": 5
},
"spell_resistance": 6,
"notes": "SRD Bestiary wolf (2d8+4 hp 13, AC 14/touch 12/flat 12, Fort +5, Will +1, bite +2 1d6+1 plus trip, Str 13 Dex 15 Con 15) + celestial template. Encoded from the template: DR 5/magic, energy resistance acid/cold/electricity 5, SR 6 (CR 1 + 5). Not encodable in this model: smite evil 2/day (+2 damage vs evil), the bite's free trip on hit (the engine has trip only as a standard-action maneuver, no auto-trip attack qualifier), and the weapon model has no magic property so no equipment bypasses the DR 5/magic (correct for mundane weapons). Summoning scaling (energy resistance +2 and SR +5 per summon spell level above 2nd) is not modeled; this sheet is the summon monster II baseline.",
"source": "summon monster II",
"features": [],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+66 -42
View File
@@ -1,46 +1,70 @@
{
"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": "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": [
{
"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
"formula": "1d6",
"types": [
"piercing"
]
}
],
"cmb": 6,
"cmd": 17
],
"damage_bonus": 2,
"crit_mult": 2,
"reach_ft": 10,
"natural": true
},
{
"name": "claw",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 2,
"crit_mult": 2,
"count": 2,
"reach_ft": 10,
"natural": true
}
],
"cmb": 6,
"cmd": 17
}
+10 -9
View File
@@ -6,24 +6,24 @@
"abilities": {
"str_score": 12,
"dex_score": 14,
"con_score": 14,
"con_score": 10,
"int_score": 20,
"wis_score": 14,
"cha_score": 16
},
"hp_max": 100,
"ac": {
"total": 25,
"total": 23,
"touch": 14,
"flat_footed": 23
"flat_footed": 21
},
"bab": 8,
"bab": 5,
"initiative_mod": 6,
"speed_land_ft": 30,
"saves": {
"fort": 10,
"ref": 10,
"will": 14
"fort": 6,
"ref": 7,
"will": 11
},
"casting_ability": "int",
"spell_slots": {
@@ -39,14 +39,15 @@
"id": "lich:touch",
"name": "Paralyzing Touch",
"kind": "touch",
"attack_bonus": 10,
"attack_bonus": 5,
"natural": true,
"damage": [
{
"formula": "1d8+5",
"types": ["negative"]
}
],
"damage_bonus": 5,
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
+4 -2
View File
@@ -32,7 +32,7 @@
"attack_bonus": 8,
"damage": [
{
"formula": "1d6",
"formula": "1d4",
"types": ["bludgeoning"]
}
],
@@ -40,9 +40,11 @@
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 10,
"count": 2
"count": 2,
"natural": true
}
],
"notes": "Light-horse mount shared with Esha's sheet: hooves are 1d4 bludgeoning (SRD Bestiary horse, light - 2 hooves 1d4), natural attacks. No bite encoded: the SRD light horse has no bite attack and the Foundry sheet lists no mount bite (unlike Jeanne's sheet, which does).",
"source": "fiches_personnages/esha_sheet.json (mount)",
"features": [
"Combat Reflexes",
+79 -72
View File
@@ -1,76 +1,83 @@
{
"name": "Harvie",
"level": 2,
"size": "Small",
"abilities": {
"str_score": 10,
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 15,
"ac": {
"total": 14,
"touch": 13,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 10,
"speed_fly_ft": 80,
"saves": {
"fort": 4,
"ref": 5,
"will": 2
},
"name": "Harvie",
"level": 2,
"size": "Small",
"abilities": {
"str_score": 10,
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 14,
"cha_score": 6
},
"hp_max": 15,
"ac": {
"total": 14,
"touch": 13,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 10,
"speed_fly_ft": 80,
"saves": {
"fort": 4,
"ref": 5,
"will": 2
},
"attacks": [
{
"id": "harvie:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d4",
"types": ["piercing"]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"id": "harvie:claw",
"name": "Claw",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d3",
"types": ["slashing"]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
{
"id": "harvie:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 2,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true,
"secondary": true
},
{
"id": "harvie:talons",
"name": "Talons",
"kind": "melee",
"attack_bonus": 2,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 2,
"natural": true
}
],
"source": "fiches_personnages/harvie_sheet.json",
"features": [
"Friendly Face",
"Fey-touched",
"Animal (race)"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
"source": "fiches_personnages/harvie_sheet.json",
"features": [
"Friendly Face",
"Fey-touched",
"Animal (race)"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+78 -54
View File
@@ -1,59 +1,83 @@
{
"name": "Ierlieth",
"level": 4,
"size": "Tiny",
"abilities": {
"str_score": 8,
"dex_score": 17,
"con_score": 11,
"int_score": 10,
"wis_score": 10,
"cha_score": 9
},
"hp_max": 29,
"ac": {
"total": 17,
"touch": 15,
"flat_footed": 14
},
"bab": 3,
"initiative_mod": 3,
"speed_land_ft": 15,
"speed_fly_ft": 40,
"saves": {
"fort": 3,
"ref": 6,
"will": 3
},
"attacks": [
"name": "Ierlieth",
"level": 4,
"size": "Tiny",
"abilities": {
"str_score": 8,
"dex_score": 17,
"con_score": 11,
"int_score": 10,
"wis_score": 10,
"cha_score": 9
},
"hp_max": 29,
"ac": {
"total": 17,
"touch": 15,
"flat_footed": 14
},
"bab": 3,
"initiative_mod": 3,
"speed_land_ft": 15,
"speed_fly_ft": 40,
"saves": {
"fort": 3,
"ref": 6,
"will": 3
},
"attacks": [
{
"id": "ierlieth:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"id": "ierlieth:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": -1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
},
{
"id": "ierlieth:tail",
"name": "Tail",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true,
"secondary": true
}
],
"source": "fiches_personnages/ierlieth_sheet.json",
"features": [
"Drake (race)",
"Toughness",
"Power Attack"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
"source": "fiches_personnages/ierlieth_sheet.json",
"features": [
"Drake (race)",
"Toughness",
"Power Attack"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+22 -2
View File
@@ -32,7 +32,7 @@
"attack_bonus": 8,
"damage": [
{
"formula": "1d6",
"formula": "1d4",
"types": ["bludgeoning"]
}
],
@@ -40,7 +40,27 @@
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 10,
"count": 2
"count": 2,
"natural": true
},
{
"id": "jeanne:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{
"formula": "1d3",
"types": ["piercing"]
}
],
"damage_bonus": 2,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true,
"secondary": true
}
],
"source": "fiches_personnages/jeanne_sheet.json",
+165 -162
View File
@@ -1,166 +1,169 @@
{
"name": "Misty",
"level": 4,
"size": "Medium",
"abilities": {
"str_score": 12,
"dex_score": 19,
"con_score": 14,
"int_score": 16,
"wis_score": 7,
"cha_score": 18
},
"hp_max": 31,
"ac": {
"total": 20,
"touch": 14,
"flat_footed": 16
},
"bab": 3,
"initiative_mod": 4,
"speed_land_ft": 30,
"saves": {
"fort": 7,
"ref": 9,
"will": 3
},
"casting_ability": "cha",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"bardic_rounds": 12
},
"attacks": [
"name": "Misty",
"level": 4,
"size": "Medium",
"abilities": {
"str_score": 12,
"dex_score": 19,
"con_score": 14,
"int_score": 16,
"wis_score": 7,
"cha_score": 18
},
"hp_max": 31,
"ac": {
"total": 20,
"touch": 14,
"flat_footed": 16
},
"bab": 3,
"initiative_mod": 4,
"speed_land_ft": 30,
"saves": {
"fort": 7,
"ref": 9,
"will": 3
},
"casting_ability": "cha",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"bardic_rounds": 12
},
"attacks": [
{
"name": "MWK Rapier",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{
"name": "MWK Rapier",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{
"formula": "1d6",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 18,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Dagger",
"kind": "melee",
"attack_bonus": 7,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 19,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Talons (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Bite (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Unarmed Strike",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d3",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
"formula": "1d6",
"types": [
"piercing"
]
}
],
"source": "fiches_personnages/misty_sheet.json",
"features": [
"Harmonic Spell",
"Weapon Finesse",
"Axe to Grind (combat)",
"Spell Vulnerability (divination; drawback)",
"Born Damned (race: tiefling)",
"Gifted Adept (dispel magic; magic)",
"Prehensile Tail (tiefling daemon-spawn)",
"Scaled Skin (electricity) (tiefling daemon-spawn)",
"Maw or Claw (Bite) (tiefling daemon-spawn)"
],
"spells": [
"Prestidigitation",
"Oath of Anonymity",
"Detect Magic",
"Read Magic",
"Disrupt Undead",
"Ray of Frost",
"Summon Instrument",
"Mage Hand",
"Open/Close",
"Arcane Mark",
"Acid Splash",
"Grasp",
"Shield",
"Cure Light Wounds",
"Color Spray",
"Saving Finale",
"Vanish",
"Shocking Grasp",
"Burning Hands",
"Alarm",
"Scorching Ray",
"Bladed Dash",
"Mirror Image",
"Heroism"
]
],
"damage_bonus": 1,
"crit_range": 18,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Dagger",
"kind": "melee",
"attack_bonus": 7,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 19,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Talons (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
},
{
"name": "Bite (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
},
{
"name": "Unarmed Strike",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d3",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"source": "fiches_personnages/misty_sheet.json",
"features": [
"Harmonic Spell",
"Weapon Finesse",
"Axe to Grind (combat)",
"Spell Vulnerability (divination; drawback)",
"Born Damned (race: tiefling)",
"Gifted Adept (dispel magic; magic)",
"Prehensile Tail (tiefling daemon-spawn)",
"Scaled Skin (electricity) (tiefling daemon-spawn)",
"Maw or Claw (Bite) (tiefling daemon-spawn)",
"Plot Armor"
],
"spells": [
"Prestidigitation",
"Oath of Anonymity",
"Detect Magic",
"Read Magic",
"Disrupt Undead",
"Ray of Frost",
"Summon Instrument",
"Mage Hand",
"Open/Close",
"Arcane Mark",
"Acid Splash",
"Grasp",
"Shield",
"Cure Light Wounds",
"Color Spray",
"Saving Finale",
"Vanish",
"Shocking Grasp",
"Burning Hands",
"Alarm",
"Scorching Ray",
"Bladed Dash",
"Mirror Image",
"Heroism"
]
}
+123 -122
View File
@@ -1,126 +1,127 @@
{
"name": "Oni Triumvir",
"level": 4,
"size": "Medium",
"abilities": {
"str_score": 12,
"dex_score": 14,
"con_score": 14,
"int_score": 12,
"wis_score": 18,
"cha_score": 16
},
"hp_max": 36,
"ac": {
"total": 19,
"touch": 18,
"flat_footed": 17
},
"bab": 4,
"initiative_mod": 2,
"speed_land_ft": 30,
"saves": {
"fort": 7,
"ref": 7,
"will": 9
},
"casting_ability": "wis",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"channel_energy": 6
},
"attacks": [
"name": "Oni Triumvir",
"level": 4,
"size": "Medium",
"abilities": {
"str_score": 12,
"dex_score": 14,
"con_score": 14,
"int_score": 12,
"wis_score": 18,
"cha_score": 16
},
"hp_max": 36,
"ac": {
"total": 19,
"touch": 18,
"flat_footed": 17
},
"bab": 4,
"initiative_mod": 2,
"speed_land_ft": 30,
"saves": {
"fort": 7,
"ref": 7,
"will": 9
},
"casting_ability": "wis",
"spell_slots": {
"1": 4,
"2": 3
},
"resources": {
"channel_energy": 6
},
"attacks": [
{
"name": "Longbow",
"kind": "ranged",
"attack_bonus": 6,
"damage": [
{
"name": "Longbow",
"kind": "ranged",
"attack_bonus": 6,
"damage": [
{
"formula": "1d8",
"types": [
"piercing"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 3,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 100
},
{
"name": "Alchemist's Fire",
"kind": "touch",
"attack_bonus": 6,
"damage": [
{
"formula": "1d6",
"types": [
"fire"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Attaques naturelles (Shifter Claws / formes fées)",
"kind": "melee",
"attack_bonus": 5,
"damage": [
{
"formula": "1d6",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
"formula": "1d8",
"types": [
"piercing"
]
}
],
"source": "fiches_personnages/oni_sheet.json",
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
"features": [
"Summon Good Monster",
"Sacred Summons",
"Selective Channeling",
"Extremely Fashionable",
"Exalted of the Society (Cleric, Pathfinder Society)",
"Bitter",
"Ambitious"
],
"spells": [
"Guidance",
"Detect Magic",
"Stabilize",
"Read Magic",
"Detect Poison",
"Purify Food and Drink",
"Summon Monster I",
"Shield of Faith",
"Forbid Action",
"Cure Light Wounds",
"Hidden Diplomacy",
"Protection from Evil",
"Bless",
"Summon Monster II",
"Cure Moderate Wounds",
"Sound Burst",
"Suppress Charms and Compulsions",
"Delay Poison",
"Ironskin",
"Restoration, Lesser"
]
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 3,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 100
},
{
"name": "Alchemist's Fire",
"kind": "touch",
"attack_bonus": 6,
"damage": [
{
"formula": "1d6",
"types": [
"fire"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Attaques naturelles (Shifter Claws / formes fées)",
"kind": "melee",
"attack_bonus": 5,
"damage": [
{
"formula": "1d6",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
}
],
"source": "fiches_personnages/oni_sheet.json",
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
"features": [
"Summon Good Monster",
"Sacred Summons",
"Selective Channeling",
"Extremely Fashionable",
"Exalted of the Society (Cleric, Pathfinder Society)",
"Bitter",
"Ambitious"
],
"spells": [
"Guidance",
"Detect Magic",
"Stabilize",
"Read Magic",
"Detect Poison",
"Purify Food and Drink",
"Summon Monster I",
"Shield of Faith",
"Forbid Action",
"Cure Light Wounds",
"Hidden Diplomacy",
"Protection from Evil",
"Bless",
"Summon Monster II",
"Cure Moderate Wounds",
"Sound Burst",
"Suppress Charms and Compulsions",
"Delay Poison",
"Ironskin",
"Restoration, Lesser"
]
}
+4 -3
View File
@@ -1,11 +1,12 @@
{
"name": "Abundant Ammunition",
"level": 0,
"school": "transmutation",
"level": 1,
"school": "conjuration",
"range": "close",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip that conjures duplicate ammunition: attacks with the affected ammunition do not expend it. No combat-mechanical effect in the engine (ammunition is not tracked)."
"description": "A 1st-level spell that conjures duplicate ammunition: attacks with the affected ammunition do not expend it. No combat-mechanical effect in the engine (ammunition is not tracked)."
}
+11 -3
View File
@@ -2,18 +2,26 @@
"name": "Acid Arrow",
"level": 2,
"school": "evocation",
"range": "medium",
"range": "long",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "damage",
"formula": "2d4",
"types": ["acid"],
"half_on_save": false
"half_on_save": false,
"recurring": {
"formula": "2d4",
"types": ["acid"],
"base_rounds": 1,
"per_levels": 3,
"max": 6
}
}
],
"description": "An arrow of acid springs from your hand and speeds to its target, dealing 2d4 points of acid damage. No save."
"description": "SRD spells/acid-arrow.md: ranged touch attack dealing 2d4 acid damage, plus another 2d4 acid damage at the start of each of the target's turns for 1 round + 1 round per three caster levels (maximum 6 additional rounds). Recurring damage is reduced by energy resistance/immunity, not by DR. No save."
}
+1
View File
@@ -7,6 +7,7 @@
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "damage",
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "Aid",
"level": 1,
"level": 2,
"school": "enchantment",
"range": "long",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
+2 -2
View File
@@ -2,10 +2,10 @@
"name": "Air Bubble",
"level": 1,
"school": "conjuration",
"range": "personal",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "You create a sphere of air around your head (or a touched creature's) that lasts 10 min/level, allowing breathing underwater. No combat-mechanical effect in the engine."
"description": "SRD spells/air-bubble.md: range touch - a small pocket of breathable air surrounds the touched creature's head (or a touched object) for 1 min./level. No combat-mechanical effect in the engine."
}
+2 -1
View File
@@ -2,10 +2,11 @@
"name": "Alarm",
"level": 1,
"school": "abjuration",
"range": "medium",
"range": "close",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Wards a area (20-ft radius, 20 ft high) against intrusion for 2 hours/level, alerting you mentally. No combat-mechanical effect in the engine."
}
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "Arcane Mark",
"level": 0,
"school": "divination",
"school": "universal",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Creates a permanent visible inscribed mark of your choosing on an object or surface, optionally invisible and keyed to you. Utility cantrip, no combat effect."
}
+13 -5
View File
@@ -8,11 +8,19 @@
"cast_time": "standard",
"effects": [
{
"type": "condition",
"condition_name": "shaken",
"duration_rounds": 10,
"save_negates": true
"type": "area",
"radius_ft": 50,
"targets": "enemies",
"save_for_half": false,
"effects": [
{
"type": "condition",
"condition_name": "shaken",
"duration_rounds": 10,
"save_negates": true
}
]
}
],
"description": "Enemies within range take a -1 penalty on attack rolls and saving throws (approximated with the shaken condition, -2). Will negates. Concentration 1 min/level approximated as 10 rounds."
"description": "SRD spells/bane.md: enemies within a 50-ft burst centered on the caster take a -1 penalty on attack rolls and a -1 penalty on saving throws against fear effects; Will negates. The -1 penalty is approximated with the shaken condition (-2 on attacks and saves), matching the existing Doom approximation. Duration 1 min/level approximated as 10 rounds."
}
+2 -2
View File
@@ -3,7 +3,7 @@
"level": 2,
"school": "transmutation",
"range": "touch",
"save": "fort",
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"effects": [
@@ -16,5 +16,5 @@
"duration_rounds": 60
}
],
"description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 Fortitude and +2 hit points (approximated as +2/+2). Fortitude negates (harmless). Duration 10 min/level approximated as 60 rounds."
"description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 to Fortitude saves and +2 hit points per Hit Die (SRD bear's endurance; the hp modifier is informational — the engine has no per-HD hp pool). Will negates (harmless). Duration 10 min/level approximated as 60 rounds."
}
+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "Bladed Dash",
"level": 1,
"level": 2,
"school": "transmutation",
"range": "close",
"range": "personal",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
@@ -15,5 +15,5 @@
"duration_rounds": 1
}
],
"description": "You move up your speed as a move action and make one melee attack at the end. Simplified as a +30 ft speed buff for 1 round; the extra attack is not modeled."
"description": "Upon casting, you immediately move up to 30 feet in a straight line in any direction; this movement does not provoke attacks of opportunity, and you may make a single melee attack at your highest base attack bonus against any creature you are adjacent to at any point along this 30 feet (SRD bladed dash). Simplified as a +30 ft speed buff for 1 round; the free melee attack, the no-AoO movement, the straight-line constraint and the circumstance attack bonus are not modeled."
}
+2 -2
View File
@@ -3,7 +3,7 @@
"level": 0,
"school": "necromancy",
"range": "close",
"save": "fort",
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"effects": [
@@ -14,5 +14,5 @@
"save_negates": true
}
],
"description": "A cantrip that causes a wound to bleed: the target takes 1 point of damage per round until healed. Fortitude negates. Modeled as the bleeding condition for 1 round."
"description": "SRD spells/bleed.md: a living creature at -1 or fewer hit points begins dying, taking 1 point of damage per round; Will negates. Modeled as the bleeding condition for 1 round with bleed_damage = 1 (the engine's bleed tick deals it at the start of the target's turn)."
}
+3 -2
View File
@@ -9,7 +9,8 @@
"effects": [
{
"type": "area",
"radius_ft": 20,
"radius_ft": 50,
"targets": "allies",
"save_for_half": false,
"effects": [
{
@@ -23,5 +24,5 @@
]
}
],
"description": "All allies within 20 ft/level (modeled as a 20-ft burst applied to all creatures in area) gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds."
"description": "The caster and all allies within a 50-ft burst centered on the caster gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds."
}
+2 -2
View File
@@ -3,7 +3,7 @@
"level": 2,
"school": "necromancy",
"range": "medium",
"save": "will",
"save": "fort",
"base_dc": 10,
"cast_time": "standard",
"effects": [
@@ -14,5 +14,5 @@
"save_negates": true
}
],
"description": "The target becomes blind or deaf (modeled as blinded) until removed by a caster of higher level. Permanent duration approximated as 100 rounds. Will negates."
"description": "The target becomes blind or deaf (modeled as blinded) until removed by a caster of higher level. Permanent duration approximated as 100 rounds. Fortitude negates."
}
+5 -4
View File
@@ -3,18 +3,19 @@
"level": 2,
"school": "transmutation",
"range": "touch",
"save": "fort",
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "attack", "value": 1, "bonus_type": "enhancement" },
{ "target": "damage", "value": 1, "bonus_type": "enhancement" }
{ "target": "attack", "value": 2, "bonus_type": "enhancement" },
{ "target": "damage", "value": 2, "bonus_type": "enhancement" },
{ "target": "cmb", "value": 2, "bonus_type": "enhancement" }
],
"duration_rounds": 60
}
],
"description": "The subject gains a +4 enhancement bonus to Strength, granting +2 attack and +2 damage (approximated as +1/+1). Fortitude negates (harmless)."
"description": "The subject gains a +4 enhancement bonus to Strength, granting +2 to melee attack rolls, melee damage rolls, and combat maneuver checks (SRD bull's strength). Will negates (harmless)."
}
+15 -6
View File
@@ -2,17 +2,26 @@
"name": "Burning Hands",
"level": 1,
"school": "evocation",
"range": "touch",
"range": "close",
"save": "ref",
"base_dc": 10,
"cast_time": "standard",
"effects": [
{
"type": "damage",
"formula": "1d4+1",
"types": ["fire"],
"half_on_save": true
"type": "area",
"shape": "cone",
"radius_ft": 15,
"save_for_half": true,
"effects": [
{
"type": "damage",
"formula": "0",
"types": ["fire"],
"half_on_save": true,
"scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 }
}
]
}
],
"description": "A cone of searing flame shoots from your fingertips, dealing 1d4+1 points of fire damage per caster level (max 5d4+5). Reflex save for half."
"description": "SRD spells/burning-hands.md: a 15-ft. cone of searing flame dealing 1d4 points of fire damage per caster level (maximum 5d4); Reflex half. The SRD range is 15 ft. - modeled as the closest category (close, 25 ft. at low levels)."
}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "Color Spray",
"level": 1,
"school": "enchantment",
"school": "illusion",
"range": "close",
"save": "will",
"base_dc": 10,
@@ -9,6 +9,7 @@
"effects": [
{
"type": "area",
"shape": "cone",
"radius_ft": 15,
"save_for_half": false,
"effects": [
@@ -21,5 +22,5 @@
]
}
],
"description": "A cone of chaotic colors (modeled as a 15-ft burst) blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned). Will negates."
"description": "SRD spells/color-spray.md: a 15-ft. cone of chaotic colors blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned; the HD tiers are not modeled). Will negates."
}
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "Comprehend Languages",
"level": 0,
"level": 1,
"school": "divination",
"range": "personal",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "A cantrip that lets you understand the meaning of any spoken or written language you encounter for 1 min/level. Utility spell, no combat effect."
"description": "A 1st-level spell that lets you understand the meaning of any spoken or written language you encounter for 1 min/level. Utility spell, no combat effect."
}
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "Create Water",
"level": 1,
"level": 0,
"school": "conjuration",
"range": "medium",
"range": "close",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Conjures 4 gallons of clean water per level in an open space or container. Utility spell, no combat effect."
}
+3 -2
View File
@@ -9,8 +9,9 @@
"effects": [
{
"type": "heal",
"formula": "1d8+1"
"formula": "1d8",
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 }
}
],
"description": "Cures 1d8+1 points of damage. Will save negates (harmless)."
"description": "Cures 1d8 points of damage +1 point per caster level (maximum +5). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure light wounds)."
}
+3 -2
View File
@@ -9,8 +9,9 @@
"effects": [
{
"type": "heal",
"formula": "2d8+3"
"formula": "2d8",
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 10 }
}
],
"description": "Cures 2d8+3 points of damage. Will save negates (harmless)."
"description": "Cures 2d8 points of damage +1 point per caster level (maximum +10). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure moderate wounds)."
}
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "Dancing Lights",
"level": 0,
"school": "conjuration",
"range": "close",
"school": "evocation",
"range": "medium",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip that creates up to four flickering torch-like lights you move as desired (concentration, 1 min/level). Utility spell, no combat effect."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "area",
+1 -1
View File
@@ -14,5 +14,5 @@
"save_negates": true
}
],
"description": "A cantrip that clouds the mind of a humanoid creature with 5 or fewer HD, making it unable to act for 1 round. Will negates."
"description": "SRD spells/daze.md: a cantrip that clouds the mind of a humanoid creature with 4 HD or fewer, making it unable to act for 1 round. Humanoids of 5 or more HD are not affected. Will negates."
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"name": "Deja Vu",
"level": 0,
"school": "divination",
"range": "personal",
"level": 1,
"school": "enchantment",
"range": "medium",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "A cantrip that lets you reroll one attack roll, saving throw, or skill check made in the past round (once per casting). Not modeled by the engine's action flow, so it carries no mechanical effect."
"description": "A 1st-level spell that lets you reroll one attack roll, saving throw, or skill check made in the past round (once per casting). Not modeled by the engine's action flow, so it carries no mechanical effect."
}
+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "Delay Disease",
"level": 3,
"school": "abjuration",
"level": 2,
"school": "conjuration",
"range": "touch",
"save": "fort",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "You suppress the progression of one disease the subject carries for 1 hour/level, keeping it dormant. Utility spell, no combat-mechanical effect in the engine. Fortitude negates (harmless)."
"description": "SRD spells/delay-disease.md: conjuration (healing), level 2 for nearly every divine/prime class (witch 1 for ratfolk); the engine pins the common level 2. The target becomes temporarily immune to disease for 1 day. Utility spell, no combat-mechanical effect in the engine. Fortitude negates (harmless)."
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Delay Poison",
"level": 2,
"school": "abjuration",
"school": "conjuration",
"range": "touch",
"save": "fort",
"base_dc": 10,
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip that reveals whether a creature of the outsider (evil) subtype has been in the area within 10 minutes/HD, and hints at its nature. Utility spell, no combat effect."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip that detects magical auras in a 60-ft cone (2 rounds to identify strength and school). Utility spell, no combat effect."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip that determines whether a creature, object, or area is poisoned, venomous, or afflicted by acid. Utility spell, no combat effect."
}
+12 -2
View File
@@ -6,6 +6,16 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "You draw upon divine power for 1 round/level: a +1 luck bonus on attack rolls and weapon damage per 4 caster levels. Simplified: the engine has no luck-bonus self-buff that preserves the pinned default-policy casting order, so no mechanical effect is applied."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "attack", "value": 1, "bonus_type": "luck" },
{ "target": "damage", "value": 1, "bonus_type": "luck" }
],
"duration_rounds": 10,
"scaling_flat": { "amount": 1, "per_levels": 3, "max": 2 }
}
],
"description": "SRD spells/divine-favor.md: evocation, level 1 (cleric/inquisitor/oracle/paladin/warpriest), range personal, duration 1 minute. You gain a +1 luck bonus on attack and weapon damage rolls, +1 for every three caster levels (at least +1, maximum +3). The bonus does not apply to spell damage - the engine's 'damage' modifier target only rides weapon damage rolls, so that clause holds by construction. Duration 1 minute = 10 rounds."
}
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "Doom",
"level": 1,
"school": "necromancy",
"range": "close",
"range": "medium",
"save": "will",
"base_dc": 10,
"cast_time": "standard",
@@ -14,5 +14,5 @@
"save_negates": true
}
],
"description": "The target is paralyzed with fear and becomes shaken (-2 on attacks and saves; on a successful save it takes -1 instead, not modeled). Will negates. Duration 1 min/level approximated as 10 rounds."
"description": "SRD spells/doom.md: the subject is filled with horrible dread and becomes shaken (-2 on attacks and saves). Saving Throw Will negates - there is no partial success (the earlier '-1 on a successful save' wording was a D&D 3.5 rule that does not exist in PF1e). Duration 1 min/level approximated as 10 rounds."
}
+22 -6
View File
@@ -1,11 +1,27 @@
{
"name": "Drunkard's Breath",
"level": 1,
"school": "abjuration",
"range": "personal",
"save": "none",
"level": 2,
"school": "conjuration",
"range": "close",
"save": "fort",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "For 1 hour/level you gain DR 5/- against alcohol effects and cannot become drunk. The engine has no DR buff target, so no mechanical effect is applied."
"spell_resistance": false,
"effects": [
{
"type": "area",
"shape": "cone",
"radius_ft": 30,
"save_for_half": false,
"effects": [
{
"type": "condition",
"condition_name": "nauseated",
"duration_rounds": 4,
"save_negates": true
}
]
}
],
"description": "SRD spells/drunkards-breath.md: bard 2, cleric 2, oracle 2, skald 2, warpriest 2; conjuration (creation). A cone-shaped burst emanating from your mouth functions like stinking cloud: living creatures in the area become nauseated (Fortitude negates; this is a poison effect). Spell Resistance: no. The SRD range is 30 ft. - modeled as the closest category (close). Duration 1 round/level approximated as 4 rounds (the SRD nausea lasts 1d4+1 rounds after leaving the cloud; fixed duration is the engine convention, cf. fear)."
}
+2 -2
View File
@@ -2,10 +2,10 @@
"name": "Endure Elements",
"level": 1,
"school": "abjuration",
"range": "personal",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "For 24 hours you tolerate comfortable extremes of heat and cold (from -50 to 140 degrees F) without ill effect. Environmental protection, no combat-mechanical effect in the engine."
"description": "SRD spells/endure-elements.md: range touch - the creature touched suffers no harm from hot or cold environments (from -50 to 140 degrees F) for 24 hours. Environmental protection, no combat-mechanical effect in the engine."
}
+4 -4
View File
@@ -1,9 +1,9 @@
{
"name": "Enhance Water",
"level": 2,
"school": "abjuration",
"range": "personal",
"save": "none",
"level": 1,
"school": "transmutation",
"range": "touch",
"save": "fort",
"base_dc": 10,
"cast_time": "one_round",
"effects": [],
+15 -6
View File
@@ -2,17 +2,26 @@
"name": "Fear",
"level": 4,
"school": "necromancy",
"range": "medium",
"range": "close",
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"effects": [
{
"type": "condition",
"condition_name": "frightened",
"duration_rounds": 4,
"save_negates": true
"type": "area",
"shape": "cone",
"radius_ft": 30,
"save_for_half": false,
"effects": [
{
"type": "condition",
"condition_name": "panicked",
"duration_rounds": 4,
"save_negates": true,
"on_success": { "condition_name": "shaken", "duration_rounds": 1 }
}
]
}
],
"description": "The affected creature becomes frightened and flees from the caster. Will negates."
"description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save; a creature that succeeds on the save is instead shaken for 1 round (partial save). Modeled as panicked for 4 rounds on a failed save, shaken for 1 round on a successful save. The SRD range is 30 ft. - modeled as the closest category (close)."
}
+4 -3
View File
@@ -14,12 +14,13 @@
"effects": [
{
"type": "damage",
"formula": "6d6",
"formula": "0",
"types": ["fire"],
"half_on_save": true
"half_on_save": true,
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 }
}
]
}
],
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half."
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 1d6 points of fire damage per caster level (maximum 10d6). Reflex save for half."
}
+4 -2
View File
@@ -6,10 +6,12 @@
"save": "ref",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "area",
"radius_ft": 20,
"shape": "cone",
"radius_ft": 30,
"save_for_half": true,
"effects": [
{
@@ -21,5 +23,5 @@
]
}
],
"description": "A flurry of snowballs hurtles at your foes: any creature in the area takes 4d6 points of cold damage. Reflex half. The RAW 30-ft cone-shaped burst is approximated as a 20-ft burst centered on the target point."
"description": "SRD spells/flurry-of-snowballs.md: any creature in the 30-ft. cone-shaped burst takes 4d6 points of cold damage. Reflex half. The SRD range is 30 ft. - modeled as the closest category (close)."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "immediate",
"spell_resistance": false,
"effects": [],
"description": "A cantrip cast as an immediate action when you fail a Climb check and would fall: you immediately retry the Climb check at a -2 penalty (increasing by 2 per successive use in the same situation); on success you don't fall. Climbing is not modeled by the engine, so the spell has no mechanical effects here."
}
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [
{
"type": "damage",
"formula": "1d8+1",
"formula": "1d8",
"types": ["negative"],
"half_on_save": true
"half_on_save": true,
"scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 }
}
],
"description": "Touching a creature channels negative energy, dealing 1d8 points of damage + 1 per caster level (maximum +5); at caster level 1 this is modeled as 1d8+1. Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets."
"description": "SRD spells/inflict-light-wounds.md: touching a creature channels negative energy, dealing 1d8 points of damage + 1 point per caster level (maximum +5). Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip: a touched object glows like a torch, shedding normal light in a 20-foot radius (plus dim light for 20 more feet) for 10 min/level. No combat effect; illumination is not modeled by the engine."
}
+14 -5
View File
@@ -8,11 +8,20 @@
"cast_time": "standard",
"effects": [
{
"type": "damage",
"formula": "6d6",
"types": ["electricity"],
"half_on_save": true
"type": "area",
"shape": "line",
"radius_ft": 120,
"save_for_half": true,
"effects": [
{
"type": "damage",
"formula": "0",
"types": ["electricity"],
"half_on_save": true,
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 }
}
]
}
],
"description": "You release a powerful stroke of electrical energy, dealing 6d6 points of electricity damage. Reflex save for half."
"description": "SRD spells/lightning-bolt.md: a 120-ft. line dealing 1d6 points of electricity damage per caster level (maximum 10d6). Reflex half. The line starts at the caster's cell (the bolt begins at your fingertips), so the caster's own square is in the area."
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Mage Armor",
"level": 1,
"school": "conjuration",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 4, "bonus_type": "armor" }
],
"duration_rounds": 600
}
],
"description": "An invisible but tangible field of force surrounds the subject, providing a +4 armor bonus to AC (SRD mage armor: school conjuration (creation) [force], range touch, duration 1 hour/level (modeled as a fixed 600 rounds), spell resistance: no; Will negates (harmless) is not modeled - the engine only self/target-buffs willing recipients). Known simplification: the engine bakes worn armor into the AC total of the JSON and cannot see it, so this armor bonus stacks additively with that baked-in armor value instead of replacing it; two mage armors still do not stack with each other (armor is a non-stacking bonus type)."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip: a spectral hand lifts and moves one unattended nonmagical object weighing up to 5 lb. from up to 25 ft. away, as far as 15 feet per move action while you concentrate. No combat effect; object manipulation is not modeled by the engine."
}
+3 -2
View File
@@ -11,8 +11,9 @@
"type": "damage",
"formula": "1d4+1",
"types": ["force"],
"half_on_save": false
"half_on_save": false,
"projectiles": { "base": 1, "per_levels": 2, "at_level": 1, "max": 5 }
}
],
"description": "A missile of magical energy darts forth from your fingertip and strikes its target, dealing 1d4+1 points of force damage."
"description": "SRD spells/magic-missile.md: unerring force missiles, 1d4+1 points of force damage each. One missile, plus one additional missile per two caster levels beyond 1st (two at 3rd, three at 5th, four at 7th, maximum five at 9th or higher). Auto-hit (no attack roll); spell resistance applies to the target."
}
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "long",
"spell_resistance": false,
"effects": [],
"description": "After 1 hour of casting (plus masterwork-priced reagents), a non-masterwork weapon, armor, shield, tool, or skill kit becomes its masterwork equivalent. Item crafting is outside the engine's combat model, so the spell has no mechanical effects here."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip: you whisper messages and receive replies with targeted creatures within range for 10 min/level; bystanders hear nothing without a DC 25 Perception check. Communication is not modeled by the engine, so the spell has no mechanical effects here."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "area",
+2 -1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A cantrip: minor magical tricks for 1 hour - lift 1 lb. of material, color/clean/soil items in a 1-ft cube, chill/warm/flavor 1 lb. of nonliving material, or create crude trinkets. It can deal no damage and duplicates no other spell. No combat effect; the trivial manipulations are not modeled by the engine."
"description": "SRD spells/prestidigitation.md: a cantrip - minor magical tricks for 1 hour; lift 1 lb. of material, color/clean/soil items in a 1-ft cube, chill/warm/flavor 1 lb. of nonliving material, or create crude trinkets. It deals no damage and duplicates no other spell. The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. No combat effect; the trivial manipulations are not modeled by the engine."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A barrier wards the subject from evil for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by evil creatures, renewed saves and suppression of mental control, and lawful summoned evil creatures cannot touch it. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
}
@@ -6,6 +6,7 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Functions like protection from evil (+2 deflection to AC and +2 resistance on saves vs evil creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A barrier wards the subject from lawful creatures for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by lawful creatures, plus renewed saves against mental control by lawful creatures. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
}
@@ -6,6 +6,7 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Functions like protection from law (+2 deflection to AC and +2 resistance on saves vs lawful creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
}
+1 -1
View File
@@ -7,5 +7,5 @@
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "A cantrip: cleanses 1 cu. ft./level of spoiled, rotten, diseased or poisoned food and water, making it pure and drinkable (no effect on creatures or magic potions). Will negates (object). Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
"description": "SRD spells/purify-food-and-drink.md: a cantrip - cleanses 1 cu. ft./level of spoiled, rotten, diseased or poisoned food and water (no effect on creatures or magic potions). Will negates (object). The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
}
+1 -1
View File
@@ -7,5 +7,5 @@
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "A cantrip: instantly rots 1 cu. ft./level of edible food and makes water and liquids brackish and undrinkable (can also spoil a single potion; no effect on creatures or unholy water). Will negates (object). Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
"description": "SRD spells/putrefy-food-and-drink.md: a cantrip - instantly rots 1 cu. ft./level of edible food and makes water and liquids brackish and undrinkable (can also spoil a single potion; no effect on creatures or unholy water). Will negates (object). The SRD range is 10 ft. - the engine has no 10-ft category, so the closest usable category (close) is kept. Non-combat utility: the engine does not model objects, so the spell has no mechanical effects here."
}
+7 -5
View File
@@ -9,11 +9,13 @@
"cast_time": "standard",
"effects": [
{
"type": "condition",
"condition_name": "fatigued",
"duration_rounds": 4,
"save_negates": true
"type": "penalty",
"formula": "1d6",
"scaling_flat": { "amount": 1, "per_levels": 2, "max": 5 },
"targets": ["attack", "damage"],
"duration_rounds_per_level": 1,
"half_on_save": true
}
],
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability damage, so the Strength penalty is modeled as the fatigued condition (attack/damage penalties, no charge) for 4 rounds (1 round/level at caster level 4); Fortitude halves is approximated as Fortitude negates."
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability-score track, so the Strength penalty is modeled as negative untyped modifiers on the attack and damage rolls (the two combat rolls Strength feeds) for 1 round per caster level; the SRD 'cannot drop below 1' floor and the non-stacking clause are not modeled."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "You ward a creature against attacks for 1 round/level: any opponent attempting to directly attack the warded creature must succeed on a Will save or be unable to follow through with that attack and cannot directly attack the subject for the duration. The subject cannot attack without ending the spell. Will negates. No combat effect encoded: the engine has no attack-refusal-by-save mechanic, so the protection cannot be represented."
}
+3 -2
View File
@@ -12,8 +12,9 @@
"type": "damage",
"formula": "4d6",
"types": ["fire"],
"half_on_save": false
"half_on_save": false,
"projectiles": { "base": 1, "per_levels": 4, "at_level": 3, "max": 3 }
}
],
"description": "You blast your enemy with a searing beam of fire: each ray requires a ranged touch attack and deals 4d6 points of fire damage. You fire one ray, plus one additional ray per four levels beyond 3rd (max three at 11th) — the engine models a single ray. No save."
"description": "SRD spells/scorching-ray.md: a searing beam of fire. One ray, plus one additional ray for every four caster levels beyond 3rd (to a maximum of three rays at 11th level). Each ray requires a ranged touch attack to hit and deals 4d6 points of fire damage. No save."
}
+10 -2
View File
@@ -7,6 +7,14 @@
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [],
"description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
"effects": [
{
"type": "damage",
"formula": "0",
"types": ["force"],
"half_on_save": true,
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 }
}
],
"description": "SRD spells/shard-of-chaos.md: evocation [chaotic], level 2 (cleric/oracle/warpriest), range close, dart-shaped projectile of chaotic energy delivered as a ranged touch attack, Will partial, spell resistance yes. A lawful creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on chaotic creatures, half damage to neutrals, the lawful-outsider 1d6/level + slowed variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models chaotic energy), Will half."
}
+2 -2
View File
@@ -10,10 +10,10 @@
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 4, "bonus_type": "deflection" }
{ "target": "ac", "value": 4, "bonus_type": "shield" }
],
"duration_rounds": 10
}
],
"description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds; the shield bonus type is approximated as deflection since the engine has no shield bonus type). Negating magic missile and the ranged-attack deflection are not modeled."
"description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds). Negating magic missile and the ranged-attack deflection are not modeled."
}
+11 -2
View File
@@ -6,6 +6,15 @@
"save": "will",
"base_dc": 10,
"cast_time": "standard",
"effects": [],
"description": "A shimmering magical field grants the target a +2 deflection bonus to AC (+1 per six caster levels, max +5) for 1 min./level. Will negates (harmless). Simplified: the engine has no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 2, "bonus_type": "deflection" }
],
"duration_rounds": 10,
"scaling_flat": { "amount": 1, "per_levels": 6, "max": 3 }
}
],
"description": "SRD spells/shield-of-faith.md: abjuration, level 1 (cleric/inquisitor/oracle/warpriest), range touch, duration 1 min./level, Will negates (harmless), spell resistance yes (harmless). The subject gains a +2 deflection bonus to AC, +1 per six caster levels (maximum +5 at 18th). Duration 1 min/level approximated as 10 rounds (the engine has no per-level buff duration; cf. mirror image)."
}
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [
{
"type": "damage",
"formula": "1d6",
"formula": "0",
"types": ["electricity"],
"half_on_save": false
"half_on_save": false,
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 5 }
}
],
"description": "Your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6) — modeled at caster level 1 (1d6). No save; the +3 attack bonus against metal-armored foes is not modeled."
"description": "SRD spells/shocking-grasp.md: your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6). No save; the +3 attack bonus against metal-armored foes is not modeled."
}
+4 -3
View File
@@ -10,13 +10,14 @@
{
"type": "area",
"radius_ft": 10,
"save_for_half": true,
"save_for_half": false,
"effects": [
{
"type": "damage",
"formula": "1d8",
"types": ["sonic"],
"half_on_save": true
"half_on_save": false,
"save_negates": false
},
{
"type": "condition",
@@ -27,5 +28,5 @@
]
}
],
"description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save or be stunned for 1 round (Fortitude partial — modeled as save for half damage and negates the stun). Creatures that cannot hear take the damage but are not stunned (not modeled)."
"description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save to avoid being stunned for 1 round (SRD spells/sound-burst.md: the save only negates the stun - the damage is full regardless). Creatures that cannot hear take the damage but are not stunned (not modeled)."
}
+10 -2
View File
@@ -7,6 +7,14 @@
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [],
"description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
"effects": [
{
"type": "damage",
"formula": "0",
"types": ["force"],
"half_on_save": true,
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 }
}
],
"description": "SRD spells/spear-of-purity.md: evocation [good], level 2 (cleric/oracle/warpriest), range close, spear of good energy delivered as a ranged touch attack, Will partial, spell resistance yes. An evil creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on good creatures, half damage to neutrals, the evil-outsider 1d6/level + blinded variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models good energy), Will half."
}
+2 -1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "one_round",
"spell_resistance": false,
"effects": [],
"description": "A cantrip (casting time 1 round): you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). No combat effect: summoned objects are not modeled by the engine."
"description": "SRD spells/summon-instrument.md: a cantrip (casting time 1 round) - you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). The SRD range is 0 ft. - the engine's personal category maps to 0 ft., so personal is the exact match. No combat effect: summoned objects are not modeled by the engine."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "one_round",
"spell_resistance": false,
"effects": [
{
"type": "summon",
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "one_round",
"spell_resistance": false,
"effects": [
{
"type": "summon",
+1
View File
@@ -6,6 +6,7 @@
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "An invisible, mindless, shapeless force performs simple tasks at your command for 1 hour/level: fetching, opening unstuck doors, holding chairs, cleaning or mending (effective Strength 2, speed 15 ft., no attacks, DC 10 skill limit). No combat effect: object manipulation by invisible servants is not modeled by the engine."
}
+1
View File
@@ -6,6 +6,7 @@
"save": "ref",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "area",
+251
View File
@@ -0,0 +1,251 @@
# Conformité SRD Pathfinder 1re édition — journal d'audit
Ce document consigne, de façon exhaustive et vérifiable, l'écart entre les règles
implémentées par le moteur et le SRD Pathfinder 1re édition. Il complète le
`README.md` (qui décrit le comportement attendu) en traçant **ce qui a été
corrigé**, **ce qui est hors périmètre**, et **ce qui reste assumé comme
résiduel**.
## Méthode
1. **Audit read-only par domaine** : chaque passe couvre un domaine étanche
(sorts, mouvement, morts-vivants, dons, manœuvres, conditions, données de
sorts, bestiaire). L'auditeur croise le code avec la base de connaissances
SRD (`pathfinder-kb`, collection `pathfinder-1e-rules`) et cite la règle.
2. **Classement** : `High` (change matériellement l'issue d'un combat),
`Med` (clairement faux, impact étroit), `Low` (donnée / cosmétique).
3. **Correction** : un commit par correctif, gate complète verte avant chaque
commit (`ruff` `select=ALL`, `basedpyright` strict, `pytest`).
4. **Non-régression** : le transcript doré (`tests/test_pc_combat_golden.py`)
n'est réécrit que lorsqu'un correctif SRD change légitimement la sortie,
avec justification ligne à ligne.
5. **Traçabilité** : tout constat non corrigé est classé ici en *hors
périmètre* (choix délibéré) ou *résiduel assumé* (écart connu, non corrigé).
## État de la gate
| Contrôle | Résultat |
|---|---|
| `uv run pytest -q` | 1521 tests verts |
| `uv run ruff check src tests` (`select=ALL`) | conforme |
| `uv run basedpyright src` (strict) | 0 erreur |
| Transcript doré depuis la passe 1 | inchangé |
---
## 1. Écarts corrigés
### Correctifs de la passe 2 (commits)
| Commit | Objet |
|---|---|
| `b03a61d` | Données de sorts : niveaux (`abundant_ammunition`, `comprehend_languages`, `deja_vu` 0→1 ; `create_water` 1→0 ; `enhance_water` 2→1), sauvegardes (`blindness_deafness` Will→Fort ; `enhance_water` none→Fort), RM de `summon_monster_i/ii`, écoles (6 sorts), portées (4 sorts) + pins de registre |
| `317f91a` | *Entangled* : jet de concentration DD 15 + niveau ; `dex_skills` −4 |
| `bfe0b76` | *Exhausted* : −6 Dextérité → −3 CA / −3 Réflexes |
| `6220edc` | *Fatigued* : −2 Dextérité → −1 CA / −1 Réflexes |
| `def8533` | *Nauseated* : privé d'attaques d'opportunité |
| `68a03a3` | *Confused* : privé d'attaques d'opportunité (simplification) |
| `cf8a073` | *Grappled/Pinned* : −2 aux jets de manœuvre (hors grapple/escape) |
| `f31cc88` | *Pinned* : ne peut plus attaquer |
| `4d45409` | Weapon Focus : correspondance insensible à la casse (le don s'applique enfin) |
| `2807f71` | Attaque sournoise : toute dissimulation l'annule |
| `350844f` | *Up Close And Deadly* : réservé aux armes à feu |
| `fdc9127` | *Toughness* : +3 pv sous 4 DV (`max(3, niveau)`) |
| `2b5920c` | *Disarm* : l'arme tombe (`weapon_dropped`) au lieu d'une pénalité −4 |
| `06da519` | *Dirty trick* : liste RAW étendue (`shaken`, `sickened`) |
| `0cccea2` | Manœuvres : succès automatique contre cible incapable |
| `3a5948c` | CMD : plus de double comptage de Dextérité (Tiny, CMB pré-calculé) |
| `e89ea42` | README/capacités : grapple (pin hors périmètre), `pinned`/`nauseated` sans attaque, Weapon Focus par groupe |
> Transcript doré : mis à jour une seule fois, par le correctif Weapon Focus (`4d45409`) — le +1 du don transforme le jet 15 en 16 contre CA 16, le gobelin meurt au round 4 (justification ligne à ligne dans le docstring du test).
### Passe 1 — sorts de buff, mouvement/grille, morts-vivants, armes naturelles
| # | Constat | Sév. | Correctif (commit) |
|---|---|---|---|
| B1 | *Bless* affectait **toutes** les créatures d'un burst de 20 ft (y compris les ennemis) ; le SRD cible « you and each of your allies within a 50-ft. burst » | **High** | `e43066e` — filtre `AreaEffect.targets` (`all`/`allies`/`enemies`), burst 50 ft, alliés + lanceur |
| B2 | *Bane* était un sort mono-cible ; le SRD cible les **ennemis** dans un burst de 50 ft | Med | `5e3505d` — conversion en `AreaEffect` 50 ft, `targets: "enemies"` |
| B3 | *Bladed Dash* : niveau 1 (au lieu de 2), portée `close` (au lieu de `personal`) | Med | `312f9fd` |
| B4 | *Aid* : niveau 1 (au lieu de 2), portée `long` (au lieu de `touch`) | Med | `312f9fd` |
| B5 | *Doom* : portée `close` (au lieu de `medium`) | Med | `312f9fd` |
| B6 | *Bull's Strength* : sauvegarde `fort` (le SRD n'accorde pas de jet ; valeur neutre corrigée en `will`) | Med | `312f9fd` |
| B7 | *Bear's Endurance* : sauvegarde `fort` (idem) | Med | `312f9fd` |
| B8 | *Mage Armor* absent du registre | Low | `3e19d29` — sort ajouté (niveau 1, conjuration, contact, +4 CA d'**armure**, RM non) + type de bonus `"armor"` non cumulable (registre porté à 108 sorts) |
| C1 | Budget de déplacement : parité des diagonales figée à 0 dans les descentes gloutonnes → la règle 5-10-5 était **violée** (un marcheur vitesse 30 parcourait 6 cases en diagonale au lieu de 4) | Med | `2cc2ba1` — parité propagée (`_best_move_step`, `_descend_path`, `_run_line_path`, `_flee_path`) + `_path_cost_5105` pour la charge |
| C2 | Pas de placement (5 ft) autorisé depuis/vers un terrain difficile | Med | `437507e` — rejet si le footprint courant **ou** la destination touche du terrain difficile |
| C3 | Entrer dans la case d'une créature hostile ne provoquait pas d'AoO | Med | `9a16ca0` — `_check_enter_aoo` câblé dans `_walk_steps` |
| C4 | Traversée d'une case hostile à ≥ 3 catégories d'écart de taille non modélisée | Med | `9a16ca0` — `_can_pass_through` (porte « traversée ») distincte de `_blocked` (porte « fin de mouvement ») |
| C5 | Un combattant **fatigué/épuisé** pouvait courir (seule la charge était bloquée) ; le SRD : « can neither run nor charge » | Low | `7fc7a1d` — garde `cant_charge` ajoutée à `_execute_run` |
| D1 | Les morts-vivants (et les créatures artificielles) mouraient à `hp ≤ -CON` au lieu d'être détruits à `hp ≤ 0` | Med | `59caf67` — seuil de mort à 0, pas de bande d'agonie |
| D2 | Fiche du liche non conforme au bloc SRD : BAB 8 (au lieu de +5), contact +10 (au lieu de +5), `damage_bonus` 5 doublant la formule (dégâts réels 1d8+10 au lieu de 1d8+5), CA 25/14/23 (au lieu de 23/14/21), jets 10/10/14 (au lieu de 6/7/11), `con_score` 14 alors qu'un mort-vivant n'a pas de Constitution | Low | `881f2ef` |
| D3 | Les sorts de soin (`cure`) soignaient les morts-vivants au lieu de les **blesser** | Low | `cad35e2` — dégâts (Volonté pour moitié), ni RD ni stabilisation |
| A1 | Armes naturelles : itératifs de BAB accordés à tort et absence de la pénalité −5 des attaques secondaires | Med | `e5691bd` — champs `natural`/`secondary` (une seule attaque, secondaires −5, Multiattack −2) |
### Passe 2 — dons/capacités, manœuvres, conditions, données de sorts
Constats d'audit (6 High, ~28 Med, ~45 Low). La colonne *Disposition* indique
le sort réservé : **corrigé** (commit), **à corriger**, **hors périmètre** (non
modélisable proprement) ou **résiduel assumé** (écart connu, conservé).
#### 2a. Données de sorts (vérifiées une à une contre le KB)
| Constat | Sév. | Disposition |
|---|---|---|
| `abundant_ammunition` niveau 0 → **1** (toutes classes) | High | corrigé (passe 2) |
| `comprehend_languages` niveau 0 → **1** | High | corrigé (passe 2) |
| `deja_vu` niveau 0 → **1** | High | corrigé (passe 2) |
| `blindness_deafness` sauvegarde `will` → **`fort`** | High | corrigé (passe 2) |
| `create_water` niveau 1 → **0** (clerc/druide) | Med | corrigé (passe 2) |
| `enhance_water` niveau 2 → **1**, portée personal → **touch** | Med | corrigé (passe 2) |
| `deja_vu` portée personal → **medium** | Med | corrigé (passe 2) |
| `summon_monster_i` / `summon_monster_ii` RM true → **false** | Med | corrigé (passe 2) |
| `divine_favor` — effet SRD (+1 chance attaque/dégâts par 3 niveaux) absent | Med | résiduel assumé |
| `owls_wisdom` — effet SRD (+4 Sagesse) absent | Med | résiduel assumé |
| `shield_of_faith` — effet SRD (+2 CA déflexion) absent | Med | résiduel assumé |
| `weapon_of_awe` — effet SRD (+2 dégâts sacrés) absent | Med | résiduel assumé |
| Écoles fausses : `arcane_mark` divination→**universal**, `color_spray` enchantment→**illusion**, `dancing_lights` conjuration→**evocation**, `deja_vu` divination→**enchantment**, `enhance_water` abjuration→**transmutation**, `abundant_ammunition` transmutation→**conjuration** | Low | corrigé (passe 2) |
| Portées fausses : `dancing_lights` close→**medium**, `create_water` medium→**close** | Low | corrigé (passe 2) |
| `enhance_water` sauvegarde none → **fort** (inoffensif) | Low | corrigé (passe 2) |
| Effets absents (sorts utilitaires) : `guidance`, `resistance`, `protection_from_evil`, `sanctuary`, `virtue`, `shard_of_chaos`, `spear_of_purity`, `spiritual_weapon` | Low | hors périmètre |
| *Faux positifs* (conformes) : `web` RM, `flurry_of_snowballs` RM, `detect_fiendish_presence` niveau, `scorching_ray` projectiles (max 3), `make_whole`/`restoration_lesser` temps d'incantation (`long`) | — | conformes |
| *Non vérifiable* (le KB omet la ligne RM) : `shield`, `read_magic`, `comprehend_languages` (RM) | — | indéterminé |
| Portées « pieds fixes » (15/30/50/60/120 ft) approchées par les catégories close/medium/long pour les cônes, lignes et bursts (`burning_hands`, `color_spray`, `fear`, `flurry_of_snowballs`, `lightning_bolt`, `bless`, `bane`, `obscuring_mist`, `detect_magic`) | Low | hors périmètre (modèle de portée par catégories) |
#### 2b. Conditions
| Constat | Sév. | Disposition |
|---|---|---|
| *Entangled* : aucun jet de concentration **DD 15 + niveau** à l'incantation | Med | corrigé (passe 2) |
| *Exhausted* : pénalités **−3 CA / −3 Réflexes** manquantes (−6 Dex) | Med | corrigé (passe 2) |
| *Nauseated* : peut porter des **attaques d'opportunité** | Med | corrigé (passe 2) |
| *Confused* : peut porter des attaques d'opportunité (le SRD l'interdit) | Med | corrigé (passe 2) |
| *Grappled* : **−2 aux jets de manœuvre** (sauf grapple/escape) non appliqué | Med | corrigé (passe 2) |
| *Pinned* : peut **attaquer** ; de plus la condition n'est **jamais appliquée** (code mort) | Med | corrigé (f31cc88) / hors périmètre (entretien) |
| *Fatigued* : **−1 CA / −1 Réflexes** manquants (−2 Dex) | Low | corrigé (passe 2) |
| *Fatigued/Exhausted* : pénalité de dégâts appliquée aussi aux armes à distance | Low | résiduel assumé |
| *Entangled* : `dex_skills` −4 manquant (incohérent avec grappled/pinned) | Low | corrigé (passe 2) |
| *Shaken/Sickened/Frightened/Panicked* : pénalités de compétence/caractéristique non modélisées | Low | hors périmètre (pas de tests de compétence) |
| *Frightened/Panicked* : fuite = retraite 2× au lieu de la course 4× ; acculé attend au lieu de se recroqueviller | Low | résiduel assumé |
| *Dazzled* / *Blinded* : malus de Perception (dont opposition) non modélisés | Low | hors périmètre |
| *Deafened* : échec d'incantation 20 % | Low | hors périmètre (déjà documenté) |
| *Stunned/Panicked* : « lâche tout ce qu'elle tient » non modélisé | Low | hors périmètre (pas de modèle d'objet tenu) |
| *Grappled* : « aucune action à deux mains » non modélisé | Low | hors périmètre |
| *Pinned* : composantes somatiques/matérielles non modélisées | Low | hors périmètre |
| *Bleeding* : règle de non-cumul (« le pire effet l'emporte ») non vérifiée | Low | à vérifier |
#### 2c. Manœuvres de combat
| Constat | Sév. | Disposition |
|---|---|---|
| *Grapple* : aucun entretien/pin/dégâts/déplacement ; `pinned` jamais appliqué — **le README « grapple → grappled/pinned » est faux** | High | hors périmètre (README corrigé, e89ea42) |
| *Disarm* : pénalité **−4 synthétique** au lieu du **lâcher d'arme** (le SRD fait tomber l'objet) | High | à corriger |
| *Grappled* : −2 non appliqué aux jets de manœuvre de combat | Med | corrigé (cf. 2b) |
| *Grapple* : plafond arbitraire de 10 rounds ; pas de bonus de +5 d'entretien | Med | hors périmètre |
| *Disarm* : pas d'échec-de-10 → l'attaquant lâche son arme | Med | résiduel assumé |
| *Trip* : `prone` expire en 1 round au lieu de persister jusqu'à `stand_up` | Med | résiduel assumé (README documenté) |
| *Sunder* : dégâts `str_mod + 2` fixes au lieu des dégâts d'arme ; **dureté non modélisée** | Med | résiduel assumé (README documenté) |
| *Sunder* : arme cassée retirée des attaques au lieu de **−2** (SRD : reste utilisable) | Med | résiduel assumé |
| Pas de **succès automatique** contre une cible incapable/inconsciente | Med | à corriger |
| *Reposition* : contrainte d'allonge non appliquée au déplacement | Med | résiduel assumé |
| *Dirty trick* : options **shaken/sickened** manquantes ; pas de retrait par action de mouvement | Med | à corriger |
| *Trip* : créatures sans jambes/volantes non trippables ; +2 par paire de jambes | Low | hors périmètre (pas de traits de créature) |
| *Overrun* : option « se laisser éviter » non modélisée | Low | hors périmètre |
| *Bull rush* : vérifications secondaires contre les créatures sur le passage non modélisées | Low | hors périmètre |
| *Drag* : budget de déplacement de l'attaquant non vérifié | Low | hors périmètre |
| *Disarm* : à mains nues −4, +10 → lâcher des deux objets, attente d'un round | Low | résiduel assumé |
| *Escape* : option « devenir le grappleur » non modélisée | Low | hors périmètre |
| *Grapple* : −4 si pas deux mains libres ; restrictions arme légère/Stealth | Low | hors périmètre (pas de modèle de mains) |
| *CMD* : double comptage de Dextérité latent pour une créature Tiny avec `cmb` pré-calculé | Low | à corriger |
#### 2d. Dons et capacités de classe
| Constat | Sév. | Disposition |
|---|---|---|
| **Weapon Focus (Pistolet) est un don mort** : le filtre de don compare exactement « Pistol » au nom d'arme « Braises d'Automne (pistolet ?) » → le +1 ne s'applique jamais | Med | à corriger |
| Attaque sournoise autorisée contre une **dissimulation partielle** (le SRD bloque dès « concealment » ; le README revendique à tort « total concealment ») | Med | à corriger |
| *Up Close And Deadly* s'applique à **toute** attaque à distance ; le SRD l'exige d'une **arme à feu** | Med | à corriger |
| *Toughness* : +niveau au lieu de **+3 puis +1 par DV au-delà de 3** (faux aux niveaux 1-2 ; données actuelles ≥ 3) | Low | à corriger |
| *Channel Energy* : 2d6 **figés** (pas de progression +1d6 par 2 niveaux) ; le canaliseur est toujours inclus | Low | résiduel assumé |
| *Combat Reflexes* : planché à 1 AoO (le SRD autorise 0 avec un modificateur de Dextérité négatif) | Low | résiduel assumé |
| *Cackle* : étend les `evil_eye` de **tous** les lanceurs, pas seulement les siens | Low | résiduel assumé |
| *Improved Critical* appliqué à **toutes** les armes (SRD : un type d'arme) | Low | résiduel assumé (README documenté) |
| *Inspire Courage* : +1 plat (progression +1 par 4 niveaux non modélisée) | Low | résiduel assumé (README documenté) |
| *Spell Combat* : le coût de pool arcanique est un no-op (misty n'a pas de pool) | Low | sans objet |
---
## 2. Hors périmètre (choix délibéré, documenté)
Ces règles sont **volontairement** non modélisées et signalées dans le
`README.md` (sections « Limites hors périmètre » et « Limites de sorts ») pour
ne pas être confondues avec des bogues.
| Sujet | Raison |
|---|---|
| **Squeeze** (se serrer dans un passage étroit) | La largeur de couloir n'est pas comparée à la taille ; une créature bloquée par son footprint l'est sans option de squeeze payante |
| **Acrobatics « Tumbling »** (traverser une case ennemie sans AoO) | Le moteur n'a pas de système de compétences |
| **Créature inerte très grande = obstacle à 2 cases** | Les cases d'une créature inactive ne bloquent pas et ne coûtent pas double |
| **Échec d'incantation du personnage sourd (20 %)** | `SpellSpec` n'a pas de composante verbale ; il faudrait encoder les composantes sur les 108 sorts |
| **Incantation multi-round (`long` : 3 rounds, 10 min, 1 h)** | Non planifiable dans un tour ; jetée du budget (`downgraded:cast_time`) plutôt qu'approximée |
| **Co-occupation au-delà de Tiny vs plus grand** | Deux créatures Small+ ne partagent jamais une case ; plusieurs Tiny sur une case non gérés |
| **Combat à deux armes / arme en main secondaire (×0,5 Force)** | Hors du modèle d'attaque actuel |
| **Vol 3D / vol par-dessus les obstacles** | La vitesse de vol est un budget au sol ; pas de maniabilité ni de survol du terrain |
| **`markers`** (points nommés de la carte) | Validés à la lecture, non appliqués à la résolution |
---
## 3. Résiduels assumés (écart connu, non corrigé)
| Sujet | Nature de l'écart | Décision |
|---|---|---|
| **Course — durée liée à la Constitution, don Run, armure lourde (×3)** | Le SRD limite la course à `CON` tours puis impose un jet de Constitution DD 10, et réduit la vitesse de course en armure lourde ; le moteur applique toujours ×4 | Assumé : aucun scénario courant ne course plus de quelques tours |
| **Dégâts non létaux** | Absents du modèle de PV | Assumé : aucune source de dégâts non létaux dans les données |
| **Armes à éclaboussures / dégâts d'éclaboussure** | Absents (pas d'objet concerné dans les données) | Assumé |
| **Sort « harmless » sur cible consentante** | Le moteur roule tout de même une sauvegarde (consommation de RNG) ; l'issue du soin n'en dépend pas | Assumé : fidélité de déterminisme, pas d'effet observable |
| **Réglages du liche non SRD** (pv 100 vs 111, Force, Intelligence, initiative, aura de terreur 30 ft/DD 22 vs 60 ft/DD 18) | Réglages d'équilibrage du scénario doré | Assumé : conservés pour ne pas casser la fiche dorée |
| **Dés énergétiques non multipliés par un critique** (contact du liche) | Règle moteur globale (exceptions CRB « Critical Hits ») | Assumé : cohérent avec les autres attaques |
| **`selective_channeling` non modélisé** | Le canal d'énergie affecte toutes les créatures du type visé dans la zone | Assumé (documenté au README) |
---
## 4. Conformités vérifiées (garde-fous anti-régression)
Constats explicitement contrôlés et **confirmés conformes** :
- **Résistance aux sorts** : le drapeau `spell_resistance` est respecté et
`_check_spell_resistance` court-circuite sans consommer de RNG quand la cible
n'a pas de RM.
- **Description de *Lightning Bolt*** (ligne 120 ft, 1d6/niveau plafonné 10d6,
Réflexes demi) : conforme.
- **Diagonale entrant en terrain difficile** : coût plat de 3 cases, convention
de table explicite dans `grid.py`, cohérente entre Dijkstra et descentes.
- **Terrain mixte** : les coûts de pas s'additionnent sans double comptage.
- **Déplacement d'un personnage aveuglé** : le SRD n'interdit ni le
déplacement, ni la course, ni la charge à un aveugle (seuls la touche, la CA
et les compétences sont affectés) — le constat d'audit initial ne visait pas
une vraie règle.
---
## 5. Journal des audits
| Passe | Domaines | Sessions | Constats | Statut |
|---|---|---|---|---|
| 1 | Sorts (B), Mouvement/grille (C), Morts-vivants (D), Armes naturelles (A) | `bg_9e9e22e6`, `bg_cda707bf`, `bg_ba80396a`, `bg_6a621d4c` | 1 High, 12 Med, ~20 Low | Corrigés + documentés (ci-dessus) |
| 2 | Dons/capacités (E), Manœuvres (F), Conditions (G), Données de sorts (H) | `bg_5314f54c` (re-run), `bg_9fdc610e`, `bg_f5829c68`, `bg_b289533d` + extraction/vérification `bg_e1ccd2da`, `bg_4292da47`, `bg_14d5344a` | 4 High, ~18 Med, ~45 Low | Constats consignés (§ 1 passe 2) ; correctifs data/conditions/dons en cours |
| 3+ | Bestiaire/PJ (stat blocks), résolution d'attaque/défense, économie d'action, sorts restants (68/108 non vérifiés champ par champ) | — | à lancer | À faire |
---
## 6. Relancer une passe d'audit
Chaque passe suit le même protocole :
1. Choisir un domaine **non encore audité** (voir le journal § 5).
2. Lancer un auditeur read-only (agent `oracle`) avec la base SRD `pathfinder-kb`
et l'exigence de citer la règle et la ligne de code fautive.
3. Trier chaque constat : **corriger** (si l'impact est matériel et le correctif
propre) ou **classer** en hors périmètre / résiduel assumé (ce document).
4. Un commit par correctif ; gate complète verte ; transcript doré préservé ou
justifié ligne à ligne.
5. Reporter les constats dans les tableaux ci-dessus et la ligne de journal.
+8 -5
View File
@@ -37,7 +37,10 @@ class AbilitySpec:
# ── Passive combat feats ─────────────────────────────────────────────────────
# Weapon Focus: +1 attack with a specific weapon (untyped, CRB)
# Weapon Focus: +1 attack with the named weapon GROUP (untyped, CRB) — the
# filter matches a weapon name case-insensitively as a substring (the engine
# resolves it in CombatEngine._weapon_filter_matches), so "Pistol" covers
# sheets that name individual pistols (e.g. "... (pistolet, masterwork)").
def weapon_focus(weapon_name: str) -> AbilitySpec:
return AbilitySpec(
name=f"Weapon Focus ({weapon_name})",
@@ -49,16 +52,16 @@ def weapon_focus(weapon_name: str) -> AbilitySpec:
)
# Toughness: +1 HP per Hit Die (approximated as +level HP, untyped, CRB)
# For simplicity at this stage, we model it as a flat HP bonus.
# The engine doesn't yet compute per-HD bonuses, so the caller sets the value.
# Toughness (CRB): "+3 hit points. If the character has 4 or more Hit Dice,
# she gains +1 hit point per Hit Die." => max(3, HD). The engine doesn't yet
# compute per-HD bonuses, so the caller passes the level.
def toughness(level: int) -> AbilitySpec:
return AbilitySpec(
name="Toughness",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="hp", value=level),
StatModifier(target="hp", value=max(3, level)),
),
)
+123
View File
@@ -0,0 +1,123 @@
"""PF1e area-shape geometry: cone and line templates on the 5-ft square grid.
PF1e (CRB, Magic — Aiming a Spell):
- "A cone-shaped spell shoots away from you in a quarter-circle in the
direction you designate. It starts from any corner of your square and
widens out as it goes." — modeled here as every cell whose center lies
within the cone's length of the origin and within ±45° of the aim
direction (a 90°-wide quarter circle), excluding the origin cell.
- "A line-shaped spell shoots away from you in a line in the direction you
designate. It starts from any corner of your square and extends to the
limit of its range [...]. A line-shaped spell affects all creatures in
squares through which the line passes." — modeled here as the 5-ft-wide
Bresenham ray from the origin through the aim cell, truncated to the
line's length. The line "starts from any corner of your square", so the
CASTER's own square is NOT one of the affected squares: the affected
squares are the ones beyond the origin along the ray (SRD Lightning Bolt:
"The bolt begins at your fingertips" — it leaves your square, it does not
fill it).
Bursts need no template here: they are circles around a center cell and are
resolved by ``CombatEngine._creatures_in_range`` (unchanged).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos
def cone_cells(
grid: Grid, origin: Pos, aim: Pos, radius_cells: int
) -> frozenset[Pos]:
"""Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``.
The cone opens toward ``aim`` (any cell in the aim direction, usually the
resolved target or point-cast center). A cell is affected when its offset
from the origin is within ``radius_cells`` (5-10-5 grid distance), points
strictly into the cone's forward half-plane, and stays within ±45° of the
aim bearing. The origin cell itself is never affected (CRB: the cone
"shoots away from you"). A degenerate aim (origin == aim) affects nothing.
The ±45° test is exact integer arithmetic: with v the cell offset and d
the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to
``2 * (v·d)^2 >= |v|^2 * |d|^2`` together with ``v·d > 0``.
"""
d_row = aim[0] - origin[0]
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset()
aim_sq = d_row * d_row + d_col * d_col
cells: set[Pos] = set()
for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1):
for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1):
cell = (row, col)
if cell == origin or not grid.in_bounds(cell):
continue
v_row = row - origin[0]
v_col = col - origin[1]
dot = v_row * d_row + v_col * d_col
if dot <= 0:
continue
if 2 * dot * dot < (v_row * v_row + v_col * v_col) * aim_sq:
continue
if grid.distance(origin, cell) > radius_cells:
continue
cells.add(cell)
return frozenset(cells)
def line_cells(
grid: Grid, origin: Pos, aim: Pos, length_cells: int
) -> frozenset[Pos]:
"""Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``.
The line runs along the Bresenham ray from the origin toward ``aim``,
extended to ``length_cells`` squares (CRB: it "extends to the limit of
its range"). The line "starts from any corner of your square" (CRB, Magic
— Aiming a Spell), so the CASTER's own square is NOT affected: the
``length_cells`` affected squares are the ones *beyond* the origin along
the ray. Out-of-bounds cells simply drop out of the template (creatures
only exist in bounds). A degenerate aim (origin == aim) affects nothing.
"""
if length_cells <= 0:
return frozenset()
d_row = aim[0] - origin[0]
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset()
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
# the origin (the dominant-axis step count of a Bresenham line is
# max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop.
scale = (length_cells + 1) / max(abs(d_row), abs(d_col))
end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale))
# The line is `length_cells` squares LONG starting just past the caster:
# 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning
# Bolt: "The bolt begins at your fingertips" — it leaves your square).
ray = _bresenham(origin, end)
return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell))
def _bresenham(start: Pos, end: Pos) -> list[Pos]:
"""Inclusive Bresenham cell list from ``start`` to ``end`` (both ends kept)."""
row, col = start
end_row, end_col = end
d_row = abs(end_row - row)
d_col = abs(end_col - col)
step_row = 1 if row < end_row else -1
step_col = 1 if col < end_col else -1
err = d_row - d_col
cells: list[Pos] = [(row, col)]
while (row, col) != (end_row, end_col):
e2 = 2 * err
if e2 > -d_col:
err -= d_col
row += step_row
if e2 < d_row:
err += d_row
col += step_col
cells.append((row, col))
return cells
+1931 -344
View File
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -99,36 +99,46 @@ SICKENED = Condition(
)
# Fatigued: -2 STR, -2 DEX; can't run or charge (CRB)
# Approximation: -1 attack (STR/DEX), -1 damage (STR)
# Approximation: -1 attack, -1 damage (STR/DEX), -1 AC and -1 Ref (the -2 DEX
# = -1 to each, same modelling as exhausted/entangled).
FATIGUED = Condition(
name="fatigued",
modifiers=(
StatModifier(target="attack", value=-1),
StatModifier(target="damage", value=-1),
StatModifier(target="ac", value=-1),
StatModifier(target="ref", value=-1),
),
cant_charge=True,
)
# Exhausted: -6 STR, -6 DEX; half speed; can't run or charge (CRB)
# Approximation: -3 attack, -3 damage
# Approximation: -3 attack, -3 damage (STR/DEX), -3 AC and -3 Ref (the -6 DEX
# = -3 to each, mirroring how ENTANGLED models its -4 DEX as -2 AC/-2 Ref).
EXHAUSTED = Condition(
name="exhausted",
modifiers=(
StatModifier(target="attack", value=-3),
StatModifier(target="damage", value=-3),
StatModifier(target="ac", value=-3),
StatModifier(target="ref", value=-3),
),
cant_charge=True,
speed_mult=0.5,
)
# Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB)
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX)
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX),
# -4 to Dexterity skills (mirrors grappled/pinned). Casting while entangled
# requires a DC 15 + spell level concentration check — enforced in
# ``CombatEngine._spell_cast_interrupted`` (SRD Table 9-1).
ENTANGLED = Condition(
name="entangled",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="ac", value=-2),
StatModifier(target="ref", value=-2),
StatModifier(target="dex_skills", value=-4), # -4 to Dex skills
),
cant_charge=True,
speed_mult=0.5,
@@ -197,9 +207,12 @@ PARALYZED = Condition(
)
# Nauseated: can't attack/cast/concentrate; only single move action (CRB)
# SRD "Conditions—Nauseated": "The character is unable to attack, cast
# spells... " — being unable to attack also denies attacks of opportunity.
NAUSEATED = Condition(
name="nauseated",
cant_attack=True,
cant_aoo=True,
)
# Dazed: can't act, no AC penalty (CRB)
@@ -253,6 +266,12 @@ GRAPPLED = Condition(
# Pinned: -4 Dex, -2 attack, no movement, can't cast, -4 AC vs melee (CRB)
# SRD "Conditions—Pinned": a pinned creature is tightly bound and can only
# attempt to escape (verbal/mental actions + the escape check) — it cannot
# attack, so cant_attack applies (the engine's move-only filter then blocks
# attacks, maneuvers and casts). Documented gap: the engine never APPLIES
# pinned (no grapple-maintain loop), so this definition is exercised only
# when the condition is injected directly.
PINNED = Condition(
name="pinned",
modifiers=(
@@ -263,6 +282,7 @@ PINNED = Condition(
),
cant_move=True,
cant_charge=True,
cant_attack=True,
cant_aoo=True,
denies_dex_to_ac=True,
)
@@ -304,9 +324,13 @@ BLEEDING = Condition(
# Confused: at the beginning of each turn the subject rolls d% to act randomly
# (CRB). The d% behavior is engine logic in ``_take_turn``; simplification:
# the "if attacked, attack the attacker" clause is reduced to "nearest
# creature". No stat modifiers or behavioral flags.
# creature". SRD "Conditions—Confused": a confused creature "will not make
# attacks of opportunity against anything that it is not already devoted to
# attacking" — simplified to NO AoOs at all (cant_aoo), since the engine has
# no "already devoted" concept. No stat modifiers.
CONFUSED = Condition(
name="confused",
cant_aoo=True,
)
# ── Registry ─────────────────────────────────────────────────────────────────
+42 -16
View File
@@ -1,11 +1,16 @@
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
PF1e bonus types determine whether multiple bonuses to the same stat stack:
- Stacking types (dodge, racial, trait, untyped/"") all sum together.
- Stacking types (dodge, untyped/"") all sum together (UM Table 2-7: "dodge
bonuses always stack"; CRB: "A bonus that doesn't have a type stacks with
any bonus").
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
enhancement, deflection, natural) keep only the highest value per type.
- Penalties follow the same rules: same-type penalties don't stack (the
least severe — i.e. the max value — is kept).
racial, trait, enhancement, deflection, natural, shield, armor,
circumstance) keep only the highest value per type (CRB "Combining Magic
Effects": "two bonuses of the same type don't stack even if they come from
different spells"; APG "Traits": "Trait bonuses do not stack").
- Penalties: for a non-stacking type the WORST penalty applies (same-type
penalties don't stack), and a same-type bonus and penalty apply together.
This module is the foundation for spells, conditions, feats, class abilities,
and racial traits — all of which produce StatModifier instances that the
@@ -31,12 +36,21 @@ BonusType = Literal[
"competence", # no stack
"insight", # no stack
"luck", # no stack
"racial", # stacks with itself
"trait", # stacks with itself
"racial", # no stack (CRB "Combining Magic Effects": same type)
"trait", # no stack (APG "Traits": "Trait bonuses do not stack")
"enhancement", # no stack
"deflection", # no stack
"natural", # no stack
"shield", # no stack
"armor", # no stack (SRD mage armor: "+4 armor bonus to AC")
"circumstance", # no stack (simulator can't distinguish circumstances)
# SRD "Ultimate Magic" Table 2-7: Bonus Types and Effects — remaining
# named types, all non-stacking like every typed bonus (CRB "Combining
# Magic Effects": "two bonuses of the same type don't stack").
"alchemical", # no stack (UM T2-7: ability scores, saves; antitoxin)
"inherent", # no stack (UM T2-7: ability scores; Manual of bodily health)
"resistance", # no stack (UM T2-7: saves; cloak of resistance)
"size", # no stack (UM T2-7: ability scores, attacks, AC; enlarge person)
]
StatTarget = Literal[
@@ -56,7 +70,7 @@ StatTarget = Literal[
"dex_skills",
]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
@dataclass(frozen=True)
@@ -115,21 +129,33 @@ class ActiveCondition:
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules.
Stacking bonus types (dodge, racial, trait, untyped) all sum together.
Non-stacking types keep only the highest value per type (max is used,
which correctly handles both bonuses and penalties: for bonuses the
highest is most beneficial, for penalties the highest is least severe).
Stacking bonus types (dodge, untyped) all sum together. For each
non-stacking type the best BONUS (max, floor 0) and the worst PENALTY
(min) are kept separately and then summed — same-type bonuses don't
stack (highest applies), same-type penalties don't stack (worst
applies), and a bonus and a penalty of the same type apply together
(CRB "Common Terms — Stacking": "Penalties and bonuses generally stack
with one another").
The caller is responsible for filtering by condition and weapon before
calling — this function performs pure stacking math.
"""
stacking_total = 0
best_by_type: dict[str, int] = {}
best_bonus_by_type: dict[str, int] = {}
worst_penalty_by_type: dict[str, int] = {}
for mod in modifiers:
if mod.bonus_type in _STACKING_TYPES:
stacking_total += mod.value
else:
prev = best_by_type.get(mod.bonus_type)
elif mod.value >= 0:
prev = best_bonus_by_type.get(mod.bonus_type)
if prev is None or mod.value > prev:
best_by_type[mod.bonus_type] = mod.value
return stacking_total + sum(best_by_type.values())
best_bonus_by_type[mod.bonus_type] = mod.value
else:
prev = worst_penalty_by_type.get(mod.bonus_type)
if prev is None or mod.value < prev:
worst_penalty_by_type[mod.bonus_type] = mod.value
return (
stacking_total
+ sum(best_bonus_by_type.values())
+ sum(worst_penalty_by_type.values())
)
+3
View File
@@ -154,6 +154,7 @@ _NOOP_FEATS: frozenset[str] = frozenset({
"animal (race)",
"drake (race)",
"orange - lazy ass",
"plot armor",
})
@@ -208,6 +209,8 @@ def load_combatant(path: Path) -> Combatant:
``features`` in the JSON are a list of feat name strings resolved via
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
The ``aware`` flag (SRD "Surprise") is a plain boolean of the
``Combatant`` schema (default ``true``), so it parses directly here.
"""
try:
raw = path.read_text(encoding="utf-8")
+16 -8
View File
@@ -293,10 +293,16 @@ def cover_bonus_cells(
)
# PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and,
# for a multi-square creature, the occupied square) that gives it the best
# shot, so it suffers only the MINIMUM cover over its own cells. The
# target side follows the same choice rule: RAW checks ANY line to ANY
# corner of the (chosen) square, scored by the MAXIMUM over corner lines —
# union over all target cells for ranged, best single cell for melee.
# shot, so it suffers only the MINIMUM cover over its own cells. Ranged:
# "choose a corner of your square. If any line from this corner to any
# corner of the target's square passes through cover, the target has
# cover" — each attacker corner is scored by the MAXIMUM over the target's
# corner lines (union over all target cells), and the attacker picks its
# BEST corner: MIN over attacker corners. Melee: the attacker may also
# pick any single square of a multi-square target (CRB "Big Creatures and
# Cover"), so the target side is a MIN over cells; the attacker side
# stays the worst-corner rule ("any line from ANY corner of your
# square").
per_cell_best = (
min(
max(
@@ -307,11 +313,13 @@ def cover_bonus_cells(
for cell_t in target_cells
)
if not ranged
else max(
lines.bonus(corner_a, corner_t)
else min(
max(
lines.bonus(corner_a, corner_t)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for corner_a in _corners(cell_a)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
)
+13 -2
View File
@@ -242,14 +242,18 @@ class AttackSpec(_FrozenModel):
increment and against full AC beyond it. `two_handed` marks a weapon swung
in two hands (two-handed weapon, one-handed weapon used two hands, or a
primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
with the reach weapon quality (CRB "Big and Little Creatures in Combat"):
it doubles the wielder's NATURAL reach but cannot strike at the wielder's
natural reach or less — the usable zone is a RING (dead zone = natural
reach). Without the flag a melee attack follows the creature's natural
reach, which is a DISK: a Large creature's natural attack (its JSON
`reach_ft` IS its natural reach, 10 ft) still strikes and threatens
adjacent squares.
adjacent squares. `natural` marks a natural weapon (bite, claw, etc.):
PF1e (CRB "Natural Attacks") grants no BAB iteratives for natural
weapons. `secondary` marks a secondary natural attack (tail, wing, etc.):
the engine applies -5 to the attack bonus (reduced to -2 with the
Multiattack feat).
"""
id: str
@@ -266,6 +270,8 @@ class AttackSpec(_FrozenModel):
count: int = Field(default=1, ge=1)
firearm: bool = False
two_handed: bool = False
natural: bool = False
secondary: bool = False
class DamageReduction(_FrozenModel):
@@ -309,3 +315,8 @@ class Combatant(_FrozenModel):
spell_resistance: int = 0
resistances: dict[str, int] = Field(default_factory=dict)
immunities: tuple[str, ...] = ()
# PF1e (SRD "Surprise"): "If some but not all of the combatants are aware
# of their opponents, a surprise round happens before regular rounds
# begin." Unaware combatants do not act in the surprise round. Default
# True keeps every existing encounter on a full first round.
aware: bool = True
+449 -44
View File
@@ -19,26 +19,159 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
from pf1e_simulator.dice import DiceExpr, parse_dice
from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
if TYPE_CHECKING:
from collections.abc import Callable
from pf1e_simulator.loaders.foundry import Json
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
SaveType = Literal["fort", "ref", "will", "none"]
# PF1e area templates (CRB, Magic - Aiming a Spell): burst (circle around a
# point), cone (90° quarter circle from the caster), line (5-ft ray from the
# caster to its length).
AreaShape = Literal["burst", "cone", "line"]
# PF1e area targeting (SRD spells/bless.md, spells/bane.md): a burst may
# affect only allies ("The caster and all allies within a 50-ft. burst") or
# only enemies ("Enemies within the area..."); the default affects everyone.
AreaTargets = Literal["all", "allies", "enemies"]
# PF1e casting times, normalized to budget codes (see combat._cast_time_slot).
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
@dataclass(frozen=True)
class DamageDieScaling:
"""Per-caster-level extra damage dice (PF1e: "1d6 per caster level, max 10d6").
- dice: the dice added per scaling step (e.g. 1d6)
- per_levels: caster levels required per added die
- max_dice: cap on the total dice added by the scaling
"""
dice: DiceExpr
per_levels: int
max_dice: int
@dataclass(frozen=True)
class DamageFlatScaling:
"""Per-caster-level flat damage bonus (PF1e: "+1 per caster level, max +5").
- amount: flat points added per scaling step
- per_levels: caster levels required per added step
- max_amount: cap on the total flat bonus from scaling (JSON key "max")
"""
amount: int
per_levels: int
max_amount: int
@dataclass(frozen=True)
class ProjectileScaling:
"""Multi-projectile count scaling with caster level.
PF1e (SRD spells/magic-missile.md): "one missile, plus one additional
missile per two caster levels beyond 1st [...] maximum of five missiles";
(SRD spells/scorching-ray.md): "one ray, plus one additional ray for every
four levels beyond 3rd (to a maximum of three rays at 11th level)".
- base: projectiles at the reference level (usually 1)
- at_level: caster level from which extra projectiles begin counting
- per_levels: caster levels required per additional projectile
- max_projectiles: cap (JSON key "max")
"""
base: int
at_level: int
per_levels: int
max_projectiles: int
def count_for(self, caster_level: int) -> int:
"""Projectiles fired by a caster of ``caster_level``.
``base + (caster_level - at_level) // per_levels``, the level delta
floored at 0 (a caster below the reference level still fires the base
projectiles), capped at ``max_projectiles``.
"""
extra = max(0, caster_level - self.at_level) // self.per_levels
return min(self.max_projectiles, self.base + extra)
@dataclass(frozen=True)
class RecurringDamageSpec:
"""Per-round recurring spell damage (PF1e Acid Arrow).
- formula: dice rolled in each recurring round (2d4)
- types: energy types (acid) — reduced by energy resistance/immunity,
never by DR (it is spell damage)
- base_rounds / per_levels / max_rounds: rounds of recurrence scale with
caster level as ``base_rounds + caster_level // per_levels`` capped at
``max_rounds`` (SRD spells/acid-arrow.md: "1 round + 1 round per three
levels [...] to a maximum of 6 additional rounds").
"""
formula: DiceExpr
types: tuple[str, ...]
base_rounds: int
per_levels: int
max_rounds: int
def rounds_for(self, caster_level: int) -> int:
"""Recurring rounds for a caster of ``caster_level``."""
return min(self.max_rounds, self.base_rounds + caster_level // self.per_levels)
@dataclass(frozen=True)
class DamageEffect:
formula: DiceExpr
types: tuple[str, ...]
# False = save negates (targeted); True = half damage on save (ray/line/cone/AoE)
half_on_save: bool = False
# Only consulted when half_on_save is False. True = a successful save deals
# no damage (save negates, the targeted-spell default); False = the save has
# no effect on this damage at all (SRD Sound Burst: "Fortitude partial"
# negates only the stun — every creature takes the full dice regardless).
save_negates: bool = True
# PF1e caster-level scaling (CRB Magic — Caster Level; e.g. Fireball
# "1d6 points of fire damage per caster level (maximum 10d6)"). Absent =
# level-independent damage, the pre-S1b behaviour.
scaling: DamageDieScaling | None = None
scaling_flat: DamageFlatScaling | None = None
# PF1e multi-projectile spells (Magic Missile, Scorching Ray): the effect
# fires `count_for(caster_level)` projectiles, each rolling its own damage
# and (for a ray) its own attack. Absent = a single projectile.
projectiles: ProjectileScaling | None = None
# PF1e recurring (damage-over-time) component (Acid Arrow): after the
# initial hit the target takes this damage again at the start of each of
# its turns for `rounds_for(caster_level)` rounds. Absent = no DoT.
recurring: RecurringDamageSpec | None = None
def effective_formula(self, caster_level: int) -> DiceExpr:
"""The damage formula resolved for a caster of ``caster_level``.
PF1e (CRB, Magic — Caster Level): spells whose damage is "per caster
level" grow with the caster and stop at the stated cap. Extra dice =
min(max_dice, level // per_levels); flat bonus = min(max,
(level // per_levels) * amount). The loader guarantees the scaling
dice share the base formula's die type (or the base is flat).
"""
count = self.formula.count
sides = self.formula.sides
bonus = self.formula.bonus
if self.scaling is not None:
extra = min(
self.scaling.max_dice, caster_level // self.scaling.per_levels
) * self.scaling.dice.count
if count == 0 and extra > 0:
sides = self.scaling.dice.sides
count += extra
if self.scaling_flat is not None:
bonus += min(
self.scaling_flat.max_amount,
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
)
return DiceExpr(count=count, sides=sides, bonus=bonus)
@dataclass(frozen=True)
@@ -46,32 +179,106 @@ class ConditionEffect:
condition_name: str
duration_rounds: int
save_negates: bool = True
# PF1e partial save (SRD spells/fear.md): on a SUCCESSFUL save the target
# is not spared outright but suffers a weaker condition instead. Encoded as
# (condition_name, duration_rounds); None = a successful save fully negates.
save_partial: tuple[str, int] | None = None
@dataclass(frozen=True)
class HealEffect:
formula: DiceExpr
# PF1e caster-level scaling for healing (SRD spells/cure-light-wounds.md:
# "cures 1d8 points of damage +1 point per caster level (maximum +5)" and
# cure-moderate-wounds.md: "cures 2d8 points of damage +1 point per caster
# level (maximum +10)"). Absent = level-independent healing, the pre-S5b
# behaviour.
scaling_flat: DamageFlatScaling | None = None
def effective_formula(self, caster_level: int) -> DiceExpr:
"""The healing formula resolved for a caster of ``caster_level``.
PF1e (CRB, Magic — Caster Level): the flat bonus grows with the caster
and stops at the stated cap — bonus = min(max, (level // per_levels)
* amount), mirroring ``DamageEffect.effective_formula``.
"""
bonus = self.formula.bonus
if self.scaling_flat is not None:
bonus += min(
self.scaling_flat.max_amount,
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
)
return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus)
@dataclass(frozen=True)
class BuffEffect:
"""Transient stat modifiers (PF1e buff spells).
``scaling_flat`` (SRD shield of faith "+2 deflection, +1 per six caster
levels (maximum +5)", divine favor "+1 per three caster levels (maximum
+3)") adds the resolved flat bonus to EVERY modifier's value at cast
time, mirroring ``DamageEffect.effective_formula``'s flat scaling.
"""
modifiers: tuple[StatModifier, ...]
duration_rounds: int
scaling_flat: DamageFlatScaling | None = None
@dataclass(frozen=True)
class PenaltyEffect:
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
The engine has no ability-score track, so the Strength penalty is
modeled on the two combat rolls Strength feeds — attack and damage
rolls — as negative untyped StatModifiers for the spell's duration.
"""
formula: DiceExpr
scaling_flat: DamageFlatScaling | None = None
targets: tuple[StatTarget, ...] = ()
duration_rounds_per_level: int = 1
half_on_save: bool = True
def effective_formula(self, caster_level: int) -> DiceExpr:
"""The penalty formula resolved for a caster of ``caster_level``.
PF1e (CRB, Magic — Caster Level): the flat bonus grows with the
caster and stops at the stated cap — bonus = min(max, (level //
per_levels) * amount), mirroring ``DamageEffect.effective_formula``.
"""
bonus = self.formula.bonus
if self.scaling_flat is not None:
bonus += min(
self.scaling_flat.max_amount,
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
)
return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus)
@dataclass(frozen=True)
class AreaEffect:
"""Area of effect spell (burst only for Phase 0).
"""Area of effect spell (burst / cone / line).
- shape: only "burst" supported (circle around target point)
- radius_ft: radius in feet (e.g., 20 for fireball)
PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on a chosen
point; a cone "shoots away from you in a quarter-circle in the direction
you designate"; a line "shoots away from you in a line in the direction
you designate" and "affects all creatures in squares through which the
line passes". Cones and lines therefore originate at the CASTER and are
aimed at the resolved target cell (or the U7b point-cast center).
- shape: "burst" (circle), "cone" (90° quarter circle) or "line" (5 ft wide)
- radius_ft: burst radius / cone length / line length, in feet
- effects: the effects applied to each creature in the area
- save_for_half: if True, successful save halves damage
- targets: which creatures in the area are affected ("all", "allies",
"enemies" — SRD bless/bane bursts)
"""
shape: Literal["burst"] = "burst"
shape: AreaShape = "burst"
radius_ft: int = 20
effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True
targets: AreaTargets = "all"
@dataclass(frozen=True)
class SummonEffect:
@@ -84,7 +291,10 @@ class SummonEffect:
duration_rounds: int
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
SpellEffect = (
DamageEffect | ConditionEffect | HealEffect | BuffEffect
| PenaltyEffect | AreaEffect | SummonEffect
)
@dataclass(frozen=True)
@@ -100,6 +310,11 @@ class SpellSpec:
# area/save-only spells keep this False.
requires_ranged_touch: bool = False
cast_time: CastTime = "standard"
# PF1e (CRB, Magic — Spell Resistance): most spells are subject to a
# target's SR, but the SRD stat block marks a handful "Spell Resistance:
# no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check
# entirely (targeted and per-creature in areas). Default True.
spell_resistance: bool = True
effects: tuple[SpellEffect, ...] = ()
description: str = ""
@@ -190,6 +405,14 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
return value in ("fort", "ref", "will", "none")
def _is_area_shape(value: str) -> TypeGuard[AreaShape]:
return value in get_args(AreaShape)
def _is_area_targets(value: str) -> TypeGuard[AreaTargets]:
return value in get_args(AreaTargets)
def _is_cast_time(value: str) -> TypeGuard[CastTime]:
return value in get_args(CastTime)
@@ -227,16 +450,21 @@ def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
return value in get_args(StatTarget)
def _parse_stat_target(value: Json) -> StatTarget:
target = _as_str(value)
if not _is_stat_target(target):
msg = f"unknown stat target: {target!r}"
raise ValueError(msg)
return target
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
return value in get_args(BonusType)
def _parse_modifier(value: Json) -> StatModifier:
m = _as_dict(value)
target = _as_str(m["target"])
if not _is_stat_target(target):
msg = f"unknown stat target: {target!r}"
raise ValueError(msg)
target = _parse_stat_target(m["target"])
bonus = _as_str(m.get("bonus_type", ""))
if not _is_bonus_type(bonus):
msg = f"unknown bonus type: {bonus!r}"
@@ -250,43 +478,219 @@ def _parse_modifier(value: Json) -> StatModifier:
)
def _parse_die_scaling(value: Json) -> DamageDieScaling:
data = _as_dict(value)
dice = parse_dice(_as_str(data["dice"]))
per_levels = _as_int(data.get("per_levels", 1))
if per_levels < 1:
msg = f"scaling per_levels must be >= 1, got {per_levels}"
raise ValueError(msg)
max_dice = _as_int(data["max_dice"])
if max_dice < 0:
msg = f"scaling max_dice must be >= 0, got {max_dice}"
raise ValueError(msg)
return DamageDieScaling(dice=dice, per_levels=per_levels, max_dice=max_dice)
def _parse_flat_scaling(value: Json) -> DamageFlatScaling:
data = _as_dict(value)
amount = _as_int(data.get("amount", 1))
per_levels = _as_int(data.get("per_levels", 1))
if per_levels < 1:
msg = f"scaling_flat per_levels must be >= 1, got {per_levels}"
raise ValueError(msg)
max_amount = _as_int(data["max"])
if max_amount < 0:
msg = f"scaling_flat max must be >= 0, got {max_amount}"
raise ValueError(msg)
return DamageFlatScaling(
amount=amount, per_levels=per_levels, max_amount=max_amount
)
def _parse_projectiles(value: Json) -> ProjectileScaling:
data = _as_dict(value)
base = _as_int(data.get("base", 1))
at_level = _as_int(data.get("at_level", 1))
per_levels = _as_int(data["per_levels"])
if base < 1:
msg = f"projectiles base must be >= 1, got {base}"
raise ValueError(msg)
if per_levels < 1:
msg = f"projectiles per_levels must be >= 1, got {per_levels}"
raise ValueError(msg)
if at_level < 1:
msg = f"projectiles at_level must be >= 1, got {at_level}"
raise ValueError(msg)
max_projectiles = _as_int(data["max"])
if max_projectiles < base:
msg = f"projectiles max must be >= base, got {max_projectiles} < {base}"
raise ValueError(msg)
return ProjectileScaling(
base=base,
at_level=at_level,
per_levels=per_levels,
max_projectiles=max_projectiles,
)
def _parse_recurring(value: Json) -> RecurringDamageSpec:
data = _as_dict(value)
formula = parse_dice(_as_str(data["formula"]))
base_rounds = _as_int(data.get("base_rounds", 1))
per_levels = _as_int(data["per_levels"])
max_rounds = _as_int(data["max"])
if base_rounds < 0:
msg = f"recurring base_rounds must be >= 0, got {base_rounds}"
raise ValueError(msg)
if per_levels < 1:
msg = f"recurring per_levels must be >= 1, got {per_levels}"
raise ValueError(msg)
if max_rounds < base_rounds:
msg = f"recurring max must be >= base_rounds, got {max_rounds} < {base_rounds}"
raise ValueError(msg)
return RecurringDamageSpec(
formula=formula,
types=tuple(_as_str(t) for t in _as_list(data["types"])),
base_rounds=base_rounds,
per_levels=per_levels,
max_rounds=max_rounds,
)
def _parse_save_partial(value: Json) -> tuple[str, int]:
data = _as_dict(value)
duration = _as_int(data["duration_rounds"])
if duration < 1:
msg = f"on_success duration_rounds must be >= 1, got {duration}"
raise ValueError(msg)
return (_as_str(data["condition_name"]), duration)
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
formula = parse_dice(_as_str(effect["formula"]))
scaling_raw = effect.get("scaling")
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
if (
scaling is not None
and formula.count > 0
and scaling.dice.sides != formula.sides
):
msg = (
"damage scaling dice must match the base formula die type: "
f"{dice_to_str(scaling.dice)} vs {dice_to_str(formula)}"
)
raise ValueError(msg)
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
proj_raw = effect.get("projectiles")
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
rec_raw = effect.get("recurring")
recurring = None if rec_raw is None else _parse_recurring(rec_raw)
return DamageEffect(
formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(effect.get("half_on_save", False)),
save_negates=bool(effect.get("save_negates", True)),
scaling=scaling,
scaling_flat=scaling_flat,
projectiles=projectiles,
recurring=recurring,
)
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
partial_raw = effect.get("on_success")
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
return ConditionEffect(
condition_name=_as_str(effect["condition_name"]),
duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(effect.get("save_negates", True)),
save_partial=save_partial,
)
def _parse_heal_effect(effect: dict[str, Json]) -> HealEffect:
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
return HealEffect(
formula=parse_dice(_as_str(effect["formula"])),
scaling_flat=scaling_flat,
)
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
return BuffEffect(
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
scaling_flat=scaling_flat,
)
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
targets: tuple[StatTarget, ...] = tuple(
_parse_stat_target(t) for t in _as_list(effect["targets"])
)
return PenaltyEffect(
formula=parse_dice(_as_str(effect["formula"])),
scaling_flat=scaling_flat,
targets=targets,
duration_rounds_per_level=_as_int(effect.get("duration_rounds_per_level", 1)),
half_on_save=bool(effect.get("half_on_save", True)),
)
def _parse_area_effect(effect: dict[str, Json]) -> AreaEffect:
shape_raw = _as_str(effect.get("shape", "burst"))
if not _is_area_shape(shape_raw):
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
raise ValueError(msg)
targets_raw = _as_str(effect.get("targets", "all"))
if not _is_area_targets(targets_raw):
msg = (
f"unknown area targets: {targets_raw!r} "
f"(expected one of {get_args(AreaTargets)})"
)
raise ValueError(msg)
return AreaEffect(
shape=shape_raw,
radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)),
targets=targets_raw,
)
def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect:
return SummonEffect(
creature=_as_str(effect["creature"]),
duration_rounds=_as_int(effect["duration_rounds"]),
)
_EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
"damage": _parse_damage_effect,
"condition": _parse_condition_effect,
"heal": _parse_heal_effect,
"buff": _parse_buff_effect,
"penalty": _parse_penalty_effect,
"area": _parse_area_effect,
"summon": _parse_summon_effect,
}
def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data)
effect_type = _as_str(effect["type"])
if effect_type == "damage":
return DamageEffect(
formula=parse_dice(_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", False)),
)
if effect_type == "condition":
return ConditionEffect(
condition_name=_as_str(effect["condition_name"]),
duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(effect.get("save_negates", True)),
)
if effect_type == "heal":
return HealEffect(
formula=parse_dice(_as_str(effect["formula"])),
)
if effect_type == "buff":
return BuffEffect(
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
)
if effect_type == "area":
return AreaEffect(
radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)),
)
if effect_type == "summon":
return SummonEffect(
creature=_as_str(effect["creature"]),
duration_rounds=_as_int(effect["duration_rounds"]),
)
msg = f"Unknown effect type: {effect_type}"
raise ValueError(msg)
parser = _EFFECT_PARSERS.get(effect_type)
if parser is None:
msg = f"Unknown effect type: {effect_type}"
raise ValueError(msg)
return parser(effect)
def load_spell(path: str | Path) -> SpellSpec:
@@ -310,6 +714,7 @@ def load_spell(path: str | Path) -> SpellSpec:
base_dc=_as_int(data.get("base_dc", 10)),
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
cast_time=cast_time,
spell_resistance=bool(data.get("spell_resistance", True)),
effects=effects,
description=_as_str(data.get("description", "")),
)
+44 -13
View File
@@ -158,26 +158,27 @@ def test_total_concealment_denies_sneak_attack() -> None:
assert not any("sneak" in line for line in eng._transcript)
def test_partial_concealment_still_allows_sneak_attack() -> None:
"""(A7) a blurred (20%) target CAN be sneak attacked — CRB rogue text is
"cannot sneak attack while striking a creature with total concealment",
so partial concealment no longer denies the precision damage."""
def test_partial_concealment_denies_sneak_attack() -> None:
"""(A7) a blurred (20%) target CANNOT be sneak attacked — SRD rogue text
is "A rogue cannot sneak attack while striking a creature with
concealment": ANY concealment denies the precision damage (the rogue
must see the target well enough to pick out a vital spot)."""
sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine(
[sneak, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 90, 6, 6, 4],
[15, 90, 6],
)
a, t = eng._states
_add_condition(t, "flat_footed")
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# d20=15 hits, d100=90 > 20% lands; 2d6 sneak (6+6=12) + 1d6 weapon (4)
# = 16 precision-inclusive damage.
assert result.damage == 16
assert t.hp == 84
assert any("sneak attack" in line and "2d6" in line for line in eng._transcript)
# d20=15 hits, d100=90 > 20% lands; sneak denied by the 20% concealment,
# so only the 1d6 weapon die (6) lands.
assert result.damage == 6
assert t.hp == 94
assert not any("sneak" in line for line in eng._transcript)
def test_concealment_miss_does_not_consume_deed() -> None:
@@ -337,7 +338,13 @@ def test_firing_into_melee_minus4() -> None:
assert t.hp == 100
def test_firing_into_melee_minus2_when_target_10ft_away() -> None:
def test_firing_into_melee_not_avoided_when_ally_engaged_5ft_away() -> None:
# PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target
# [...] is at least 10 feet away from the nearest friendly character, you
# can avoid the -4 penalty" — the exemption is measured from the TARGET to
# the attacker's nearest friendly character, not from the attacker. The
# archer is 10 ft from the target, but the target is engaged at 5 ft by
# the archer's own ally (the guard) -> the -4 still applies.
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
@@ -349,8 +356,32 @@ def test_firing_into_melee_minus2_when_target_10ft_away() -> None:
)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 15
assert result.melee_penalty == -2
assert result.melee_penalty == -4
assert result.total == 13
assert result.hit is False
assert t.hp == 100
def test_firing_into_melee_avoided_when_nearest_ally_20ft_away() -> None:
# The target is engaged (a third-party melee threat stands next to it),
# but the attacker's nearest friendly character is 20 ft from the target
# -> the -4 is avoided even though the archer itself is only 5 ft away.
# The OLD (buggy) rule measured attacker->target/attacker->threat and
# would wrongly apply the -4 here.
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
ally = _combatant("ally", ac=99)
raider = _combatant("raider") # neither the archer's side nor the target's
eng = _engine(
[archer, tgt, ally, raider],
[(0, 0), (0, 1), (0, 5), (1, 2)],
["players", "monsters", "players", "gnolls"],
[10, 3],
)
a, t, _al, _r = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.melee_penalty == 0
assert result.total == 18
assert result.hit is True
assert result.damage == 3
+156 -9
View File
@@ -127,6 +127,24 @@ def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid:
return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend))
def _walled_grid(size: int = 16) -> Grid:
"""Open grid with a wall block at rows 1-4, col 5 (a straight-line barrier).
A hero at (0, 0) running toward a foe at (2, 8) would have to turn the
corner around the wall (a Dijkstra ``move`` does); the straight-line run
must instead stop at the wall face.
"""
terrain = []
for r in range(size):
row = ["#" if (1 <= r <= 4 and c == 5) else "." for c in range(size)]
terrain.append("".join(row))
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
return Grid.from_spec(MapSpec(name="walled", terrain=tuple(terrain), legend=legend))
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
@@ -275,6 +293,32 @@ def test_failed_aid_grants_nothing() -> None:
assert hero.hp == 97
# ── (c2) the aid bonus expires at the AIDER's next turn, not the beneficiary's ─
def test_aid_bonus_survives_beneficiary_turn_and_expires_at_aider_turn() -> None:
# SRD "Aid Another": the +2 lasts "as long as that attack comes before the
# beginning of your next turn" — your = the AIDER's. The ally aids the
# hero (ac) in round 1; the hero's own round-2 turn start must NOT clear
# the bonus, the ally's round-2 turn start must.
seen: dict[str, int] = {}
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
if cid == "hero" and engine._current_round == 2:
seen["hero_turn"] = state.aid_ac_bonus
if cid == "foe" and engine._current_round == 2:
seen["foe_turn"] = engine._states[0].aid_ac_bonus
if cid == "ally" and engine._current_round == 1:
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
return _WAIT
# Rolls: 3 initiatives (10,10,10); aid d20=10+5=15 >= 10 success.
eng = _trio(policy, [10, 10, 10, 10], round_cap=2)
eng.run()
assert seen["hero_turn"] == 2 # survived the beneficiary's turn start
assert seen["foe_turn"] == 0 # expired at the aider's turn start
# ── (d) run moves up to 4x speed (destination and target), sets running ──────
@@ -340,7 +384,9 @@ def test_run_toward_target_outreaches_normal_move() -> None:
)
eng2.run()
move_hero = eng2._states[0]
assert move_hero.pos == (6, 6) # only 6 cells of the 15-cell path
# 5-10-5: 4 diagonal cells cost 1+2+1+2 = 6 = the speed budget; the old
# cell-count budget let a speed-30 walker cover 6 diagonal cells.
assert move_hero.pos == (4, 4) # only 4 diagonal cells of the 15-cell path
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
@@ -368,12 +414,13 @@ def test_run_stops_before_difficult_terrain_toward_target() -> None:
assert eng._states[0].pos == (2, 0)
def test_run_stops_before_difficult_terrain_to_destination() -> None:
def test_run_beyond_difficult_terrain_to_destination_is_rejected() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination beyond it
Then: the descent stops in front of the rubble (the destination is
reachable in budget, but the run cannot cross — it moves up to the
obstacle and stops)."""
When: hero runs to an explicit destination ONLY reachable across it
Then: the destination is rejected (unreachable_destination) — the
reachability check uses the same avoid-difficult constraint as the
descent (CRB: "You can't run or charge across difficult terrain"), so
the verdict and the walked path agree instead of silently stopping short."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
@@ -382,9 +429,28 @@ def test_run_stops_before_difficult_terrain_to_destination() -> None:
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
# The default foe sits at (0, 1), adjacent to the start: the run walks
# out of its threatened disk and provokes one (missed) AoO.
eng = _duo(policy, [10, 5, 1, 1], round_cap=1, hero=hero, grid=grid)
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
def test_run_destination_in_front_of_difficult_terrain_is_reachable() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination that avoids the rubble
Then: the run honours it (the field routes around difficult cells, the
budget is generous — the verdict is positive and the path arrives)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 0)),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
# The default foe at (0, 1) threatens the start: the run provokes one
# (missed) AoO on the first step — scripted as a natural 1.
eng = _duo(policy, [10, 5, 1], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (2, 0)
assert "round 1 hero: run" in result.transcript
@@ -409,6 +475,87 @@ def test_move_can_enter_difficult_terrain_but_run_cannot() -> None:
assert eng._states[0].pos[0] > 3
# ── (d3) S4a: a run is a straight line (SRD Full-Round Actions — Run) ─────────
def test_run_does_not_turn_a_corner_around_a_wall() -> None:
"""Given: hero at (0,0), a wall column at col 5 (rows 1-4), foe at (2,8)
When: hero runs toward the foe (a Dijkstra move would bend around the wall)
Then: the run follows the straight Bresenham line and stops at the wall
face — it does NOT turn the corner (SRD Run: "move up to four times your
speed in a straight line")."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (2, 8))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# The straight line (0,0)->(2,8) crosses col 5 at rows 1-2 (both wall), so
# the run halts before the wall face and never reaches or bends past it.
assert hero_state.pos[1] < 5
assert hero_state.running is True
# Contrast: a plain move DOES route around the wall (bends the corner).
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe2 = make_state(_combatant("foe"), "monsters", (2, 8))
eng2 = _duo(
lambda _e, s: (
(Action(kind="move", target_id="foe"),)
if s.combatant.id == "hero" else _WAIT
),
[10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid,
)
eng2.run()
assert eng2._states[0].pos[1] > 5 # the move got around to the far side
def test_clear_straight_run_still_works() -> None:
"""Given: an open grid, hero (0,0), foe far along a clear straight line
When: hero runs toward the foe
Then: the straight-line run reaches adjacent to the target (unchanged
behaviour on an unobstructed line)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 15))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# 4x speed = 24 cells; the 15-cell straight run reaches (0,14), adjacent.
assert hero_state.pos == (0, 14)
assert hero_state.running is True
def test_run_destination_around_a_corner_is_rejected() -> None:
"""Given: the wall column, hero (0,0)
When: hero runs to an explicit destination only reachable by turning the
corner (2,8)
Then: the straight line cannot honour it, so the destination is rejected
(unreachable_destination) rather than the run bending around the wall."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 8)),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
def test_running_combatant_is_denied_dex_to_ac() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
+64
View File
@@ -78,6 +78,29 @@ def test_aoo_suppressed_vs_total_concealment() -> None:
)
def test_aoo_suppressed_when_attacker_is_blinded() -> None:
"""Given: the guard is blinded and the runner has no concealment
When: the guard resolves the AoO
Then: it is suppressed — a blinded character's opponents all have total
concealment (CRB Conditions — Blinded) — no die, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(guard_s, "blinded")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
assert any(
"suppressed (total concealment)" in line for line in engine._transcript
)
def test_aoo_still_strikes_in_the_open() -> None:
"""Given: no cover and no concealment between guard and runner
When: the guard resolves the AoO
@@ -112,3 +135,44 @@ def test_aoo_not_suppressed_by_partial_concealment() -> None:
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 1
assert not any("suppressed" in line for line in engine._transcript)
def test_nauseated_guard_makes_no_aoo() -> None:
"""Given: the guard is nauseated (SRD: "unable to attack")
When: the runner provokes an AoO
Then: no opportunity is taken — no die rolled, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(guard_s, "nauseated")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
def test_confused_guard_makes_no_aoo() -> None:
"""Given: the guard is confused (SRD: "will not make attacks of
opportunity against anything that it is not already devoted to
attacking" — simplified to no AoOs at all, the engine has no
"already devoted" concept)
When: the runner provokes an AoO
Then: no opportunity is taken — no die rolled, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(guard_s, "confused")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
+287
View File
@@ -0,0 +1,287 @@
"""Cone and line spell areas (Package S1a).
PF1e (CRB, Magic - Aiming a Spell): a cone "shoots away from you in a
quarter-circle in the direction you designate"; a line "shoots away from you
in a line in the direction you designate" and "affects all creatures in
squares through which the line passes". Both originate at the CASTER and are
aimed via ``cast_spell``'s existing ``target_id`` / ``target_pos``.
Factories are reused from ``tests/test_observation_enriched.py``; every roll
is scripted. The cone spell is save-less (save="none", save_for_half=False)
so each affected creature consumes exactly its damage dice — the scripted
queues also pin the EXACT set of creatures caught in each template.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
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.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
DamageEffect,
SpellSpec,
load_spell,
)
from tests.test_observation_enriched import make_combatant, make_state
if TYPE_CHECKING:
from pathlib import Path
_CONE_SPARKS = SpellSpec(
name="Cone Sparks",
level=1,
school="evocation",
range="close",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="cone",
radius_ft=15,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_LINE_SPARKS = SpellSpec(
name="Line Sparks",
level=2,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="line",
radius_ft=120,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),
),
save_for_half=False,
),
),
)
_BURST_SPARKS = SpellSpec(
name="Burst Sparks",
level=3,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_REGISTRY = {
"Cone Sparks": _CONE_SPARKS,
"Line Sparks": _LINE_SPARKS,
"Burst Sparks": _BURST_SPARKS,
}
def _open_grid(side: int = 12) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="open", terrain=tuple(["." * side] * side), legend=legend)
return Grid.from_spec(spec)
def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine:
return CombatEngine(
ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY
)
def _caster(hp: int = 100) -> CombatantState:
return make_state(
make_combatant(
"caster",
hp=hp,
spells=["Cone Sparks", "Line Sparks", "Burst Sparks"],
spell_slots={1: 9, 2: 9, 3: 9},
),
"players",
(6, 2),
)
# ---------------------------------------------------------------------------
# cone: 15 ft in front of the caster, ±45° wide, origin cell excluded
# ---------------------------------------------------------------------------
def test_cone_hits_cells_in_front_and_not_behind() -> None:
"""Given: caster (6,2); foe_front (6,4) east, foe_behind (6,0) west,
foe_side (2,2) north, foe_far (6,7) east beyond the 15-ft reach
When: cast a 15-ft cone aimed at foe_front (target_id)
Then: only foe_front (in front, within 3 cells) is hit; the behind/side
foes fall outside the ±45° quarter circle and foe_far exceeds the length.
The caster's own cell (the origin) is excluded by the cone template."""
caster = _caster()
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 0))
foe_side = make_state(make_combatant("foe_side", hp=100), "monsters", (2, 2))
foe_far = make_state(make_combatant("foe_far", hp=100), "monsters", (6, 7))
eng = _engine([caster, foe_front, foe_behind, foe_side, foe_far], [4])
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Cone Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
assert foe_side.hp == 100
assert foe_far.hp == 100
assert caster.hp == 100 # origin cell excluded — the caster is never caught
def test_cone_covers_the_full_90_degree_arc() -> None:
"""Given: caster (6,2) aiming east; foes on the straight axis (6,4), the
45° edge (4,4) and just off the edge (3,4)
When: the 15-ft cone resolves
Then: the axis and the 45° edge cell are inside the quarter circle; the
off-edge cell (beyond ±45°) is untouched."""
caster = _caster()
foe_axis = make_state(make_combatant("foe_axis", hp=100), "monsters", (6, 4))
foe_edge = make_state(make_combatant("foe_edge", hp=100), "monsters", (4, 4))
foe_outside = make_state(make_combatant("foe_outside", hp=100), "monsters", (3, 4))
eng = _engine([caster, foe_axis, foe_edge, foe_outside], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_axis", spell_name="Cone Sparks")
)
assert foe_axis.hp == 100 - 4
assert foe_edge.hp == 100 - 4
assert foe_outside.hp == 100
def test_cone_directed_by_target_pos_point_cast() -> None:
"""Given: the same scene, no target_id — the cone is aimed at an explicit
cell (6,5) via target_pos (U7b point cast)
When: the point cast resolves
Then: the direction is caster -> point; the foe at (6,4) on that bearing
is caught, the north foe is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (6, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 2))
eng = _engine([caster, foe_in, foe_out], [4])
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Cone Sparks", target_pos=(6, 5))
)
assert result.ok
assert foe_in.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# line: 120 ft straight ray, 5 ft wide — only the cells it passes through
# ---------------------------------------------------------------------------
def test_line_hits_the_straight_ray_only() -> None:
"""Given: caster (6,2) aiming east at foe_ray (6,5); foe_off (5,5) sits a
row away, foe_west (6,0) is behind the caster
When: cast the 120-ft line aimed at foe_ray
Then: every creature ON the ray beyond the caster's own square (foe_ray)
takes damage; the caster's own cell is NOT part of the line (CRB: it
"starts from any corner of your square"), and the off-row and the
behind-caster foes are outside the 5-ft-wide template."""
caster = _caster()
foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (5, 5))
foe_west = make_state(make_combatant("foe_west", hp=100), "monsters", (6, 0))
eng = _engine([caster, foe_ray, foe_off, foe_west], [4, 4])
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_ray", spell_name="Line Sparks")
)
assert result.ok
assert foe_ray.hp == 100 - 4
assert caster.hp == 100 # the caster's own square is outside the line
assert foe_off.hp == 100
assert foe_west.hp == 100
def test_line_follows_a_diagonal_bresenham_ray() -> None:
"""Given: caster (6,2) aiming diagonally at foe_diag (2,6)
When: the line resolves
Then: the diagonal ray cells are affected (foe_diag is hit) while a cell
beside the ray (foe_off at (2,5)) is not."""
caster = _caster()
foe_diag = make_state(make_combatant("foe_diag", hp=100), "monsters", (2, 6))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (2, 5))
eng = _engine([caster, foe_diag, foe_off], [4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_diag", spell_name="Line Sparks")
)
assert foe_diag.hp == 100 - 4
assert foe_off.hp == 100
# ---------------------------------------------------------------------------
# burst: unchanged circle around the resolved center
# ---------------------------------------------------------------------------
def test_burst_still_centers_on_the_target_and_ignores_the_caster_side() -> None:
"""Given: caster (6,2); foe_in (4,4) and foe_out (2,0) around a burst
centered on target_id=foe_in (radius 20 ft = 4 cells)
When: the burst cast resolves
Then: the circle around the TARGET cell decides — foe_in (distance 0)
and the caster (distance 3) are inside, foe_out (distance 5) is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (4, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 0))
eng = _engine([caster, foe_in, foe_out], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_in", spell_name="Burst Sparks")
)
assert foe_in.hp == 100 - 4
assert caster.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# loader: shape parsing
# ---------------------------------------------------------------------------
def test_loader_parses_cone_and_line_shapes(tmp_path: Path) -> None:
"""Given: area effects declaring "shape": "cone" / "line" / (absent)
When: load_spell parses them
Then: the shapes are carried on the AreaEffect; an absent shape defaults
to "burst"; an unknown shape is a data error."""
spell_file = tmp_path / "shapes.json"
spell_file.write_text(
'{"name": "Shapes", "level": 1, "school": "evocation",'
' "range": "close", "save": "ref", "effects": ['
' {"type": "area", "shape": "cone", "radius_ft": 15, "effects": []},'
' {"type": "area", "shape": "line", "radius_ft": 120, "effects": []},'
' {"type": "area", "radius_ft": 20, "effects": []}]}'
)
spell = load_spell(spell_file)
areas = [e for e in spell.effects if isinstance(e, AreaEffect)]
assert [a.shape for a in areas] == ["cone", "line", "burst"]
bad_file = tmp_path / "bad_shape.json"
bad_file.write_text(
'{"name": "Bad", "level": 1, "school": "evocation",'
' "range": "close", "save": "none", "effects": ['
' {"type": "area", "shape": "spiral", "radius_ft": 10, "effects": []}]}'
)
with pytest.raises(ValueError, match="unknown area shape"):
load_spell(bad_file)
+3 -2
View File
@@ -102,7 +102,8 @@ def test_heal_spell_clears_bleed() -> None:
_add_condition(a, "bleeding")
a.conditions.append(BLEEDING)
result = eng._apply_heal_effect(
HealEffect(formula=parse_dice("1d8")), a, empower=False, maximize=False
HealEffect(formula=parse_dice("1d8")), a, caster_level=1,
empower=False, maximize=False, saved=False,
)
assert result == "+5 HP"
assert a.hp == 55
@@ -319,4 +320,4 @@ def test_snapshot_exposes_bleed_damage() -> None:
defs = dict(snap["condition_definitions"])
assert len(defs) == len(CONDITIONS_BY_NAME)
assert defs["bleeding"]["flags"] == []
assert defs["confused"]["flags"] == []
assert defs["confused"]["flags"] == ["cant_aoo"]
+75 -44
View File
@@ -1,19 +1,24 @@
"""Tests for the cackle hex: bound the extension of hex conditions (Task 9).
"""Tests for the cackle hex (APG): free action, no resource cost, extends
every evil-eye hex on all creatures within 30 ft by exactly 1 round.
Cackle must extend exactly one +1 round per pre-existing hex condition, in a
single pass, without re-extending the replacements it appends. The pre-fix code
mutated ``target.active_conditions`` (remove/append) while iterating it, which
grew the list unboundedly and looped forever.
The pre-fix code spent a hex use, acted on a single target, and extended
every ``hex:``-sourced condition (including slumber, which RAW cackle does
not touch). Cackle now rebuilds each in-range creature's buff list in a
single pass, so an extended hex is never re-extended and the call always
terminates (the pre-Task-9 code mutated the list while iterating it).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
_default_cast_time,
_effective_slot,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.effects import ActiveBuff, StatModifier
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
@@ -26,11 +31,9 @@ from pf1e_simulator.models import (
)
from pf1e_simulator.rng import ScriptedRng
if TYPE_CHECKING:
from collections.abc import Collection
_HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2")
_ROUNDS_BEFORE = (2, 1)
_HEX_SOURCE = "hex: evil_eye by caster"
_OTHER_SOURCE = "hex: evil_eye by caster-2"
_UNRELATED_SOURCE = "spell: bull's strength"
def _make_witch(cid: str = "caster") -> Combatant:
@@ -90,7 +93,7 @@ def _make_target(cid: str = "target") -> Combatant:
)
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
c = CombatantState(
combatant=_make_witch(), side="players", pos=(0, 0), hp=20,
resources_left={"hexes": 3},
@@ -98,42 +101,70 @@ def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
t = CombatantState(
combatant=_make_target(), side="monsters", pos=(3, 0), hp=30,
)
far = CombatantState(
combatant=_make_target("far"), side="monsters", pos=(10, 0), hp=30,
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t])
return engine, c, t
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t, far])
return engine, c, t, far
def _seed_hex_conditions(target: CombatantState) -> dict[str, int]:
target.active_conditions = [
ActiveCondition(
condition=CONDITIONS_BY_NAME["shaken"],
rounds_left=rounds,
source=source,
)
for source, rounds in zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True)
]
return dict(zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True))
def _evil_eye(rounds_left: int, source: str = _HEX_SOURCE) -> ActiveBuff:
return ActiveBuff(
modifiers=(StatModifier(target="attack", value=-2),),
rounds_left=rounds_left,
source=source,
)
def _rounds_by_source(conditions: Collection[ActiveCondition]) -> dict[str, int]:
return {ac.source: ac.rounds_left for ac in conditions}
def _rounds_by_source(buffs: list[ActiveBuff]) -> dict[str, int]:
return {b.source: b.rounds_left for b in buffs}
class TestCackleExtendsHexOnce:
"""Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes,
rounds_left 2 and 1)
class TestCackleExtendsEvilEyeOnce:
"""Given: a target carrying 2 pre-existing evil-eye buffs (2 Evil Eyes,
rounds_left 2 and 1) and an unrelated buff, plus a creature beyond 30 ft
carrying its own evil eye
When: the witch cackles
Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left
rises by 1, the total hex-condition count stays 2 (no growth), and the
call terminates (the pre-fix loop never did)."""
Then: EXACTLY 2 extensions of +1 round happen in range — each evil eye's
rounds_left rises by 1, the unrelated buff is untouched, the out-of-range
creature is not extended, the buff count stays stable (no growth), and
the call terminates."""
def test_exactly_two_hex_extensions_of_one_round(self) -> None:
engine, c, t = _make_engine()
expected = _seed_hex_conditions(t)
def test_exactly_two_evil_eye_extensions_of_one_round(self) -> None:
engine, c, t, far = _make_engine()
t.active_buffs = [_evil_eye(2), _evil_eye(1, _OTHER_SOURCE),
ActiveBuff(modifiers=(), rounds_left=5, source=_UNRELATED_SOURCE)]
far.active_buffs = [_evil_eye(3, "hex: evil_eye by other-witch")]
engine._hex_cackle(c, t)
engine._hex_cackle(c)
after = _rounds_by_source(t.active_conditions)
assert len(after) == 2
assert after == {source: rounds + 1 for source, rounds in expected.items()}
after = _rounds_by_source(t.active_buffs)
assert len(after) == 3
assert after[_HEX_SOURCE] == 3
assert after[_OTHER_SOURCE] == 2
assert after[_UNRELATED_SOURCE] == 5
assert _rounds_by_source(far.active_buffs)["hex: evil_eye by other-witch"] == 3
class TestCackleIsFreeAndCostless:
"""Given: the cackle hex action
When: budgeted and executed
Then: it occupies the FREE slot (not the standard one), does not spend a
hex use, and works without a resolved target."""
def test_cackle_slot_is_free_but_evil_eye_stays_standard(self) -> None:
cackle = Action(kind="hex", hex_name="cackle")
evil_eye = Action(kind="hex", target_id="target", hex_name="evil_eye")
assert _effective_slot(cackle, _default_cast_time) == "free"
assert _effective_slot(evil_eye, _default_cast_time) == "standard"
def test_cackle_spends_no_hex_resource(self) -> None:
engine, c, t, _far = _make_engine()
t.active_buffs = [_evil_eye(1)]
engine._execute(c, Action(kind="hex", hex_name="cackle"))
assert c.resources_left["hexes"] == 3
assert _rounds_by_source(t.active_buffs)[_HEX_SOURCE] == 2
+25
View File
@@ -205,6 +205,31 @@ def test_charge_result_no_charge_path_too_far() -> None:
assert result.distance_ft == 0
def test_restricted_charge_halves_the_budget() -> None:
"""SRD "Charge": a creature able to take only a standard action (here a
disabled one at 0 hp) can still charge, but only up to its speed (1x,
6 cells at 30 ft) instead of double speed — a target 6 cells away stays
chargeable, one 10 cells away is not."""
a = make_state(make_combatant("a"), "players", (0, 0))
near = make_state(make_combatant("near"), "monsters", (0, 6))
engine = make_engine([a, near], make_grid(12))
a.hp = 0 # disabled -> staggered -> restricted charge
result = engine.can_charge(a, near)
assert isinstance(result, ChargeResult)
assert result.weapon is not None # end cell 5 steps away <= 1x budget
a2 = make_state(make_combatant("a2"), "players", (0, 0))
far = make_state(make_combatant("far"), "monsters", (0, 10))
engine2 = make_engine([a2, far], make_grid(12))
a2.hp = 0
res2 = engine2.can_charge(a2, far)
assert isinstance(res2, ChargeResult)
assert res2.weapon is None # 9 steps to the closest attack cell needs 2x
a2.hp = 20 # standing again: full 2x charge is legal
res3 = engine2.can_charge(a2, far)
assert isinstance(res3, ChargeResult)
assert res3.weapon is not None
# --- Test 5: no melee weapon -------------------------------------------------
+135 -1
View File
@@ -31,6 +31,7 @@ def _combatant(
*,
resources: dict[str, int] | None = None,
spells: list[str] | None = None,
spell_slots: dict[str, int] | None = None,
hp_max: int = 20,
creature_type: Literal["living", "undead", "construct"] = "living",
) -> Combatant:
@@ -59,6 +60,7 @@ def _combatant(
attacks=[attack],
resources=resources or {},
spells=spells or [],
spell_slots=spell_slots or {},
creature_type=creature_type,
)
@@ -253,6 +255,83 @@ def test_nairda_hex_spends_resource() -> None:
assert me.resources_left["hexes"] == 4
def test_evil_eye_failed_save_deals_minus_two_attack_three_rounds() -> None:
"""APG Evil Eye: -2 on one chosen option, 3 rounds; Will does not negate.
Given: a target that fails the Will save (d20 2 +4 < DC 14)
When: the witch hexes it
Then: a -2 attack modifier is applied for 3 rounds (no shaken condition)."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
)
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
assert len(hex_buffs) == 1
assert hex_buffs[0].rounds_left == 3
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
assert not any(ac.condition.name == "shaken" for ac in enemy[0].active_conditions)
def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None:
"""APG Evil Eye: "A Will save reduces this to just 1 round" — the penalty
still lands. Given: a target that saves (natural 20)
When: the witch hexes it
Then: the -2 attack modifier is applied for exactly 1 round."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
)
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
assert len(hex_buffs) == 1
assert hex_buffs[0].rounds_left == 1
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
def test_slumber_failed_save_puts_target_asleep_for_witch_level() -> None:
"""APG Slumber: the target "remains asleep for a number of rounds equal to
the witch's level". Given: a level-5 witch and a target failing the Will
save (d20 2 +4 < DC 14)
When: the hex is cast
Then: the asleep condition carries rounds_left = 5."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
)
asleep = [
ac for ac in enemy[0].active_conditions if ac.condition.name == "asleep"
]
assert len(asleep) == 1
assert asleep[0].rounds_left == 5
def test_slumber_saved_leaves_target_awake() -> None:
"""APG Slumber: Will negates. Given: a target saving (natural 20)
When: the hex is cast
Then: no asleep condition is applied."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
)
assert not any(
ac.condition.name == "asleep" for ac in enemy[0].active_conditions
)
# --- tammara: deeds ---
@@ -312,8 +391,10 @@ def test_tammara_deed_out_of_range_spends_no_grit() -> None:
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
# Shocking Grasp (not Burning Hands): the offensive-spell scan reads
# top-level damage effects, and Burning Hands is a cone area spell now.
misty = _combatant(
"misty", resources={"bardic_rounds": 12}, spells=["Burning Hands"]
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"]
)
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
@@ -378,6 +459,59 @@ def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
)
def test_spell_combat_penalty_applies_to_spell_touch_attack() -> None:
"""CRB Magus Spell Combat: "any attack roll made as part of this spell
also takes this penalty" — Shocking Grasp's melee touch attack is at -2
(bab 3 + str 2 - 2 = +3): d20 8 -> 11 misses touch AC 12, so the spell
is wasted. Without the penalty the same roll (8+5=13) would have hit."""
misty = _combatant(
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
spell_slots={1: 1},
)
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
# Queue: goblin AoO on the cast (nat 1 miss), touch d20=8, swing d20=10.
engine, me, enemy = _engine(
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 10]
)
engine._execute(
me,
Action(
kind="spell_combat", target_id=enemy[0].combatant.id,
spell_name="Shocking Grasp",
),
)
assert any(
"touch attack vs goblin d20=8+3=11 AC 12 -> MISS" in line
for line in engine._transcript
)
assert enemy[0].hp == 6 # the missed touch delivers no electricity
def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
"""Control: the same Shocking Grasp cast through cast_spell keeps +5
(bab 3 + str 2): d20 8 -> 13 hits touch AC 12 and deals damage."""
misty = _combatant(
"misty", spells=["Shocking Grasp"], spell_slots={1: 1}
)
goblin = _combatant("goblin", hp_max=60)
reg = load_spell_registry(SPELLS_DIR)
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each
# (SRD spells/shocking-grasp.md: 1d6 per caster level, max 5d6; lvl 5).
engine, me, enemy = _engine(
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4, 4, 4, 4, 4]
)
engine._execute(
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
spell_name="Shocking Grasp")
)
assert any(
"touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
for line in engine._transcript
)
assert enemy[0].hp == 60 - 20
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
"""Misty + one ally in 30ft, inspire courage executed on the ally."""
misty = _combatant("misty", resources={"bardic_rounds": 12})
+39 -10
View File
@@ -200,15 +200,38 @@ def test_dr_reduces_damage() -> None:
assert b.hp == 4
def test_dr_floors_damage_at_zero() -> None:
def test_dr_floors_hit_at_one_minimum_damage() -> None:
# PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage
# result to less than 1, a hit still deals 1 point of nonlethal damage"
# (no nonlethal track: floored to 1 point of normal damage).
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12, 3], [a, b])
result = engine.resolve_attack(a, b, a_spec.attacks[0])
assert result.damage == 0
assert b.hp == 6
assert result.hit is True
assert result.damage == 1
assert b.hp == 5
def test_energy_resistance_floors_hit_at_one() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}})
fire = AttackSpec(
id="a-fire",
name="flame blade",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["fire"])],
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12, 5], [a, b])
result = engine.resolve_attack(a, b, fire)
assert result.hit is True
assert result.damage == 1
assert b.hp == 5
def test_dr_bypass_ignores_reduction() -> None:
@@ -558,19 +581,22 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 4, 12, 2, 8, 2, 14, 1])
rng = ScriptedRng([10, 9, 5, 13, 6, 15, 4])
engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run()
assert result.winner == "players"
# SRD Measuring Distance corner rule: the (0,1)->(1,2) diagonal is flank-
# blocked by the wall at (0,2), so the auto step goes to (1,1) instead —
# and the wall keeps granting cover (+4 AC) from that square.
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9",
"round 1 archer: 5ft step (0,1)->(1,2)",
"round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
"round 1 archer: 5ft step (0,1)->(1,1)",
"round 1 archer: short bow vs target d20=5+4=9 AC 17 -> MISS",
"round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 archer: short bow vs target d20=13+4=17 AC 17 -> HIT 6 damage (6->0)",
"round 2 target: wait",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 4 damage (3->-1)",
"round 3 archer: short bow vs target d20=15+4=19 AC 17 -> HIT 4 damage (0->-4)",
"round 3 archer: target down",
"battle over: players win in 3 rounds",
)
@@ -813,7 +839,7 @@ def test_ranged_attack_ignores_flanking() -> None:
"initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8",
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
"round 1 arc: short bow vs tgt d20=12+2-2(melee)=12 AC 15 -> MISS",
"round 1 arc: short bow vs tgt d20=12+2-4(melee)=10 AC 15 -> MISS",
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds",
@@ -1068,7 +1094,10 @@ def test_aoo_limit_one_per_round() -> None:
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)",
# 5-10-5 parity: the second diagonal (1,2)->(2,3) now costs 2, so the
# greedy walk prefers the orthogonal (1,2)->(1,3) detour (both paths
# total 5 squares; the new one avoids the expensive second diagonal).
"round 1 runner: move (0,0)->(0,1)->(1,2)->(1,3)->(2,3)->(3,3)",
"round 1 guard: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
+44
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import ENTANGLED
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
@@ -175,3 +176,46 @@ class TestCombatCastingPlacement:
grappler = _combatant("grappler", cmb=3)
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
assert engine._check_concentration(c, 1, damage_taken=0) is True
class TestEntangledCasting:
"""SRD Table 9-1: "Entangled while casting" => DC 15 + spell level. The
casting gate must force the check and lose the spell on a failure."""
def _entangled_engine(
self, roll: int, *, combat_casting: bool = False
) -> tuple[CombatEngine, CombatantState]:
caster = _combatant("caster", level=5, combat_casting=combat_casting)
foe = _combatant("foe")
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(3, 3), hp=foe.hp_max)
c.conditions.append(ENTANGLED)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 4] * 4), legend=legend)
engine = CombatEngine(ScriptedRng([roll]), Grid.from_spec(spec), [c, f])
return engine, c
def test_entangled_concentration_dc_is_15_plus_spell_level(self) -> None:
caster = _combatant("caster", level=5)
engine = _make_engine(caster, _combatant("grappler"), [])[0]
assert engine._entangled_concentration_dc(2) == 15 + 2
def test_failed_check_loses_the_spell(self) -> None:
# DC 16 (15 + level 1). d20 10 + level 5 + wis 0 = 15 < 16 -> spell lost.
engine, c = self._entangled_engine(10)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is True
def test_successful_check_keeps_the_spell(self) -> None:
# d20 11 + level 5 + wis 0 = 16 >= 16 -> pass (foe at (3,3) does not
# threaten, so no AoO gate either).
engine, c = self._entangled_engine(11)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
def test_combat_casting_helps_while_entangled(self) -> None:
# d20 7 + level 5 + wis 0 + 4 = 16 >= 16 -> PASS with the feat.
engine, c = self._entangled_engine(7, combat_casting=True)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
def test_entangled_carries_minus4_dex_skills(self) -> None:
mods = {m.target: m.value for m in ENTANGLED.modifiers}
assert mods["dex_skills"] == -4
+20
View File
@@ -7,6 +7,7 @@ from pf1e_simulator.conditions import (
BLINDED,
DAZZLED,
ENTANGLED,
EXHAUSTED,
FATIGUED,
FLAT_FOOTED,
FRIGHTENED,
@@ -123,9 +124,28 @@ class TestConditionData:
assert ENTANGLED.cant_charge is True
assert ENTANGLED.speed_mult == 0.5
def test_exhausted_minus6_dex_gives_minus3_ac_and_ref(self) -> None:
# SRD: exhausted takes -6 to Dexterity => -3 AC and -3 Reflex
# (each -2 Dex = -1 AC/Ref, same modelling as entangled's -4 Dex).
mods = {m.target: m.value for m in EXHAUSTED.modifiers}
assert mods["attack"] == -3
assert mods["damage"] == -3
assert mods["ac"] == -3
assert mods["ref"] == -3
assert "fort" not in mods
assert "will" not in mods
def test_fatigued_flags(self) -> None:
assert FATIGUED.cant_charge is True
def test_fatigued_minus2_dex_gives_minus1_ac_and_ref(self) -> None:
# SRD: fatigued takes -2 to Dexterity => -1 AC and -1 Reflex.
mods = {m.target: m.value for m in FATIGUED.modifiers}
assert mods["attack"] == -1
assert mods["damage"] == -1
assert mods["ac"] == -1
assert mods["ref"] == -1
def test_flat_footed_flags(self) -> None:
assert FLAT_FOOTED.denies_dex_to_ac is True
assert FLAT_FOOTED.cant_aoo is True
@@ -81,6 +81,50 @@ def test_blinded_attacker_d100_rolls_once_with_target_concealment() -> None:
assert any("50%" in line for line in eng._transcript)
def test_blinded_attacker_cannot_sneak_attack() -> None:
# PF1e (CRB Conditions — Blinded): "All opponents are considered to have
# total concealment (50% miss chance) against the blinded character" — and
# total concealment denies sneak attack (CRB rogue "Sneak Attack").
eng = _engine(
[
_combatant("atk", features=("Sneak Attack",)),
_combatant("tgt"),
],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 51, 4],
)
a, t = eng._states
_add_condition(a, "blinded")
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# d20 15 + 5 = 20 hits; blinded d100 51 > 50 lands; base 1d6 = 4 only —
# no sneak d6 is rolled despite the target being denied its Dex bonus.
assert result.hit is True
assert result.damage == 4
assert not any("sneak" in line for line in eng._transcript)
def test_sighted_sneak_attack_control_when_not_blinded() -> None:
eng = _engine(
[
_combatant("atk", features=("Sneak Attack",)),
_combatant("tgt"),
],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 3, 4],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Same swing without blinded: sneak applies (d20 15 hits, sneak d6 3,
# base d6 4 -> 7 total).
assert result.hit is True
assert result.damage == 7
assert any("sneak attack" in line for line in eng._transcript)
# ── Rule B: invisible attacker (+2 to hit, target loses Dex to AC) ───────────

Some files were not shown because too many files have changed in this diff Show More