Representative 7-combatant caster-heavy encounter on sample_arena,
measured through the real engine path (engine._build_observation at each
policy call, delegating to default_policy):
- observation: 64,204 bytes at first call, 67,712 max mid-battle (~16.9k tokens)
- policy calls/battle: 124 (7 combatants, round_cap=20), 12 for a 2v2 duel
- projected calls per sweep cell: 124 / 12,400 / 124,000 at runs 1/100/1000
Loose bounds (obs < 200 KB, calls/battle < 200) + seed-determinism pinned.
- LLMPolicy is now Callable[[Observation], tuple[Action, ...]]: the snapshot
is the policy's sole information source; the live CombatantState is no
longer passed to new-style policies.
- CombatEngine._llm_adapter keeps a compatibility path for pre-D1
two-argument callables: the signature is inspected ONCE at wrap time
(_accepts_state) and legacy policies still receive (observation, state).
- Observation.to_dict() carries schema_version (OBSERVATION_SCHEMA_VERSION=1)
as its first pinned key; bump only on removal/meaning change.
- Pinned keyset tests updated (test_observation, test_observation_enriched);
new tests: observation-only e2e, legacy 2-arg e2e through the adapter,
schema_version pin + JSON-serializability.
- README: documents the new entry signature and the schema_version key.
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.
_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.
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.
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.
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.
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).
- 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.
- 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.
- 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.
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.
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.
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.