engine_boot · loreweavers-cauldron · v27
CLICK TO SKIP — ENTER TO BEGIN
The Lore Weaver's Cauldron — under the table

The Mechanics of the Cauldron

How the engine runs beneath the game: a Dungeon Master that writes stories, a Guardian that turns prose into state, dice that only the player may roll, a voice cache that never speaks the same line twice, and one JSON file that remembers everything.

DM prompt v27 Guardian 38-field contract Two-phase dice TTS cache · 64 slots · 600s TTL SSE character weaving
▼ scroll to open the hatch ▼
§1

The Dual Mind

Every turn is resolved by two minds that never write to the same place. One is an artist, the other an accountant — and they only meet through a contract of square brackets and JSON.

The Dungeon Master

A free, creative LLM storyteller. Its identity, craft rules and world-consistency laws live in DM_CORE_PROMPT, versioned as v27 — bumping the version forces a cache miss so the model can never serve a stale persona.

It is told: "The story is NOT pre-written: it is shaped by the player's choices, in the moment." It chooses theme, tone and atmosphere. It may say no. Death is real.

Role split: Narrator · NPC actor · Rules arbiter · World builder · Challenger

The Guardian

A second LLM pass that reads the DM's narration and extracts every mechanical effect into a strict 38-field JSON contract: damage, healing, XP, items, currency, quests, NPC changes, combat, rest, world lore.

It works in two phases: PRE (before the DM writes — decides if a roll is needed) and POST (after — turns prose into state). It never writes a sentence; the DM never writes a number.

Fallback: a legacy regex tag pipeline ([SKADA:] [GULD:]…) still exists but is nearly dead since v27 told the DM "you do NOT need mechanical tags".
PLAYERaction · SSE GUARDIAN PREroll? → [KAST:] hint DUNGEON MASTERsystem prompt v27 CHAT STREAMreasoning + content VALIDATEstrip · parse rolls GUARDIAN POSTprose → JSON contract APPLY MECHANICSmutate state · dedup STATE.JSONthe one truth TRUTH BLOCK + SUMMARIES feed the next system prompt 1. action 2. need a roll? 3. narrate + [KAST:] 4. stream to chat 5. clean 6. extract JSON 7. write state 8. save + lock
DM path Guardian state writes persistence validation

Four knobs, one clamp

Each campaign carries four model choices, saved in meta and swappable from the in-game settings: the DM model (storyteller), the Guardian model (roll judge), the Extraction model (POST parser) and the TTS provider (StepFun "Step Plan" or Qwen "Token Plan").

Non-admin players are clamped to a whitelist server-side — the picker in onboarding is only a UX affordance; _clamp_player_model is the real gate.

step-3.7-flashdeepseek-v4-flashqwen3.6-flashqwen3.8-maxollama:heretic (local)
§2

The Turn Pipeline

One player message triggers eight serialized stages. The DM stream is the only thing the player watches; everything after it happens in the dark.

1 Action in

The player's message arrives via SSE. A per-campaign asyncio lock is taken — every state write is serialized, no two turns may race.

2 Guardian PRE

guardian_check_roll reads the action + compact state and decides if the outcome is uncertain. If so it hands the DM the exact [KAST: 1d20+MOD | LABEL (DC X)] tag to use.

3 DM writes

_build_system_prompt assembles the layered prompt (see §3). The DM streams tokens — reasoning shown as a thought bubble, then narration — into the chat.

4 Validate & strip

validate_dm_response checks the shape; mechanical tags are stripped from display text. [KAST:] is parsed into a real dice button the player must press.

5 Guardian POST

In the background, guardian_extract_mechanics sends the reply + last 6 transcript entries + compact state to the extraction model. Invalid JSON triggers a 2-attempt repair loop; total failure returns an empty dict — the turn survives.

6 Apply & save

apply_mechanics mutates state with dedup (skip_effects) against anything the legacy tag pipeline already applied. state.json is written, last_effects recorded — and the loop feeds §1.

transcript.snapshot · turn 42
# player action (SSE)
❯ you: I edge toward the door, dagger ready, and try to slip past the guard. [sneak]
 
# guardian_pre · qwen3.6-flash · 0.9s
🛡 pre: uncertain → recommend [KAST: 1d20+3 | DEXTERITY to sneak (DC 14)]
 
# dm stream · step-3.7-flash · 6.2s · 412 tokens
DM: You press your back to the damp stone, torchlight crawling across the floor… [KAST: 1d20+3 | DEXTERITY to sneak (DC 14)]
 
