Commit Graph

379 Commits

Author SHA1 Message Date
ctan 9cfcd67f70 feat(rules): SplashSpec data model - data-driven splash marker on thrown weapons, Alchemist's Fire splash 1 fire on oni/tammara (SRD Throw Splash Weapon, Alchemist's fire) 2026-09-20 19:11:39 +02:00
ctan 83c6e541ac feat(rules): nonlethal sources - unarmed strikes deal nonlethal by default (SRD Unarmed Attacks), Minimum Damage floor lands 1 nonlethal (CRB Damage), transcript + AttackResult expose the track 2026-09-20 18:54:22 +02:00
ctan cba7bbf3c9 feat(rules): nonlethal damage track - separate total, derived staggered/unconscious, max-hp overflow, undead/construct immunity, healing removal (SRD Nonlethal Damage) 2026-09-20 18:41:53 +02:00
ctan 3d0fd0c101 docs(rules): conformance journal round 14 - transcript/format audit (14 bugs fixed)
A read-only audit of the transcript rendering, map and loader formats found
14 confirmed bugs (4 Med, 10 Low: duplicate --side, id-disambiguation
collision, range_increment_ft division by zero, silent spell_slots/features
drops, negative round-cap, multi-char legend keys, blank rows, id parsing,
duplicate YAML keys, move_cost default) - all fixed. The transcript/format
area is audited; only the structural out-of-scope items remain. Gate 1900.
2026-09-20 18:08:29 +02:00
ctan dd0b42b73b docs(map): make omitted move_cost explicitly mean impassable 2026-09-20 18:03:46 +02:00
ctan ee22f7548a fix(map): reject duplicate YAML keys via a SafeLoader duplicate-check 2026-09-20 18:00:27 +02:00
ctan 53c6dc0b43 fix(models): reject combatant ids that misparse the transcript format 2026-09-20 17:51:40 +02:00
ctan bce0a59e60 fix(map): fail on blank rows inside a layer, keep trailing-space cells verbatim 2026-09-20 17:48:47 +02:00
ctan 205ddece0b fix(map): require single-character legend keys 2026-09-20 17:43:50 +02:00
ctan c46e636d12 fix(cli): require --round-cap >= 1 2026-09-20 17:40:29 +02:00
ctan 10f811e80e fix(models): require a positive range_increment_ft (reject 0 and negatives) 2026-09-20 17:36:45 +02:00
ctan e9c2a63122 fix(runner): disambiguation suffix skips ids already taken by explicit combatants 2026-09-20 17:33:35 +02:00
ctan de9309ca43 fix(cli): reject duplicate --side names with a clear error 2026-09-20 17:29:10 +02:00
ctan 130087a556 fix(map): reject a zones layer without a deployment section
The README documents deployment as required when zones exist, but load_map
only enforced the reverse direction (deployment without zones). A map with
a zones layer and no deployment now fails with MapValidationError instead
of loading with unreachable deployment zones.
2026-09-20 17:15:29 +02:00
ctan a8c7e37f8e fix(loaders): resource pools validated consistently before grant merge
_merge_resource_grants silently dropped non-int pool values when features
were present (a "5" pool vanished, leaving only the feat grant) while
Pydantic coerced the same value when features were absent. _validated_resources
now runs the JSON resources through TypeAdapter(dict[str, int]) -- the same
lax coercion as Combatant.resources -- so both paths coerce-or-fail
identically, and grants are added to the coerced ints.
2026-09-20 17:13:09 +02:00
ctan 9adc87c9e1 fix(loaders): coerce combatant level with Pydantic-consistent rules
Feat resolution read level with an isinstance(int) check and fell back
to 1, while Pydantic accepts numeric strings and lossless floats: a
level-4 sheet authored as "4" got Toughness for level 1. _parse_level
now runs the value through TypeAdapter(int) (same lax coercion as the
model) and raises CombatantLoadError for values the model would reject.
2026-09-20 17:05:36 +02:00
ctan b8e96294a6 fix(loaders): non-list features and blank/non-string entries fail cleanly
features was popped and only re-added when it was a list, so a bare
string (or dict/null) loaded with zero feats - silent data loss. Now a
present non-list features raises CombatantLoadError, and the list
resolution adopts an accept-string-or-error contract: non-string or
blank entries raise instead of being silently dropped.
2026-09-20 17:02:39 +02:00
ctan 9656ae820a fix(loaders): malformed spell_slots key raises CombatantLoadError, not bare ValueError
int(k) on a non-numeric JSON key escaped the loader as an unhandled
ValueError traceback (CLI exit 1) instead of the documented exit code 2.
Route the conversion through _parse_spell_slot_keys which wraps it in a
CombatantLoadError with a clear message. Audited the other loader-side
int()/conversions: foundry.py guards every int() with isinstance/isdigit
checks and combatant.py has no other unguarded conversion.
2026-09-20 16:58:57 +02:00
ctan 5911602736 docs(rules): conformance journal round 13 - objects/inventory (out of scope -> modeled)
Sunder now uses object hardness/HP with the SRD broken condition (usable at
-2), steal transfers real carried items and unarmed disarm auto-picks up the
dropped weapon. Objects/inventory leave the out-of-scope list; skills,
nonlethal, splash, 3D flight and the transcript remain. Gate 1861.
2026-09-20 16:10:00 +02:00
ctan d0f7f3a107 feat(rules): carried items + real steal transfers, unarmed-disarm auto-pickup
- Combatant.carried_items (APG Steal: non-wielded, non-closely-worn gear),
  loaded from JSON, seeded into CombatantState, exposed in the snapshot.
