run_encounter gains llm_policy and per-run policy_factory (mutually
exclusive with policy/llm_policy); replay.py logs every (observation,
action) keyed by call_seq to JSONL and replay() reproduces the recorded
battle bit-identically (wins/draws/rounds/transcript) with mismatch,
exhausted, duplicate and out-of-order guards. P0 frontier complete: the
recorded run replays identically.
Add replay.py: a ReplayLog records every (observation, action) pair a
policy produced, keyed by monotonic call_seq, plus the EncounterSpec,
seed and round_cap, and serializes the whole battle to JSONL. replay()
turns a log back into an observation-only LLMPolicy that returns each
recorded action, raising ReplayMismatchError on a differing observation
and ReplayExhaustedError past the last call.
Observations are matched by their canonical JSON (observation_key), not
raw dataclass equality, because JSON collapses tuples and lists and the
snapshot's inner dict[str, object] holds both. Action and the spec are
dumped through Pydantic TypeAdapters (mode=json) so tuple fields survive
as arrays and are coerced back on load.
Move TrickCondition/ReadyTrigger out of the TYPE_CHECKING block in
combat.py: TypeAdapter(Action) must resolve them at runtime to serialize
the dataclass, so the Literal aliases now live at module level.
Compact render is 15,691 bytes (~3.9k tokens) = 24.4% of the raw 64,204-byte
to_dict() snapshot on the representative 7-combatant encounter, hitting the
~2-4k token target; grids are sparse and determinism is pinned in tests.
candidates() derives plausible actions from the pairs with no second legality
engine (swift/free/full_round excluded).
Build a decision-sufficient ObservationView from the raw snapshot and
render it as a deterministic line-based prompt string. The raw to_dict()
is ~64 KB (~16k tokens) for a 7-combatant encounter; the compact view
renders at ~15.7 KB (~3.9k tokens, 24% of raw), under the 4k-token
ceiling of the phase-2 architecture doc.
Compaction: sparse terrain/elevation/threatened grids, per-combatant
key selection (occupied_cells, abilities, skills, initiative_mod/total,
temp_hp_rounds_left, features, carried_items, weapon_damage, aware,
alignment, armor_spell_failure, arcane_caster, run_rounds dropped),
attacks compacted to formula+type damage strings, spells to 7
decision-relevant fields, pair target_index replaced by the stable
combatant id, condition_definitions dropped. Attacks and spells render
as positional tuples declared by attack_fields/spell_fields legend
lines, with '-' for missing values.
Tests pin the size bounds (compact < 0.25 x raw and < 16000 bytes), the
sparse-grid contract, the dropped-key contract and the deterministic
id/render contract on a caster-heavy 3v4 representative battle.
Measures: observation 64 KB (~16k tokens) for 7 combatants / 22 KB for a 2v2;
124 policy calls per battle, i.e. 124k calls per 1000-run sweep cell -> a
per-turn LLM in a sweep is infeasible (D9 validated) and a compact view is
mandatory. Danger map (default vs focus-fire monsters, 100 runs): the default
policy understates danger (PC deaths 13%->24% and 0%->19%, TPK 0%->2%) and the
win rate alone misleads (rises to 98% while deaths rise) -> D12/D13 validated.
Notes the spike limits and the remaining P0.2-P0.5 items.
run_encounter gains an opt-in collect_runs flag returning one RunOutcome
per battle (winner, rounds, per-combatant id/side/hp/hp_max/dead) next to
the EncounterReport; default behavior is unchanged. objectives.py provides
the pure optimizer primitives: P(survives all), P(TPK), P(>=1 death),
expected hp lost, expected rounds and a worst-decile (CVaR-style) tail
metric. phase2_spike.py holds the spike-level strengthened monster policy
(focus fire on the lowest-AC enemy, ties to the wounded; party side stays
on default_policy). tests/test_phase2_danger_map.py prints the danger-map
sketch: warband and lich scenarios measured default vs focus-fire at
100 runs / seed 11, pinning the delta deterministically.
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.
All recommendations accepted by the user except prompt location: prompts live
outside this repo (separate pack, configurable path, versioned; prompt_version
feeds the cache key and the replay record; a pinned pack for integration, a
minimal local fixture for unit tests, no silent fallback). Also records the
repo-scope decision (code only: calculation + LLM + report) with the final
detachment of data/sheets/prompts as a backlog item and the design constraint
to avoid hardcoded data paths in the new code. Priority: cartography first.
The execution tiers answer 'how to run a tactic', not 'which tactic is best'
nor 'where is the danger'. Adds two decision layers: an optimizer over
StrategySpec (objectives incl. tail risk, coarse-to-fine search, LLM as
candidate seeder) and an adversarial cartography (AdversarySpec + alternating
min-max + scenario sweeps + danger frontier + sensitivity), giving the danger
view for BOTH sides. Reframes the LLM as hypothesis generator / red team /
explicator / reporter, never the optimizer or per-turn executor in sweeps.
Adds D12-D14 and phases P3 (optimizer) / P4 (robustness).
Key correction: the LLM must not sit on the per-turn hot path of a Monte
Carlo sweep (runs x rounds x combatants calls, no cross-run cache reuse).
Adopts a two-tier design - a compiled, parameterised deterministic Policy for
quantitative sweeps (tier A) and a per-turn NLPolicy for a handful of showcase
battles (tier B). Also fixes: 'engine unchanged' -> rules unchanged/additive
boundary; replay keyed by a monotonic call_seq (delay re-queues, summons);
composite response-cache key incl. model id; ObservationView as a structured
object; candidates derived from pairs instead of a second legality engine;
per-run PolicyFactory; cost guardrails in P1; concurrency; D1 to settle in P0.
Design-only document (no code): target architecture (compact versioned
observation view, legal-actions API, LLM client + cache, NL policy with
parse/validate/repair/fallback, strategy sweep, replay/eval harness), the
concrete gaps in the current runner/CLI wiring, the decisions to settle and a
phased implementation plan (P0 foundations, P1 client+policy, P2 strategy
sweep, P3 provider+report) with exit criteria and risks.
Brings in the last four out-of-scope mechanics: the nonlethal damage track
(unarmed strikes, minimum damage), splash weapons (direct + scatter),
combat skills (skill checks, Acrobatics Tumbling, Stealth/opposed Perception)
and 3D flight over ground terrain (vertical line of effect deferred). Updates
the out-of-scope and residual tables to drop the now-modeled items and marks
the round-2 disposition tables as historical. Gate 1981.
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.
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.
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.
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).
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.
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.
- 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.