# validate · strip tags · parse_roll_requests
⚙ parsed: 1 roll request → dice button spawned in chat
 
# player roll (the only number in this turn)
❯ you: [Resultat: 1d20+3 → 17] (button click, auto-sent)
 
# guardian_post · extraction model · 2.1s · 3 changes
🛡 post: xp:0 · logbook:"Faelyndra smög förbi vakten…" · npcs_near:[Kip] · time_passed:1h
 
# apply_mechanics → state.json · lock released
✍ saved: turn 42 · hp 21/26 · gp 15 · 2 quests active
§3

Prompt Injection

The DM never sees raw JSON. Every turn the engine welds a stack of layers into one system prompt — language first, truth forever, memory in tiers. Expand each layer.

The very first line of the system prompt pins the output language and overrides any Swedish internal notes. The game can be played in Swedish while the UI stays English — language is a per-campaign setting, not a code constant.

[LANGUAGE: ENGLISH] You MUST write ALL narration, dialogue, NPC speech, descriptions, and every single word of your response in English. This overrides any Swedish text in the instructions below — those are internal system notes, NOT the output language.

Then the version stamp — [DM-prompt v27] — which forces a provider cache miss on every prompt change, so a deployed update is instantly live.

The personality charter. The key covenants the model is sworn to:

## Identity and tone
- You are an engaged, atmospheric storyteller. Adapt the mood to the scene — threatening in combat, warm by the campfire, tense in mysteries.
- ALWAYS respond in the language specified in the [LANGUAGE] or [SPRÅK] directive at the top.
- ALWAYS end with an opening — the last sentence should invite the player to act.
- Do NOT be afraid to say no. Consequences must be felt. Death is real.

## 🖋️ STORYTELLING CRAFT
- SHOW, DON'T TELL: "The innkeeper's hands tremble as she fills your tankard" instead of "she is afraid".
- ENGAGE THE SENSES: aim for at least two senses per scene.
- IMPLICATION IS STRONGER THAN DESCRIPTION: a sound in the dark, the empty chair, the door left ajar.
- EVERY SCENE IS A PROMISE: spark curiosity — a mystery, a threat, an opportunity.

## 🗺️ WORLD CONSISTENCY (CRITICAL)
- The world is a FICTIONAL fantasy world. NEVER use real place names.
- Every NPC gets a UNIQUE, UNEXPECTED name — never reuse a name style from an earlier NPC.
- Keep the world consistent: the same place has the same name, the same NPC has the same personality.

Mechanics are explicitly delegated: "A separate system (Guardian) automatically extracts mechanical effects from your narration… You do NOT need mechanical tags — just write what happens." The exception that stays alive: the [KAST:] roll tag.

The player must never win an item, spell or resource by simply claiming it. The DM is sworn to contradict them, kindly but firmly, offering an alternative path:

## ⚠️ ANTI-HALLUCINATION (CRITICAL)
- If the player says "I take my lamp" but the lamp is NOT in the inventory → SAY NO:
  "You have no lamp. Your hands search the dark but find only cold stone."
- If the player says "I cast my spell" but it is not on the character sheet → SAY NO.
- You NEVER accept player-invented details that give mechanical advantage.
  The WORLD and INVENTORY are authoritative.

## ⚖️ THE DM TRIAD — each action is resolved by exactly ONE of three responses
1. SAY YES — creative, reasonable, cinematic (Rule of Cool).
2. SAY NO — breaks the world or the sheet; always offer an alternative.
3. ROLL DICE — outcome uncertain + consequences matter → [KAST:].

## 🚨 [KAST:] BEFORE OUTCOME — ABSOLUTE RULE (CRITICAL)
❌ WRONG: "You slash at the goblin — the sword hits! 8 damage."
✅ RIGHT: "You slash at the goblin! [KAST: 1d20+5 | ATTACK vs AC 13]"
- If you write that the player hits/misses WITHOUT having requested [KAST:] first, it is a SERIOUS ERROR.
- The player must ALWAYS roll the die themselves. NEVER narrate the outcome before the tag.
DifficultyDC
Easy8–10
Medium12–14
Hard16–18
Very hard20–22
Nearly impossible25+

Mechanical advantages (Bardic Inspiration, Bless, Guidance…) must be mentioned with their die in the narration — "A warm melody fills you — you gain Bardic Inspiration (1d6)" — so the Guardian can create the dice button. Active resources must be reminded when appropriate.