- _maneuver_steal: success transfers the first carried item victim -> thief
  (logged); empty list keeps the documented nothing-stealable behaviour.
- _maneuver_disarm: unarmed attacker's success auto-picks the dropped weapon
  into its carried_items (SRD Disarm); armed disarms unchanged. Dropped-weapon
  recovery via retrieve_weapon (move action) verified + pinned by tests.
- data/pcs: gear transcribed from Foundry sheet inventories (esha 13,
  jeanne 3, misty 9, nairda 7, oni 6, tammara 20); harvie/ierlieth/
  esha_mount and all monsters empty (no stealable inventory), noted in README.
2026-09-20 16:05:07 +02:00
ctan 74f5d64c43 feat(rules): sunder deals real weapon damage vs object hardness/hp; broken weapons usable at -2 with restricted crits
SRD Sunder + CRB 'Smashing an Object' / 'Conditions - Broken': weapons
carry Table 7-12 object stats (AttackSpec.hardness/hit_points/enhancement,
defaults by weapon class); a successful sunder deals the attacker's actual
physical weapon damage minus the target weapon's hardness, subtracted from
its hp. At <= half hp the weapon gains the broken condition - still usable
at -2 attack/damage, crits only on a natural 20 at x2. At <= 0 it is
destroyed (weapon_destroyed, unusable). The old flat str_mod+2 / durability
10 / synthetic 'disarmed' condition is gone. Firearm misfire carve-out kept
(broken firearms stay unusable until Quick Clear - misfire escalation not
modeled, documented).
2026-09-20 15:43:45 +02:00
ctan 544d870688 docs(rules): conformance journal round 12 - spell components and arcane spell failure
107/108 spells carry their SRD components; arcane_caster and
armor_spell_failure are set from the sheets; the deafened 20% verbal
failure and the arcane spell failure roll are modeled. Components/ASF
leave the out-of-scope list. Gate 1850.
2026-09-20 14:38:42 +02:00
ctan 9f72948d23 docs(rules): README - component/ASF data now encoded, dedup snapshot bullet
The components/ASF bullet no longer defers the data encoding (done in
the two preceding commits): 107/108 registry spells carry their KB
components (Create Alcool is a homebrew cantrip with no KB entry) and
all 15 combatants carry arcane_caster plus an effective
armor_spell_failure - zero everywhere because the shipped arcane casters
are unarmored or wear light armor whose ASF their magus/bard feature
annuls for their own-list spells. Also collapses an accidental
duplicated armor_spell_failure/arcane_caster description in the
observation snapshot bullet.
2026-09-20 14:34:33 +02:00
ctan f6292f8bdc feat(rules): encode arcane_caster and armor_spell_failure from the sheets
- arcane_caster true: misty (Magus/Bard sheet class), nairda (Witch/
  Sorcerer), thrune_agent (Human magus 8, KB bestiary/thrune-agent.md);
  lich already true. False: oni (Cleric), tammara (Gunslinger/
  Warpriest - divine), esha (Cavalier/Rogue, spell-like only), the
  companions harvie/ierlieth/jeanne/esha_mount and the remaining
  monsters.
