Completes the SRD standard-action suite. Off-hand attack reuses the
normal hit math with only the damage expression swapped, positive
ability mod withheld per SRD (negative kept), Light property modeled
explicitly (opt-in on stat blocks; undeclared weapons refused, never
silently permitted), and a new spend_bonus_action enforcement point —
plus SRD ordering (Attack action must already be spent). Help stores a
consumable advantage promise ON THE BENEFICIARY, consumed exactly once
by same-side attackers through both attack routes, cleared on refresh,
and rewound with Action refund. Ledger + gateway proxies + client
helpers throughout; seed never client-forwardable. 18 red-first tests;
228 workspace / 476 pytest green, clippy -D clean.
Co-Authored-By: Claude <[email protected]>
Two workstreams sharing server.py, landed together:
Ready action (71): ReadiedAction on EntityState with Action spend via
the single enforcement point, READY_ACTION_SET ledger event, cleared on
round refresh and rewind; engine route + gateway proxy + CharacterSheet
"Holding:" display with description input. GM adjudication by design —
no automatic trigger matching. 6 red-first tests across layers.
Fourth-audit remediation (72): MED-HIGH deploy crash — Roll20 imports
persisting speed:None crashed float(None) → 500 on every deploy of such
characters; guarded fallback to 30 with math.isfinite checks and the WS
movement budget consuming the same value. LOW: out-of-range import
levels now disclose their clamp instead of silently snapping. 8
red-first tests.
Full suite: 470 passed / 22 skipped; clippy -D warnings clean.
Co-Authored-By: Claude <[email protected]>
All six engine action routes (grapple/shove/dodge/dash/disengage/
stabilize) gain gateway proxies with strict extra=forbid bodies and
identity forwarding — notably the deterministic seed is deliberately
NOT forwardable, so clients can't pin their own rolls. CharacterSheet
gains a Combat Maneuvers panel: target picker from the projected
roster, skill/effect toggles, self-buttons, downed-ally stabilize list
with an honest hint when player-role projection hides ally HP; results
quote engine fields and rejection codes verbatim with zero optimistic
mutation. 10 new proxy tests; suite at 459.
Co-Authored-By: Claude <[email protected]>
Three small audit notes closed red-first:
- Empty/[DONE]-only SSE reassembly no longer masquerades as a
completed turn: complete_with_tools raises LLM_UPSTREAM_EMPTY and the
tool agent surfaces that status instead of COMPLETED with blank
narration.
- The SLA 'intent_parsing' row is relabelled intent_parsing_keyword
(it measures regex classification); an LLM-classification row appears
only under RUN_LIVE_LLM=1 with a real key, counting only genuine llm
outcomes; SSE first-token requires >=3 samples or WITHHELD.
- Report ends with explicit FAILED/PASSED/WITHHELD tallies and an
all-withheld run exits nonzero — withheld can never read green.
21 red-first tests; suite at 451.
Co-Authored-By: Claude <[email protected]>
Third-audit web finding: two individually-clean modules broke at the
handoff — unparsable speed strings passed through unwarned and the
gateway fabricated speed=30; missing class/race/background/alignment
silently became "fighter/Human/Soldier" persisted as fact under a
docstring claiming every substitution was pre-warned. Now: unparsable
speed warns and persists None (never invented gameplay data); identity
fields persist neutral-empty with per-field warnings naming exactly
what was absent; missing level defaults to 1 WITH a warning; the
missing-core dedup no longer lets garbage-value warnings suppress
missing-stat notices. 7 red-first tests.
Co-Authored-By: Claude <[email protected]>
- P0: pytest was RED in any clean environment — dead skip guards fired
only after ConnectError had already escaped. Both helpers now skip
properly when the engine is down; suite green with no live engine.
- HIGH: starter-adventure export required ?token= while the wizard sent
an Authorization header — every click was a guaranteed 422 under a
feature claiming to work. Route migrated to _require_auth
(header-first); tests pin header-auth success and 401 semantics.
- MED: clipboard 'Copied!' claimed success unconditionally; now awaits
the write and shows an honest Ctrl+C fallback on rejection or missing
Clipboard API.
Co-Authored-By: Claude <[email protected]>
GOALS.md's latency targets were removed from the docs as unverifiable;
now they're measured. measure_slas.sh drives 200 real network calls
per category against live services and prints p50/p95/max vs targets
with a documented +5ms transport allowance: rules 0.39ms p50 (≤10ms),
spatial LoS/path 0.35/0.41ms (≤15ms), intent parsing 1.10ms (≤150ms),
SSE first-token 1.86ms (≤1200ms) — all PASS with wide margin.
Unreachable services WITHHOLD rows rather than simulating; rate-limit
429s surface as visible exclusions; sub-30-sample categories withhold
rather than judge rump samples.
Co-Authored-By: Claude <[email protected]>
The live suite proved llm.heretek.one answers text/event-stream even
to non-streaming requests, silently breaking complete_json /
generate_narrative / complete_with_tools via resp.json() failures. All
three now detect SSE content-type and reassemble assistant messages
from delta frames (tool_calls merged by index), feeding the unchanged
JSON extraction; JSONL records carry an honest transport marker;
malformed/empty streams degrade without guessing. Narrative blank-
stream degrades with degraded:true rather than empty-but-healthy.
11 red-first tests; suite at 436.
Co-Authored-By: Claude <[email protected]>
pytest 'live' marker + conftest auto-skip unless LLM_KEY is set AND
RUN_LIVE_LLM=1 — structurally CI-proof. Six tests exercise the REAL
endpoint: mechanical/safety classification provenance, Concordia
generator-marker-vs-reality (a template marker while the endpoint is
alive FAILS), strict-JSON parsing, soft 60s latency budget, and a gate
proving non-mock configuration. Connection failures degrade to SKIP.
First live run surfaced two real integration bugs, reported honestly
as skips rather than fabricated passes: .env named a nonexistent model
that hangs instead of 404ing (fixed in .env), and the endpoint returns
text/event-stream even for non-streaming calls which breaks resp.json()
(fix iteration pending). Default suite unchanged: 406 passed / 8 skips.
Co-Authored-By: Claude <[email protected]>
GOALS.md Pillar 1's drop-in AI party members: tank / skirmisher /
healer roles execute coherent turns through the same authenticated
proxy path as campaign_sim (no second HTTP stack), with optional LLM
targeting refinement validated against known entities and per-turn
provenance accounting (decision_source, fallback_reason, degraded).
Honest realizability matrix: ally-heal triage from a plain player seat
is REFUSED rather than fabricated because the role projection redacts
other PCs' HP — the companion falls back to tank behavior instead.
19 red-first tests; suite at 425.
Co-Authored-By: Claude <[email protected]>
POST /api/v1/import/roll20 accepts single or campaign exports (2 MiB
bound), maps through the importer onto the existing character storage
path so imports are owned by the caller and owner-scoped everywhere;
importer ValueErrors map to 422 with the fail-loud reason, all-
malformed campaigns refuse rather than fake a 200-with-zero. Foundry
preview route is a deliberate documented 501 — directory trees need
multipart support, pinned by test so it can't regress into fake
success. 7 red-first tests; suite at 406.
Co-Authored-By: Claude <[email protected]>
Two minor audit notes closed: the gateway's sliding-window store now
sweeps stale keys past a 100k cap (mirroring the Rust twin's guard,
same sweep-on-overflow shape, injectable for tests), and NPC
interaction magnitude is bounded to (0,10] matching the disposition
engine's band-saturation scale so one call can no longer pin a stance
to an extreme. 5 new tests; suite at 373.
Co-Authored-By: Claude <[email protected]>
Second independent audit findings, fixed red-first:
- HIGH: /engine/session-state required no token and returned FULL
engine state verbatim via a service-principal fallback. The route
now requires valid auth (401 otherwise) and no code path returns
anonymous full state.
- MED-HIGH: ledger events bypassed role projection, leaking HP/damage
numbers for hidden entities while replay export redacted them. Both
now share one projection policy (fail-closed summaries for
spectators/unknown roles).
- MED: GET /characters/{id} had an IDOR — any authenticated user could
read any character; now owner-scoped with 404 for foreign/garbage ids
(no existence oracle).
Six remediation tests verified failing before each fix.
Co-Authored-By: Claude <[email protected]>
Pillar 10 interop grounded in live schema research (foundryvtt.com
manifest docs, foundry-vtt-types, NDJSON export format): module.json +
packs parsing onto AetherTable canon via explicit per-entry mapping
tables, unmapped fields accounted per entry rather than dropped or
guessed, LevelDB-format pack dirs detected and skipped, fail-loud
ValueErrors on manifest problems, deterministic sorted output.
Roll20 guarded out of scope pending its own fixture corpus. 25
red-first tests; suite at 363.
Co-Authored-By: Claude <[email protected]>
Pillar 5 sub-agents become reachable: GET /api/v1/npc/ lists public
persona metadata; POST /npc/{id}/respond returns norms-gated
{reply, generator, stance} (taboo-violating LLM text degrades to the
template fallback with an honest norm_rejected marker); POST
/npc/{id}/interactions shifts persistent stances through the shared
disposition engine. Three personas bound to starter-adventure lore
(Drowned Steward, Baron Vane, Cabal Emissary) with distinct goals,
norms, and voices on a deterministic shared clock. 13 red-first tests;
suite at 338.
Co-Authored-By: Claude <[email protected]>
Two features sharing server.py, landed together:
Initiative order end-to-end (5.6): vtt-core begin/end_combat roll
d20+DEX initiative with deterministic seeded order (total desc, DEX
desc, name asc), COMBAT_BEGAN/ENDED ledger events; engine routes
/sessions/{id}/combat/{begin,end} with RBAC; gateway proxies forward
caller identity; the HUD renders the real rolled order with an honest
"Combat not started" empty state and GM controls. 7 core unit tests +
4 integration + mocked proxy tests.
Role-projected session state (spectator follow-up): session-state and
session-replay now project by caller role — GM/admin see everything,
players see own entities in full plus others as public board-token
projections, spectators get visible-only projections with HP/damage
amounts stripped from replay summaries; service-principal reads stay
verbatim; unknown roles fail closed to spectator view. is_player/
is_dead kept in projections because campaign_sim's targeting depends
on them through this endpoint. 20 red-first tests.
Suite at 325 passed.
Co-Authored-By: Claude <[email protected]>
GOALS.md Pillar 5's dedicated NPC sub-agents: explicit Memory (bounded
episodic, relevance+recency recall), Goals (priority/satisfaction/
feasibility walk with survival fallback), Social Norms (taboo +
obligation rules), and Linguistic Style components composing a persona.
respond_to() builds structured prompts from all four components plus
live disposition stance; LLM replies that violate norms are discarded
entirely for the deterministic stance/style-shaped fallback — violating
text never reaches output. Deterministic without an LLM; honest
generator markers. 17 red-first tests; suite at 296.
Co-Authored-By: Claude <[email protected]>
Multi-tenant retrieval per GOALS.md Pillar 7: compendium spells/
monsters/magic items indexed into the qdrant_rules collection named
and dimensioned by the repo's own collections config, payload-filter
namespacing (category + system_id + tags), deterministic UUID5 point
ids, keyword indexes on filter fields — all over raw httpx REST so the
transport stays trivially mockable. Embedding is a documented
lexical-hash embedder (MD5-bucketed, L2-normalized), not semantic;
real models are a single-function swap. lore-lookup gains semantic=true
with provenance retrieval: qdrant | substring | substring_fallback,
zero network when disabled, one honest warning when unreachable,
read-path degradation only (canon-write strictness unchanged).
29 red-first tests; suite at 279.
Co-Authored-By: Claude <[email protected]>
GOALS.md Pillar 2's first out-of-the-box module: three encounters
referencing ONLY real srd_5_2_monsters stat blocks (unknown ids raise
at definition time, so invented stats cannot ship), deterministic
seeded layout resolved via the engine's live WFC with a documented
in-module fallback and provenance recorded in the artifact, crypt-theme
loot name references, lore seeds reusing exact canon node ids, X-card-
safe content note. Ships as a real .vttbundle through the packager
(new backward-compatible adventure.json member) behind GET/POST
/api/v1/adventures/starter routes. 12 red-first tests; suite at 250.
Co-Authored-By: Claude <[email protected]>
Two features sharing server.py, landed together:
Auth headers: new client auth_headers.ts migrates lobby/character/
campaign REST calls to Authorization Bearer headers (five POSTs
previously sent NO credential at all); gateway gains a _require_auth
dependency reading header-first with ?token= fallback on migrated
routes (missing token is now 401, not 422). WS query tokens unchanged.
7 new tests.
Neo4j lore: Neo4jEpistemicGraph speaks to the dockerized neo4j:5 HTTP
transactional endpoint over httpx (zero new deps), same public surface
as the in-memory manager, all three paradox checks in one tx round
trip, tier weights matching canon, startup-only selection with honest
in-memory fallback logging, request-time failures raise rather than
silently splitting canon, predicates validated UPPER_SNAKE_CASE before
Cypher assembly. 24 tests incl. in-memory parity cases.
Co-Authored-By: Claude <[email protected]>
GET /api/v1/engine/session-replay projects the live engine ledger into
a downloadable artifact: events reduced to {sequence_id, actor_id,
event_type, is_reverted, summary} with human-readable summaries derived
only from fields actually present — partial payloads render what
exists, unknown event types pass through verbatim, reverted events are
flagged not dropped. Authenticated via forwarded caller identity;
Content-Disposition attachment naming falls back to -unknown.json when
no combat round exists. 15 red-first tests.
Co-Authored-By: Claude <[email protected]>
NpcDispositionEngine wired into CampaignSimulation: accepted attacks
record 'attacked' on the target's disposition toward the attacker
(rejected actions and non-attacks record nothing), decision prompts and
scripted-policy inputs receive each visible target's current stance
toward the acting player, and reports carry per-player end-of-run
stances plus interaction totals. All disposition timestamps use an
explicit sim clock — deterministic across identical runs; the engine's
clock is pinned so wall-clock can never fire. 'aided' hook deliberately
absent (no heal action reachable through proxies) and documented.
8 new tests; suite at 192 passed.
Co-Authored-By: Claude <[email protected]>
httpx transport exceptions stringify to "" so the JSONL audit log
recorded status:null + error:"" — indistinguishable from nothing.
Error fields now always carry the exception type. Recorded evidence
from the first LIVE campaign-simulation run against the configured
custom endpoint (llm.heretek.one): endpoint currently unreachable
from this network; the harness degraded per-turn exactly as designed,
counted every attempt, and the engine accepted 100% of executed
actions.
Co-Authored-By: Claude <[email protected]>
The deferred flagship: synthetic players authenticate, host/join/launch
a lobby, deploy RBAC-owned characters, and take turns through the same
authenticated engine proxies real clients use. Decisions come from the
configured custom LLM endpoint via complete_json with strict decision
validation (unknown actions / malformed JSON / bad coordinates fall
back per-turn while still counting the attempt); no key means honest
scripted mode with zero network. Every LLM call flows through the
existing gateway so logs/llm_calls.jsonl captures it. Reports carry
only counted metrics with enforced invariants (accepted+rejected ==
attempted). 13 tests, fully mocked; __main__ entry runs live from .env.
Co-Authored-By: Claude <[email protected]>
Audit follow-up: classify_with_llm existed only as tested library.
execute_orchestrator_turn now classifies FIRST — keyword safety hits
short-circuit before any network call or grounding; the LLM path
engages only when VTT_LLM_CLASSIFIER != 0 and a key is configured,
falling back to keywords with provenance. Non-mechanical intents are
gated away from the engine cycle (SKIPPED_NON_MECHANICAL / SAFETY_
INTERVENTION statuses), matching the playtest harness semantics.
Responses gain classifier/classified_intent/fallback_reason fields;
unconfigured environments see zero behavior change. 8 new tests with
network blocked by an exploding httpx fake.
Co-Authored-By: Claude <[email protected]>
The browser had no readable path to authoritative post-rewind state,
so local tokens drifted until next hydration. The x-card response now
carries a full serialized GameSession snapshot taken after the rewind
(Null-degrading on serialization failure), and the gateway gains POST
/api/v1/engine/session-state — the first browser-readable proxy to
authoritative engine state, with identity forwarding when a token is
present. Per-role visibility filtering documented as future work.
8 pytest proxy tests + 1 integration test pinning post-heal rewind HP.
Co-Authored-By: Claude <[email protected]>
Replaces greedy first-fit with STRIPS-style forward search: actions
declare preconditions and effects (thresholds + delta wrappers), the
planner runs uniform-cost A* over world-state snapshots with
deterministic tie-breaking, dominance dedup, depth/expansion caps
bounding EspionageRaid's positive resource loop. Risk folds into
planning cost; execution stays deterministic. Legacy tick behavior is
preserved verbatim when no goal is set — same three actions, same log
lines — so existing callers are untouched. New API: set_goal/clear_
goal/plan/execute_plan/goal_progress_pending. 19 new tests including
optimality-over-greedy and legacy-equivalence pins.
Co-Authored-By: Claude <[email protected]>
POST /api/v1/engine/heal and /api/v1/engine/rest follow the established
proxy pattern: required token Query param, _caller_actor forwarded so
engine RBAC authorizes the real caller, strict Pydantic models
(extra=forbid, Literal rest kind) mirroring the engine's
deny_unknown_fields contract so no client math crosses the boundary.
8 new proxy-identity tests; suite at 150 passed.
Co-Authored-By: Claude <[email protected]>
classify_with_llm() prompts the configured custom endpoint
(LLM_API/LLM_KEY/LLM_MODEL) for JSON classification into the real
IntentType enum, with layered honesty and safety:
- SAFETY_INTERVENTION always wins — keyword safety short-circuits
before any network call; an LLM safety label overrides keyword
disagreement
- any failure (kill-switch VTT_LLM_CLASSIFIER=0, no key, malformed or
unknown output) falls back to the deterministic keyword classifier
with provenance fields; never raises
- RULE_OF_COOL has no enum counterpart and is deliberately not mapped
complete_json() adds fence-tolerant JSON extraction + non-streaming
chat-completions with response_format json_object; degraded-marker
code preserved. 12 new tests, HTTP fully mocked.
Co-Authored-By: Claude <[email protected]>
When the LLM upstream fails (no key, non-200, mid-stream error), the
deterministic fallback generator now announces itself: a sentinel from
stream_narrative makes the gateway emit a leading {degraded, reason}
frame and tag every subsequent frame degraded:true. Healthy streams
carry no degraded key at all. Non-streaming generation gains
generate_narrative() returning {narrative, degraded, reason?} with
shared canned prose so both paths stay identical. Clients can finally
tell real LLM narration from canned fallback.
Co-Authored-By: Claude <[email protected]>
Continuous directed relationship scores per (npc, player) pair per
GOALS.md Pillar 8: trust and fear with independent exponential decay
(fear half-life ~3.5x shorter), alignment-compatibility bias, NPC-level
stress that amplifies fear, event-driven interaction recording with
strict kind validation, stance banding (hostile..allied), and lazy
decay computed at query time so scores are pure functions of recorded
events. Injectable clock keeps it deterministic for replay/testing.
Pydantic v2 record model matches project conventions. 15 new tests.
Co-Authored-By: Claude <[email protected]>
AFPR's rejection branch was mathematically unreachable (run_turn_cycle
only returns COMMITTED / FALLBACK_COMMITTED), so false positives were
tautologically 0%. Now:
- grounded proposals that get rejected ARE counted as auditor false
positives (a grounded draft describes the genuine engine outcome);
- ~10% of audited turns inject a recall probe — a draft narrating the
death of a target live state says is alive — which the auditor MUST
catch; new auditor_recall_pct metric (target >= 95%) reported by the
benchmark suite;
- entity counts fed to the audit come from the live session snapshot.
Live run: MCR 100%, HCI 1.0, AFPR 0.0%, recall 16/16 = 100%.
Co-Authored-By: Claude <[email protected]>
- Grounding: NarrativeGenerateRequest gains engine_session_id /
target_entity_id. When a session is named, _resolve_audit_inputs fetches
the live snapshot and the auditor reconciles against IT: lethality fields
come from the live entity (a payload claiming death while the engine says
25 HP can no longer smuggle a kill past Vector 3), entity counts come
from the live snapshot, named-but-missing targets are rejected as ghost
entities, and an unreachable engine fails closed with
WORLD_INSPECTOR_UNAVAILABLE instead of auditing stale client claims.
- Streaming: tokens are now HELD until the sentence they belong to has
passed the auditor; violating sentences and everything after them are
never emitted — only the corrective SYSTEM frame and the cut.
- Lore continuity: Vector 4 was dead code (nothing ever populated its
lore_* payload keys). The auditor now derives (subject, predicate,
object) triples from canon entity names and predicate verbs appearing in
the draft itself, including reflexive assertions like "Oakhaven Keep
still stands". Payload triples still take precedence.
Co-Authored-By: Claude <[email protected]>
Unparseable input used to silently yield a combat-ready fake: AC 14,
HP 30, invented "Claw Strike" action with +5 / 2d6+3. Core fields now
stay absent when not parsed, every missing field is reported in
warnings, and responses carry parse_ok. Strict mode (new flag on
HomebrewParseRequest) rejects with 422 instead of returning a
half-invented statblock. Placeholder lists are copied per call to stop
cross-parse state leaks.
Co-Authored-By: Claude <[email protected]>
The step-4 report block had escaped quotes inside f-strings passed through
python -c, a guaranteed SyntaxError that suppressed live MCR/HCI/AFPR output
entirely. Replaced with a quoted-delimiter heredoc so the Python reaches the
interpreter byte-for-byte, and pinned AUTH_SECRET so the fail-closed engine
and the playtest signer agree.
First honest run then exposed a harness bug: the runner kept attacking its
training dummy after killing it (~100 turns of TARGET_ALREADY_DEAD rejections
dragging MCR to 76%). The runner now replaces the dummy when it drops, so
MCR measures action acceptance against living targets. Result: MCR 100%,
HCI 1.0, AFPR 0.0%, trust boundary 8/8.
Co-Authored-By: Claude <[email protected]>
- CORS: replace wildcard origins + credentials with an env-driven
allowlist (CORS_ALLOW_ORIGINS), mirroring the engine's strict_cors.
- identity: engine proxies (spawn/cast-spell/move/turn-next/damage/
reactions-arm, plus attack and death-save) now verify the caller's
token and mint a forwarded-identity HMAC token carrying the real
user_id and role, so the engine's RBAC authorizes the actual actor.
Previously every proxied action spoke as 'orchestrator-service' with
no role and was rejected ENTITY_NOT_OWNED on player-owned entities —
deployed characters could not be driven end-to-end. Omitting the
token keeps legacy service-mediated behavior.
- tests: proxy identity forwarding, 401 on invalid tokens, contract
route updated to act as GM.
Co-Authored-By: Claude <[email protected]>
- narrative stream: extract_srd_context() scans player intents against the
SRD 5.2 bestiary/grimoire and injects stat-block facts (AC/HP/actions,
spell level/school/description) into both live LLM prompts and the
deterministic mock generator — the DM can no longer contradict the rules
- new GET /api/v1/compendium/lore-lookup for client-side fact resolution
- safety: X-card now applies a real ledger rewind on the engine session
(POST /sessions/{id}/safety/x-card) via the orchestrator, recording
SAFETY_REWIND_APPLIED audit events; client wires the SafetyModal through
- voice: removed the random speaking simulation from WebRTCMeshManager;
local mic RMS (getUserMedia + AnalyserNode) drives the real speaking
ring; remote peers stay silent until genuine P2P audio lands
- tests: +7 grounding/x-card suite (53 pytest total pass), cargo clean,
client ~1s build, MCR 100% / HCI 1.0 / AFPR 0%
Co-Authored-By: Claude <[email protected]>