Every turn the engine scans state.npcs for living enemies (relation == "fiende" and alive). Enemies present → DM_COMBAT_PROMPT (chat-first combat law, turn order, death saves); otherwise → DM_NARRATIVE_PROMPT. Combat rules only weigh on the context when combat is real.

Combat is chat-first: no battle arena. The DM opens it with the [STRID:name|HP|AC, name2|HP|AC] tag; the Guardian tracks damage, rounds and turn order from narration. Death saves follow D&D 5e exactly: nat 1 = two failures, nat 20 = wake at 1 HP, three of three decides.

NPCs are tagged on introduction — [NPC:Morvaine|Enigmatic wizard|unknown] — and a player can write @Name to make that NPC answer in their own voice, with the DM allowed only brief interjections.

truth_block(state) compacts the whole campaign state into a readable digest (HP, inventory, currency, known NPCs, quests, location, pinned facts) and ships it under a threat:

## SANNING (auktoritär — motsäg ALDRIG detta) / TRUTH (authoritative — never contradict this)
HP 21/26 · AC 15 · gold 15gp 4sp · longsword (equipped) · 2 quests active
Pinmade fakta: "The Gray Gate has been sealed since the Sundering."

Pinned facts are player-controlled truth — the only part of the world the model is forbidden to forget or revise.

Raw transcripts would drown the context, so memory is distilled into tiers and injected newest-first: the two most recent scene summaries, the two most recent chapter summaries, and the single campaign arc summary. Each is tagged with its turn or chapter number so the DM can reason about chronology.

Then the engine reports what just happened mechanically, from meta.last_effects — "Skada: 12 · Föremål: Torch · Tid: 2h" — so the DM can react to consequences it already caused. Finally: world (location, time, weather), known locations with travel times, lore, NPC list, quests and the character sheet.

Travel time is a formula the DM is handed verbatim: distance ÷ 10 × terrain modifier — road 0.5, plains 0.6, forest 1.2, mountains 1.8, swamp 1.5, minimum half a day per journey.

After the prompt is assembled, the engine asks the fact register which stored facts are relevant to this turn's message and appends the winners as a small authoritative block the DM may never contradict — joined by the four best semantic chunks from the Qdrant index. Full mechanics in §4 — Fact Memory.

## FACT REGISTER (authoritative — never contradict)
- [NPC] Kaelen Voss bears the party no ill will (turn 12)
- [PLATS] The Hollow Forge hides a second entrance behind the smithy (turn 8)

The assembly order

1 Language directive → 2 Core prompt v27 → 3 Combat or Narrative → 4 TRUTH block + pinned facts → 5 Scene ×2, Chapter ×2, Arc ×1 → 6 Last turn's effects → 7 World & locations & travel rule → 8 Lore → 9 NPCs → 10 Quests → 11 Inventory & character → 12 Fact memory & RAG — top 8 facts · top 4 chunks (§4)
§4

Fact Memory

Facts are extracted after every turn, ranked before every DM reply — and only the top eight make it into the prompt. Nothing is deleted; everything competes.

The register

The extraction model reads every Guardian POST and stores durable facts in a per-campaign FactRegister (facts.json): people, places, promises, grudges, discoveries. Each entry carries its category, source turn, mentions, confidence, and a relevance score between 0.2 and 1.0.

Facts never collide blindly: a new fact that replaces an old one sets superseded_by on its predecessor — replaced facts are excluded from the active set forever.

The ranking

Before each DM turn, every active fact is scored against the player's message:

score = (keyword-overlap + recency + mentions) × relevance-weight × confidence-weight

Recency adds up to +30% for newer facts · mentions add up to +25% for facts the story keeps returning to · a compacted fact's relevance cuts its score by up to −50%. The top 8 are injected as an authoritative block, joined by 4 semantic chunks from the Qdrant index (transcript · lore · summaries).

get_relevant_facts · limit 8 · scores
# player message: "I ask Kaelen about the Hollow Forge…"
0.81 [NPC] Kaelen Voss bears the party no ill will (turn 12)
0.74 [PLATS] The Hollow Forge hides a second entrance behind the smithy (turn 8)
0.31 [HÄNDELSE] The north gate guard lost his purse to a pickpocket (turn 3)
…more compete — only the top 8 ship. The 0.31 fact loses the window this turn.