- armor_spell_failure stays 0 everywhere, as the effective PF1e value:
  nairda and the lich wear no armor (sheet note "Pas d'armure", KB lich
  block); misty's mithral shirt (RAW ASF 10%, magic-items/mithral-shirt
  .md) and the thrune agent's +1 studded leather (15%) are light armor
  and every spell they cast is on their own class list - magus and bard
  cast their own spells in light armor with no arcane spell failure
  (classes/magus.md, classes/bard.md).
- tests/test_spell_failure.py: the lich/oni smoke test becomes a
  full-data table pinning all 15 combatants' flags with per-entry
  sources. No shipped combatant incurs a non-zero ASF; the failure roll
  itself stays covered by the synthetic armored-caster tests.
2026-09-20 14:30:57 +02:00
ctan 91666d84b9 feat(rules): encode SRD spell components for all 108 registry spells
- Every data/spells/*.json now carries a "components" field with the
  V/S/M/F/DF tokens from its KB Components line (material/focus
  descriptions dropped; M/DF and F/DF alternatives kept verbatim).
- 107/108 encoded. Create Alcool (data/spells/create_alcool.json) is
  left without the field: it is a homebrew cantrip (level 0) with no KB
  entry - the closest real spell, Enhance Water (Inner Sea Gods, V, S),
  is already a separate registry entry.
- summon_monster_i/ii map to the KB's numeric files (summon-monster-1/2);
  hidden_diplomacy maps to its KB name Egorian Diplomacy (same spell,
  Spymaster's Handbook rename): V, S, M.
- tests/test_spell_failure.py: the placeholder asserting the registry was
  not yet encoded is replaced by an encoded-state smoke test (all spells
  but Create Alcool declare components; five spot-checks against the SRD
  stat blocks). Golden transcripts unchanged: the component gates still
  need deafened/ASF conditions no golden combat has.
2026-09-20 14:21:15 +02:00
ctan 1f8f068dd1 feat(rules): spell components + deafened verbal failure and arcane spell failure rolls
- SpellSpec.components (frozenset of V/S/M/F/DF, JSON key accepting the RAW
  string notation "V, S, M/DF" or a token list; slash = alternative; unknown
  token is a data error). Default empty = not encoded yet -> no failure.
- Combatant.armor_spell_failure (0-100 percent, authored on the JSON - the
  model has no equipment) and arcane_caster (divine casters never incur ASF);
  both load directly through load_combatant.
- _cast_spell: after the start-of-casting gates and before delivery, two
  conditional d100 rolls (convention mirrors the concealment roll, <=):
  deafened + V component spoils the spell on <=20 (SRD cast-a-spell.md);
  arcane_caster + S component + armor_spell_failure > 0 fails the spell on
  <= the ASF percent (SRD arcane-spells-and-armor.md). The slot stays spent
  (mirrors the concentration fizzle). No condition -> no roll: the RNG
  stream is bit-identical for every current combat (registry encodes no
  components yet - full encoding is a follow-up task).
- Observation: spell summary gains "components" (sorted tuple), combatant
  snapshot gains "armor_spell_failure"/"arcane_caster"; README documents
  the keys and the two former out-of-scope bullets now describe the modeled
  rule with its data-encoding follow-up.
- Data smoke test: lich arcane_caster true / ASF 0, oni arcane_caster false.
- tests/test_spell_failure.py: 21 tests (parsing, thresholds <=20 / <=ASF,
  divine/no-ASF/no-component never roll, roll order, slot stays spent,
  observation keys). Golden transcripts unchanged.
2026-09-20 14:02:10 +02:00
ctan a483070e13 docs(rules): conformance journal round 11 - alignment subsystem (out of scope -> modeled)
Adds the alignment field + axis flags, the AlignmentGate on spell damage
(Shard of Chaos, Spear of Purity) and the attacker-alignment-conditional
wards (Protection from Evil/Law + communal). Alignment leaves the
out-of-scope list; objects/inventory, skills, nonlethal and ASF are recorded
as the remaining out-of-scope mechanics. Gate 1829.
2026-09-20 13:02:15 +02:00
ctan 3be22b9c55 feat(rules): alignment-gated protection from evil/law wards via attacker context
Add attacker-alignment modifier conditions (attacker_is_evil/lawful/good/
chaotic) evaluated against an optional attacker context threaded through
_stat_modifiers/_modifier_condition_holds, _attack_ac_mods (resolve_attack
AC path), resolve_save and _area_save (spell save paths), plus the fear
aura. Without a source in scope the condition is False, so existing rolls
are untouched (golden transcripts bit-identical).

Give the four protection_from_evil/law(+communal) JSONs their SRD buff:
+2 deflection to AC and +2 resistance on fort/ref/will, gated on the
attacker/caster alignment (SRD: bonuses apply 'against attacks or effects
created by evil creatures'); duration 1 min/level approximated as 10
rounds (engine convention). Descriptions document the unmodeled mental-
control suppression and communal duration-split clauses. Level/range/
save/SR verified against the KB.
2026-09-20 12:57:29 +02:00
ctan 6a18d456f6 feat(rules): alignment-gated spell damage for Shard of Chaos and Spear of Purity
DamageEffect gains an optional AlignmentGate (axis law_chaos/good_evil):
the immune side (chaotic/good) is wholly unaffected (no damage dice rolled),
neutral-on-axis targets take half damage with no condition (gate halving
stacks after the save halving), and the full side (lawful/evil) takes full
damage plus the gate condition on a failed save. Shard of Chaos maps the
SRD slowed to the engine staggered condition (the -1 penalty and half-speed
of slow are not modeled); the outsider-only 1d6/level variants are not
modeled (no outsider subtype). Non-gated damage spells are bit-identical.
Loader validates axis/side pairing with clear ValueErrors.
2026-09-20 12:32:14 +02:00
ctan f9e51e377a feat(rules): add alignment field to Combatant with axis gating flags
Adds the PF1e two-axis alignment code (LG/NG/CG/LN/N/CN/LE/NE/CE,
default N) to the Combatant model with a strict validator, derived
is_lawful/is_chaotic/is_good/is_evil properties, exposure in the
observation snapshot, and alignment values for all monsters and PCs
sourced from the bestiary KB and the Foundry sheets.
2026-09-20 11:49:04 +02:00
ctan 40088e1869 docs(rules): conformance journal round 10 - utility spells (guidance, resistance, virtue, stabilize)
Guidance (new OneShotEffect), resistance (+1 resistance save buff, with the
cantrip-skip policy guard), virtue (temporary HP track) and stabilize
(auto-stabilization) are now modeled; spiritual_weapon and saving_finale
remain deferred with a precise engine reason. Gate 1734.
2026-09-20 10:52:22 +02:00
ctan f7886135bd fix(rules): stabilize is a StabilizeEffect that auto-stabilizes a dying creature 2026-09-20 10:48:08 +02:00
ctan a8a65b18cd fix(rules): virtue is 1 temporary hit point for 1 minute 2026-09-20 10:32:05 +02:00
ctan 1238c40e43 fix(rules): resistance is a +1 resistance bonus on fort/ref/will saves for 1 minute - SRD spells/resistance.md grants the subject a +1 resistance bonus on saving throws for 1 minute (Will negates, harmless); the resistance bonus type already exists in the stacking model (effects.py BonusType), so the buff is expressible as a BuffEffect and was only deferred because _find_buff_spell returns the FIRST BuffEffect in known-spell order and cantrips cost no slots - encoding it would have made default_policy cast Resistance every round (for Tammara it would preempt Divine Favor and never kill anything); resistance.json now carries a BuffEffect (+1 resistance on fort/ref/will, 10 rounds = 1 minute) and _find_buff_spell skips level-0 spells (cantrips have no spell slot in the engine's budget - spell_slots_left has no level-0 key - and the policy deliberately never auto-casts them, a cantrip buff would be cast every round degrading the policy), so Resistance cannot preempt a leveled buff (Divine Favor) in the known-spell order; leveled buffs keep the historical first-match behavior (if the first one's slots are exhausted the buff branch gives up - the golden pins that); the resistance bonus type does not stack with itself (resolve_modifiers keeps the higher value); +9 tests (test_resistance.py: buff shape, cast on a willing ally consumes no d20 and applies without a save roll, the bonus rides save rolls, no self-stacking, expiry at 10 rounds, _find_buff_spell skips the slot-less cantrip / returns None without a leveled buff, _choose_spell_action proposes Divine Favor and never Resistance); golden untouched; 1714 tests green 2026-09-20 10:10:32 +02:00
ctan 08f9af9e3e fix(rules): guidance is a one-shot +1 competence bonus on a single attack roll - SRD spells/guidance.md grants '+1 competence bonus on a SINGLE attack roll, saving throw, or skill check' (duration 1 minute or until discharged); the engine had no spell effect that could write the one-shot attack track (aid_attack_bonus is hardwired to aid-another +2 and expires at the start of the beneficiary's next turn), and a persistent BuffEffect would misrepresent the discharge rule (it would boost every roll for a minute) and would become the first buff spell in the casters' known-spell order, degrading default_policy; a new OneShotEffect type is added (track/value/bonus_type/duration_rounds, parser 'one_shot', SpellEffect union, summary 'one-shot +X type on track'), stored as ActiveOneShot on CombatantState (one_shot_bonuses, decremented with the other duration-tracked effects, exposed in the snapshot), discharged by the FIRST roll of the encoded track via _discharge_one_shot (best value wins, each casting is its own charge) and folded into the resolve_modifiers pool so bonus-type stacking applies; the attack track rides the attack roll only, NOT the crit-confirm roll (the SRD grants it to a SINGLE attack roll, so unlike aid another the confirmation is untouched - _attack_roll_modifiers returns (pool_mod, attack_roll_mod) and resolve_attack uses the latter for the total while the confirm keeps the former); the save and skill tracks are wired in the mechanism (resolve_save folds a save-track shot into save_mods, resolve_skill discharges a skill-track shot into _skill_static_bonus via extra_mods) but guidance.json encodes the attack track only - the SRD 'attack OR save OR skill' choice has no declaration channel in the engine, so the attack-roll variant is encoded (documented); _apply_targeted_spell_effect dispatches OneShotEffect to _apply_one_shot_effect (a successful save negates it, 'one-shot negated'); guidance.json now carries the effect (1 minute = 10 rounds) and default_policy never casts it (cantrips have no spell slot and the one-shot type is not a buff/heal/offensive pick); _spell_effect_summary split into _damage_effect_summary/_non_damage_effect_summary to stay under the PLR0911 return cap; test_spell_registry_batch2._EFFECT_TYPES gains OneShotEffect; +14 tests (test_one_shot_guidance.py: cast-and-discharge on the next attack, not on the crit-confirm roll, save/skill tracks, stacking by bonus type, expiry at 10 rounds, save negation, snapshot exposure, no auto-cast by default_policy); golden untouched; 1705 tests green 2026-09-20 09:53:53 +02:00
ctan 154c3f69a0 docs(rules): conformance journal round 9 - assumed residuals closed (10 fixes, 1 deferred)
Rounds 1-3 closed the assumed residuals: run bounded by CON endurance + Run
feat, harmless spells forgo the save on willing targets, self-centered bursts
for bless/bane, Selective Channeling, the lich stat block aligned to the KB,
summon-stats disambiguation, healing_done exposure, multi-cell difficult-terrain
move pricing, and the grazed-caster-cell cone/line fix. The burst intersection
origin is deferred with a precise rationale. Rewrites the residual table to
drop the now-fixed and stale rows, leaving only the genuinely remaining items.
2026-09-20 00:46:08 +02:00
ctan d5ad3c3aac fix(rules): a grazed caster cell no longer shortens a line spell - SRD Aiming a Spell: a line 'starts from any corner of your square and extends to the limit of its range' (Lightning Bolt: 'The bolt begins at your fingertips'), so the caster's own squares are origin space the line starts PAST and must not consume one of the length_cells squares; line_cells took a fixed ray[1:length_cells+1] window and dropped caster cells inside it, so a 2x2 caster's line came out one square short per grazed footprint cell (the old code documented this as a simplification); the ray is now extended (reach = length_cells + 1 + len(caster_cells)) and caster cells are skipped WITHOUT spending a length slot, so a multi-cell line runs its full length starting just past the footprint; 1x1 casters are bit-identical (the only skipped cell is the origin at ray[0], already outside the counted window); the barrier/LoE stop and out-of-bounds semantics are unchanged (a straight ray never re-enters a convex map); golden untouched (no line spell in it); +3 tests (grazed-cell full-length template, 1x1 bit-identical regression, engine-level reach of a foe the short line missed) 2026-09-20 00:41:02 +02:00
ctan d347dde3fc fix(rules): a multi-cell creature moving under mixed terrain pays the difficult rate for its whole footprint - SRD Movement, Position and Distance: 'If you occupy squares with different kinds of terrain, you can move only as fast as the most difficult terrain you occupy will allow'; the charge/run/5-foot-step already gated on the whole footprint (_footprint_touches_difficult) but the move/withdraw DESCENT priced every step by the anchor cell alone (grid.step_cost), so a 2x2 creature straddling rubble crossed at full speed; the descent now prices a step by the most difficult square under the footprint via _footprint_step_cost -> a new grid.step_cost_with_terrain (the 5-10-5 + elevation formula stays centralized in grid.py, step_cost delegates to it); 1x1 creatures are bit-identical (footprint == anchor cell); the direction Dijkstra field remains anchor-priced (making it creature-aware rewrites grid.reachable wholesale - the documented deferral); golden untouched (open-floor arena, no difficult terrain); +7 tests (trailing-square orthogonal/diagonal cost, 1x1 bit-identical regression, full-lane move budget 3-vs-6 squares, withdraw hampering) 2026-09-20 00:29:36 +02:00
ctan b775d46850 fix(rules): document the burst center-to-center convention as a deferred SRD approximation - the SRD (aiming-a-spell.md) measures a burst from a grid INTERSECTION with the far-edge rule ('count from intersection to intersection'; a square is inside only 'if the far edge of a square is within the spell's area'), but the engine's burst origin is a CELL (target_pos, centered_on_caster, resolved target) counted center-to-center 5-10-5 in _creatures_in_range; matching RAW exactly needs an intersection coordinate added to AreaEffect/Action.target_pos/the observation area snapshot contract plus a per-caster corner choice (the far-edge boundary result depends entirely on which of the cell's four corners is the origin), so it is DEFERRED with a precise explanation in areas.py and a pointer from _creatures_in_range; no behavior change, golden untouched 2026-09-20 00:16:18 +02:00
ctan e6021a6dda fix(rules): expose healing_done in per-combatant stats and the CLI report - the channel/spell healing deliberately removed from damage_dealt (2e2873f) was invisible; CombatantStats/_LiveStats gain a healing_done counter booked with the hp actually restored (capped at the target's hp_max, overheal uncounted) by _channel_heal and by spell HealEffect on living targets, while cure-as-damage on undead stays in damage_dealt and the damage counters keep their exact semantics; the runner aggregates and averages the new field and the French report prints it as 'soins prodigués'; +4 tests (channel heal, cure spell, undead cure guard, run_encounter aggregate) +1 CLI assertion 2026-09-19 23:23:00 +02:00
ctan 5e7436b210 fix(rules): duplicate summons get disambiguated ids - the engine now counts id occurrences (seeded from the deployed roster) and gives each new summon of the same creature a build_states-style suffix (celestial_wolf, celestial_wolf-2, ...), so two summons no longer share one _LiveStats slot (the second summon used to reset the first one's counters and their attrition merged); run_encounter aggregation verified to report each slot separately; runner 'known limitation' comment updated; +4 tests (id split, distinct slots, per-slot attrition, run_encounter aggregate) 2026-09-19 23:13:06 +02:00
ctan 4f8a131ba0 fix(rules): lich stat block aligned to the KB entry - bestiary/lich.md gives hp 111 (11d6+55 plus 15 false life), Str 10, Int 22, Init +2 and 'Aura fear (60-ft. radius, DC 18)'; the JSON carried scenario-tuned 100 hp / Str 12 / Int 20 / Init +6 and the engine's feature-keyed aura constants carried 30 ft / DC 22 (BAB/touch/AC/saves/CON already conformed); the sim's own spell list is kept (the KB's prepared list includes spells outside data/spells/) and the choice is documented in the JSON notes and the README; golden tammara-vs-lich re-pinned for the two SRD-correct line changes (initiative d20=10+2=12 from the KB +2, Magic Missile DC17 = 10+1+6 from Int 22; damage unchanged - the 75 ft deployment distance stays beyond even the widened 60 ft aura, so no save die is consumed); +2 aura range tests (75 ft outside, 60 ft inside) 2026-09-19 23:05:25 +02:00
ctan 5fe2b72339 fix(rules): selective channeling modeled as a policy-chosen exclusion - Action(kind=channel_energy) gains channel_exclude, honored only with the Selective Channeling feat and capped at the Charisma modifier (SRD feats/selective-channeling.md: 'choose a number of targets in the area up to your Charisma modifier... not affected by your channeled energy'); without the feat the list is ignored and logged (SRD Normal: all targets affected); observable in current data because heal mode also heals living ENEMIES in the burst (deny them the heal) and harm mode can spare an undead ally; default_policy never excludes (opt-in, like weapon_id/destination/metamagic); golden transcripts unchanged; +5 tests 2026-09-19 22:49:40 +02:00
ctan 9f0d86273a fix(rules): self-centered bursts for bless and bane - AreaEffect gains a data-driven 'centered_on_caster' flag (bless.json/bane.json set it per SRD bless.md '50-ft. burst, centered on the caster' and bane.md 'centered on you'); the burst origin is the caster's square regardless of the resolved target or a U7b target_pos, and with no chosen origin there is no primary save - every creature in the area rolls its own (mirror of the point-cast path); snapshot area key exposes centered_on_caster; golden transcript unchanged; +6 tests 2026-09-19 22:38:39 +02:00
ctan 4665a1c245 fix(rules): harmless spells on willing targets forgo the save roll - SpellSpec gains a data-driven 'harmless' flag set on the 29 SRD '(harmless)' spells (verified one by one against the KB); a same-side/self target consumes NO save die (SRD saving-throw.md: '(harmless) ... can attempt a saving throw if it desires' + 'Voluntarily Giving up a Saving Throw'), while hostile targets and undead receiving a cure (harmful to them, Will half for undead damage) keep the roll; snapshot spell summaries expose 'harmless'; golden transcript unchanged (no harmless-save cast in it); +8 tests, 3 fixtures re-pinned for the removed die 2026-09-19 22:27:46 +02:00
ctan ea835293a0 fix(rules): run is bounded by Constitution endurance and the Run feat - after a number of consecutive run rounds equal to the CON score each further round requires a Constitution check (DC 10 +1 per prior check, no auto nat-1/20, undead/constructs exempt with no CON score); failure stops the run and forces the SRD 1-minute (10-round) rest; the Run feat grants +4 on the check and keeps the Dex bonus to AC while running (SRD Full-Round Actions - Run); heavy-armor x3 speed clause documented out-of-scope (no armor field); +5 tests 2026-09-19 22:02:36 +02:00
ctan 28bc624d1e docs(rules): conformance journal round 8 - adversarial verification and 8 new fixes
An independent pass confirmed the sampled High/Med fixes are genuinely
implemented, then found previously-undocumented divergences in the default
policy (spell combat without a slot, out-of-range heal wasting the turn,
wasted grit on a melee deed, fixed hex range), dice (wrong exception for
d1), the channel-heal trigger, and the foundry migration tool. All fixed;
corrects the stale 'goblin dies round 4' note (now round 5) and records the
Bless centering residual.
2026-09-19 19:15:27 +02:00
ctan 1b43f4eef1 fix(docs): README states data/pcs/*.json are the canonical PC sheets and foundry.py is a one-shot incomplete migration tool - re-running it and regenerating the JSON would regress the manual/SRD conformance fixes (natural/secondary/firearm flags, Jeanne hooves reach 10, Ierlieth fly 40, Misty bite 1d6, feats beyond its 8-entry map, spell_slots/resources/casting_ability); refresh dev test count 1634 -> 1647 2026-09-19 19:10:38 +02:00
ctan 238e5f201e fix(combat): witch hex trigger uses the SRD close range scaled by caster level (25 ft + 5 ft per 2 levels, extracted as spells.close_range_ft and shared with spell_range_ft) instead of the fixed _HEX_RANGE_FT 25 - a level-5 witch reaches 35 ft; + scaling regression test 2026-09-19 19:07:41 +02:00
ctan 6f01a8ebdd fix(combat): _find_ally_needing_heal counts only living allies (SRD Channel Energy: heal mode heals living creatures, positive energy never heals undead) - an undead ally below half triggered heal mode, spending the channel to heal nobody; + trigger regression test 2026-09-19 19:01:03 +02:00