When facts fall out

Superseded — replaced by a newer, truer fact; never injected again.

Not hot enough — a turn with no keyword overlap and few mentions filters the fact out for that turn (the moving window).

Recency decay — as the campaign grows, old turns score less; old facts sink below the top-8 cut.

Compaction (every 50 turns) — an archivist LLM reads up to 80 active facts and marks the stale ones; their relevance drops to 0.2 — a permanent −50% unless the story re-mentions them hard.

Pinned facts are the exception: player-pinned truth (the /pin command) is injected uncapped in every TRUTH block and never falls out.

Nothing is deleted

The register keeps every fact forever — eviction is only ever ranking. A fact that loses the window today can win it back tomorrow if the story returns to it; that is what makes a 200-turn campaign remember turn 3 when it matters.

limit 8top_k 4 RAGcompact every 50 turnspinned = never evicted
§5

The Guardian Contract

After the DM speaks, the Guardian must answer with exactly one thing: a JSON object with all 38 fields present. Never an explanation, never a missing key. Watch the extraction happen.

guardian_extract_mechanics → JSON (38-field contract)

  

The reading

The Guardian receives the DM reply, the player's action, the last 6 transcript entries and a compact state snapshot. It is a paranoid reader: implicit damage ("the cold bites", "your breath catches") must be caught; a potion drunk is a roll grant (2d4+2), never a fixed number; a "you see a book" is not an item — only "you take the book" is.

If the reply contains invalid JSON, the engine appends a correction — "Returnera ENDAST JSON-objektet, inget annat." — and retries once. Two failures return the empty contract and the turn continues untouched.

The rules it obeys

XP: only kills, quests, milestones, complex puzzles — never walking, resting, trading or small talk. Most turns pay zero XP. Quests pay their stored reward automatically, never double-paid.

Items: every item needs D&D stats, a weight, and a lore line from the campaign's own world. The player never equips themselves — the DM decides what is worn.

NPCs: updates are aggressive — implicit relation shifts (helping → allied, threatening → enemy), name reveals, secrets. Corrections: retract anything the DM implied that shouldn't have happened.

Enemy attacks: the Guardian names the attacker only — "the CODE rolls the dice and the damage" (d20 + attack bonus vs the player's AC). The LLM is never allowed to decide whether an enemy hits.

Health & combat
damage · healing · death · combat_start · combat_round · combat_end · player_attacks · ally_attacks · ally_damage · enemy_attacks · combat_events · initiative_entries
Progression
xp · level_up · spells_add · character_updates
Possessions
items_add · items_remove · currency (pp/gp/sp/cp)
Quests
quests_new · quests_completed · quests_failed
People
npcs_new · npc_relations · npcs_near · npc_notes · npc_name_reveals
World & time
locations_new · current_location · world_lore · time_passed · rest · new_day · day_summary · logbook
Grants & fixes
roll_grants (Bardic Inspiration → 1d6 button) · corrections (retract & explain)

One field is famously dead: status_apply still rides in the empty contract but is never consumed — a relic of an unwired status engine. enemy_actions, by contrast, was resurrected by the Battle AI: when enemies are alive, the Guardian decides their moves and the code rolls the dice — no enemy ever hits or misses by LLM fiat. The audit that found the dead field also found (and fixed) the shape-conflict landmine where a day-entry dict could crash the logbook append inside a swallowed background task, silently eating a turn's effects.

§6

The Dice Engine

The single most sacred rule: the LLM never rolls. The DM requests, the Guardian judges, the player rolls — and the result is the only number that ever enters state. Try it below; this roller runs the same notation parser as the backend.

✦ dice console — /api/dice · dice_roll(notation)

Fair in both directions

Both directions are rolled by code, never by the model. When you roll, the DM's [KAST:] request spawns a real dice button and /api/dice rolls it. When an enemy attacks, the Guardian asks the engine to roll — and the code rolls. The LLM never produces a number, and it never decides whether an enemy hits.

The algorithm is server-side Python's secrets module — cryptographically secure randomness (secrets.randbelow(sides) + 1), identical for player and enemy dice. Notation like 1d20+3 is parsed and validated — 1–100 dice, 2–1000 sides — before anything is rolled.

Because the model never sees the outcome before it is committed, the tale can lie — but the dice cannot.

The two-phase flow

1 · Guardian PRE reads the action and decides the outcome is uncertain. 2 · The DM writes the [KAST: 1d20+MOD | LABEL (DC X)] tag — the only syntax that can spawn a die. 3 · The frontend renders a dice button; the click sends the result back as [Resultat: …]. 4 · The DM must narrate the outcome immediately — never "what do you do?" before resolving.

Advantage/disadvantage is declared in the tag itself (ADVANTAGE/FÖRDEL): two d20, keep best/worst.

Edge rules encoded

Nat 20 = critical triumph · nat 1 = catastrophe. Death saves: 3 successes vs 3 failures; nat 1 counts as two failures, nat 20 wakes the character at 1 HP. Healing potions are always rolled (2d4+2) by the player — the DM may never narrate a fixed heal. Concentration: hit while concentrating → CON save DC 10.

Spell-slot dice and buffs (Bless, Guidance, Heroism) arrive through roll_grants — Guardian reads the narration and creates the button automatically.

§7

Voice & the TTS Cache

Every DM line can be spoken aloud — but synthesis is billed per character. So the engine never speaks the same line twice: a small in-memory cache with a ten-minute memory and a hard cap of 64 voices.

DM TEXT"the wind howls…" SEGMENTER≤900 chars / sentence SYNTHESIZERStepFun / Qwen · 1.1× CACHE64 slots · TTL 600s SPEAKERMP3 in browser HIT → skip synthesis, serve MP3 split at sentence ends miss → synth → store
text provider call cache hit path

How a line becomes voice

Long DM messages (2000+ chars) are split at sentence boundaries into segments of at most 900 characters — Qwen's synthesis chokes on long texts, so each segment is synthesized separately and the MP3 frames are stitched back into one file. Truncation at 1000 chars falls back to the last sentence boundary, never mid-word.

Providers: StepFun (Step Plan) and Qwen (Token Plan), both rendered ~10% faster (1.1× speed) so narration never drags.

The cache contract

Key = provider + voice + normalized text hash. TTL = 600 seconds — a stale entry is a miss and is evicted. Capacity = 64 entries; at the cap the oldest (insertion order) is evicted. Usage is still booked on every request — chars, estimated tokens (~4 chars/token), and true MP3 duration computed by parsing the audio frames (no ffmpeg needed) into each user's ledger.

Try the simulation below: request the same line twice and watch the second one hit.

tts_cache_sim · _TTS_CACHE · max 64 · ttl 600s
$ cache empty — first request will miss
§8

Character Weaving

An adventurer is not generated — it is woven, live, on an SSE stream, from a free-text dream into a finished D&D sheet with inventory, story and stats, ready to preview, save or unravel and re-roll.

PLAYER DREAM"a scarred ranger…" /vault/generate/streamSSE · temp 0.95 · max tokens + high reasoning LLM WEAVESreasoning + content deltas EXTRACT + FINALIZE_extract_json → inventory PREVIEWSave / Reroll reroll → new stream · save → vault (survives campaign deletion)

The weave

The most creative step in any adventure is the adventurer — the human at the table. The engine's job is to be a worthy partner: every DM turn, every character, every face is generated by FULL-SIZE models at maximum token budget and high reasoning — Qwen 3.8 Max with thinking always on (1M context), DeepSeek V4 at high reasoning, StepFun 3.7 Flash at high reasoning with a full 32k budget. No quantized small models, no context-window truncation, no artificial rate limits on reasoning, no token-capped thinking. The price is latency — a few extra seconds while the DM thinks before it speaks. That wait is the product: a world that reasons, not one that guesses.

The request is a single line — "Skapa en karaktär: {prompt}" — against CHARACTER_PROMPT at temperature 0.95 (the most creative setting in the whole engine) with a maximum token budget and high reasoning — the same full-mind policy as the Dungeon Master, because the adventurer is the most creative step of all. The stream pushes two kinds of frames: reasoning (the invisible thoughts) and content (the growing character JSON), then a done frame with tokens used, seconds elapsed and reasoning length.

The raw text is passed through _extract_json, then _finalize_character_data fills hit dice, ability modifiers, starting equipment and inventory. Token usage is booked to a lifetime ledger that survives even character deletion.

The vault

Saved adventurers live in a vault — one per account, not per campaign — so an adventurer survives a campaign's end. Each entry keeps the full sheet, inventory, story and painted avatar. Summoning into a campaign copies the adventurer into that campaign's state; rerolling spawns a fresh stream.

Avatars are painted separately by an image model — either from the player's own words or from an auto-built sheet prompt — and the status line always tells you which source won.

sse_frames · vault_generate_stream
POST /api/vault/generate/stream
{"prompt":"a scarred ranger who hunts the things that hunt villages","lang":"en"}
 
data: {"type":"reasoning","text":"The player wants a ranger with a scar, a hunter… I will build…"}
data: {"type":"content","text":"{\"name\":\"Faelyndra\",\"race\":\"Half-elf\","}
data: {"type":"content","text":"\"class\":\"Ranger\",\"level\":1,\"hp\":{\"max\":12},"}
data: {"type":"content","text":"\"abilities\":{\"DEX\":{\"score\":16,\"mod\":3}},"}
data: {"type":"content","text":"\"story\":\"She carries the scar of the hollow…\"}}"}
 
data: {"type":"done","ok":true,"time_s":31.2,"tokens":{…},
"character":{…},"inventory":[{…longsword…},{…shortbow…}]}
 
# frontend → preview card → [Save to Vault] [Unravel & Reroll]
§9

The State Model

One JSON file per campaign is the single source of truth. The DM reads a compact digest of it every turn; the Guardian mutates it every turn; everything else — characters page, NPC dossiers, map, logbook — is just a projection of it.

state.json — one file per campaign · written under an asyncio lock
meta— identity & ledger
campaign_id · campaign_name · language · opening_style · created · last_updated
dm_model · guardian_model · extraction_model · tts_provider
turn_count · session_count · last_effects[] · last_roll_requests[]
unguarded_tokens {prompt · completion · by_model} — usage that survives resets
tts_usage {calls · api_calls · chars · tokens · seconds}
character— the sheet
name · race · class · level · alignment · background · ac · initiative · perception · speed · proficiency
hp {current · max · temp} · xp {current · next_level} · spell_slots {current · max}
abilities STR·DEX·CON·INT·WIS·CHA → {score, mod}
hit_dice {dice · total · remaining} — spent on short rests · saves[6]
traits[] · spells[] · updates[] (every Guardian character_update, stamped with turn)
story · gear · notes
inventory[] — name · type · category · usage · qty · weight · lore · equipped · rarity · damage · ac_bonus …
currency {pp · gp · sp · cp} — normalized, weighted, displayed
npcs[]— dossier
name · role · relation (allied / neutral / enemy / unknown) · color · icon · notes · alive · near
near is recomputed every turn by Guardian — npcs_near[] — proximity drives the @-mention list
quests[] — id · name · description · reward · xp_reward · gold_reward · status · created_turn
world— the living world
current_location · visited_locations[] · time · weather · day · day_description
logbook[{day, turn, text}] · travel_log[{from, to, day}] · day_summaries[{day, title, events, quests, mood}]
combat— chat-first battle state
active · round · phase · started_turn · ended_turn
enemies[{id, name, hp, max_hp, ac, alive, statuses}] · initiative[] · turn_order[]
player_actions {action · bonus · reaction} — one each per round · log[]
lore[] — durable world memories, written by Guardian world_lore
pinned_facts[] — player-pinned truth, injected into every TRUTH block
locations[] — name · description · terrain · x · y · visited — the map is a projection of this
avatars{} — player · dm · npc:Name → {disk_name, seed, ai_generated, edit_mode}
images[] · resources[]

Concurrency & durability

Every read-modify-write runs under a per-campaign asyncio lock — two turns can never interleave. Writes are journaled as JSONL transcripts (one file per session) plus periodic chapter and scene summaries, so the hierarchy in §3 can be rebuilt even after a crash.

The audit that shaped this page found the classic trap: a background writer appending a list to a field another path had written as a dict — an AttributeError inside a swallowed try/except that silently erased a turn's mechanics. The logbook shape is now guarded on both sides.

One XP table, two consumers

D&D 5e level thresholds live in exactly one place — _XP_THRESHOLDS — and both the legacy tag pipeline and the Guardian's level-up check import it. Guardian pays XP only for kills, quests, milestones and puzzles; quest completion pays its stored reward automatically.

lvl 1 → 2: 300 XPlvl 3 → 4: 2,700 XPlvl 9 → 10: 64,000 XPlvl 19 → 20: 355,000 XP

The invariant

Everything the player sees — the sheet, the dossier, the map, the logbook, the next DM line — is derived from state.json. The DM may never invent what it holds; the Guardian may never forget to update it. That asymmetry is the whole game.