# Lab Report: The UQF Migration Playbook (§ARCH-01) **Document ID:** §ARCH-01 (Phase 3 companion) — a **method** document, not a feature close-out. **Original date:** 2026-06-28 · **Closed:** 2026-07-05, Wave 8c (`647e070`) **Reference build:** `24becb6` (2026-06-28 14:32:20 −0700, Wave 1a) — the tree the §2 survey was taken against. **Status:** ✅ CLOSED. Every `QUEST_DB` entry executes as UQF-1.0 except 30 deliberate stubs. `QuestRuntime` is the sole execution surface; `QUEST_DB` is the single source of truth. **Verified:** 2026-08-17 (§DOC-02bu) against HEAD and against the archive. Findings in §7–§10. --- ## 1. Abstract `QUEST_DB` began as **data with JavaScript stapled to it**: each quest carried its own `activateCond` predicate, `onPass`/`onFail`/`onComplete` closures, and a bespoke `completeFn`. Behaviour lived in ~2,800 hand-written functions with no shared vocabulary, no validator, and no way to ask *"what does this quest do?"* except to read it. §ARCH-01 replaced that with the **Universal Quest Format (UQF-1.0)**: a quest is *pure declarative data* — `schema` · `gate` · `bits` · `completion` — executed by a host VM (`QuestRuntime`). Logic moved out of the content and into an opcode table (`BIT_CONTRACTS`). The migration ran in eight waves over eight days, hand-migrating the 79 bespoke arcs and bulk-transforming the remaining ~2,340 through a deterministic codemod verified against a pre-migration golden capture. This document is the **playbook** — the repeatable procedure, the grammar it produced, and the per-wave ship record. Its central claim is procedural: *a migration should widen the grammar, never special-case the content.* Fifty days of subsequent engineering have not needed a single exception. --- ## 2. Intention, inspiration, and what the player actually gets ### 2.1 The design intention The inspiration is the **script/host split** familiar from game engines and from the JVM: content is script, the engine is the host, and capability grows **through the grammar** (a new opcode, a new gate term) rather than through a new one-off branch per quest. The design goals, in the order the report argued them: 1. **One execution surface.** A quest resolves in exactly one place, so a fix reaches all 2,853. 2. **Declarative gates.** *When does this list?* becomes readable data, not an opaque predicate. 3. **Validatable content.** `validateQuest` can refuse a malformed quest; a closure cannot be checked. 4. **Authorable content.** If quests are data, the worldbuilder can author them — realised in Wave 8b. 5. **Generalise, never special-case.** Every gap becomes a reusable primitive. The whole grammar in §6 was produced by twenty-odd quests each demanding one thing the engine could not yet say. ### 2.2 What it did for playability — the load-bearing number The migration is usually described as a refactor. It was not. **Recon in Waves 4 and 5 found that 177 quests could not be played at all**, and the archive confirms it exactly: | Legacy type | Count at `24becb6` | Had any completion surface | Verdict | |---|---|---|---| | `combat` | 78 | 0 | dead | | `delivery` | 57 | 0 | dead | | `escort` | 22 | 0 | dead | | `hybrid` | 13 | 0 | dead | | `dialogue` | 7 | 0 | dead | | `main` (`mq_1`–`7`) | 7 | **7** (`completeItems`) | alive | The cause is one line in the legacy resolver — `if (q.type !== 'skill_check') return;` — and no other code accepted those types. They had no `completeFn`, no `completeItems`, no `waypointNode`. **A player who accepted one of them could never finish it**, and because downstream acts gated on their pass-flags, *every arc containing one stalled at that act.* Waves 4–6 turned 177 inert entries into playable content and repaired three dead gates in the Emmer arc as a rider. Three further fixes reached the whole game: - **§SKILLFIX-01 (`662ee99`)** — the legacy resolver read only `q.checkAbility`, but **2,443 quests store the ability in `checkStat`**. Those checks rolled `d20 + 0 + proficiency` and printed the ability name as **"undefined"**. Your Wisdom score was decorative on the overwhelming majority of the game's skill checks. Four read sites were aliased; the fix is verbatim at HEAD. - **§SKILLFIX-02** — ~176 more quests stored a *skill name* (`Persuasion`, `History`, `Insight`) in the stat field, hitting the same `+0` path. The migrator maps skill → governing ability (D&D; homebrew Courage/Presence → CHA) and keeps the name for display and proficiency. - **Wave 2c** — three `ath_*` acts carried a duplicate `activateCond`, the real function *plus* a dead string copy. Last-key-wins made the parsed value a string, so `q.activateCond()` **threw a TypeError on arrival at those nodes**. The migration fixed a live crash. Wave 4 found and removed three more; at HEAD `activateCond:"` occurs **0 times**. ### 2.3 What it did for the author Wave 8b carried the grammar into the worldbuilder: the Mission Builder compiles arcs to UQF, the ✏ Editor authors outcome **bits** instead of JavaScript textareas, and `serializeQuestLiteral` learned to write `schema`/`gate`/`bits`/`completion` — which it had been **silently dropping**, so every UQF quest posted through the API had been arriving stripped dead. A grammar the tools can speak is the difference between a format and a filing convention. --- ## 3. Diagnosis at the reference build The 2026-06-28 survey, re-measured at `24becb6` by two independent methods (the `wbapi-core` parser and a standalone brace-depth walk, which agree exactly): | Figure | Report | Measured at `24becb6` | Verdict | |---|---|---|---| | Total `QUEST_DB` entries | 2,515 | **2,839** | ✘ short by 324 | | Already UQF (`§WISDOM-01` ×8 + Wane ×6) | 14 | **14** | ✔ exact, ids match | | `skill_check` | 2,192 | **2,482** | ✘ | | `side` | 129 | **133** | ✘ | | `combat` | 71 | **78** | ✘ (the Wave-4 "drift" was an undercount on day one) | | `delivery` / `hybrid` | 38 / 9 | **57 / 13** | ✘ (likewise recounted in Wave 5) | | `epic` / `escort` / `main` / `dialogue` | 40 / 22 / 7 / 7 | **40 / 22 / 7 / 7** | ✔ | | skill_checks with an `onPass` closure | 78 | **79** | ≈ off by one | | entries with a `completeFn` closure | 163 | **163** | ✔ **exact** | | `checkStat` carriers (§SKILLFIX-01) | 2,443 | **2,443** | ✔ **exact** | | `checkAbility` carriers | ~30 | **83** | ✘ (inherited from `662ee99`'s own comment) | > **The report distrusted its first count, replaced it with a brace-walker, and printed > *"Treat the originals as void."* The replacement is wrong by 324.** The instinct was right; the > second instrument was not better than the first. Note precisely *which* figures survived: every > exact number is one that could be taken by grepping a single field (`completeFn` 163, > `checkStat` 2,443, the 14 UQF ids). Every wrong one required walking the whole corpus. **A count > you can copy is evidence; a count you must derive is a claim.** The *implication* the survey drew was nonetheless correct and is the document's best judgement call: at either figure, Wave 2 is not sixty hand-migrations but ~2,340 near-identical ones, so it must be a programmatic transform. Being wrong about the magnitude did not change the decision. --- ## 4. Method — the playbook ### 4.1 Recon (per arc, once) 1. `grep -n "quest__" play.html` — enumerate the arc. 2. Read each quest's **full** legacy object; capture the verbatim long strings. 3. **Grep for external consumers** of every field you intend to drop (`\.checkStat|\.checkPassFlag|\.onPass|\.completeFn`). Confirm they occur only inside `_rollCeremonia` / the `storyCheckQuests` loop, both schema-guarded. A field read by a `storyRender` block stays. ### 4.2 Transform (per quest) 4. **`activateCond` → `gate`.** `()=>!!A` → `{flags:['A']}` · `A && B` → `{flags:['A','B']}` · `A || B` → `{flagsAny:[…]}` · negation → `notFlags` · visited-node → `nodes`. 5. **Pick the mechanic.** Skill check → a `skill_check` bit (`stat` UPPERCASE, `skill`, `dc`). Passive/side → a declarative `completion` gate, `bits:[]`. 6. **`onPass` closure → an ordered bit chain**, statement by statement: `checkPassFlag` → `{kind:'mission_bit', flag}` — **not** a bare `flag_write`, which sets the flag and drops the inventory token · extra `S_story.x = true` → `flag_write` · gold/xp/knowledge/item pushes → one `reward` · `storyMsg(text)` → `narrative`, pasted verbatim. 7. **`onFail` closure → `[{kind:'narrative', msg}]`.** 8. **`completeFn` → a `completion` gate** (`flags` AND-group; `flagsAny`/`battles` OR-group). 9. **Preserve display fields byte-for-byte**: `title, desc, hint, disposition, passText, failText, waypointNode, npc, retryable`; keep `type` for the badge. > **Fail flags do not migrate themselves.** The legacy non-retryable fail path granted > `checkFailFlag` through `_grantMissionBit`; `_resolveQuestUQF` does not. A quest with a > `checkFailFlag` needs an explicit `onFail:[{kind:'mission_bit', …}]`. Safe because such quests are > non-retryable, so the chain runs exactly once. `retryGateDays` needs no migration — the retry > helpers read it directly and are schema-agnostic. ### 4.3 Generalise, never special-case 10. A missing mechanic becomes a **reusable** gate term or bit kind with a `BIT_CONTRACTS` entry — never a quest-specific branch. Every term in §6 was born this way and every one immediately served several quests. 11. **`_legacy_fn` is the sanctioned escape hatch**, not a failure. An imperative shared helper (`_addCroneMark()`, `_innKindness(1)`) rides as `{kind:'_legacy_fn', fn}` — byte-identical behaviour, and the quest still moves onto the `schema+gate+bits` shell. Do not invent a single-use bit kind for one helper. **Order matters:** emit `reward` before `_legacy_fn`, to mirror the legacy `xpAward`-then-`onPass()` sequence. ### 4.4 The codemod recipe (validated Wave 1g, industrialised Wave 2) A one-shot script with an explicit per-quest `[oldStructural, newStructural]` spec, applied **within each quest's brace-delimited block** — so each old-string need only be unique inside its own block, not the whole 5 MB file. Touch **only structural fragments**; never the narrative prose, whose apostrophes and quotes make escaping brittle. Recompute the section bound after each splice. Then: vm-parse, structurally assert every target, run the suites, and **delete the one-shot script** — *the transform lives in git and in this report.* Wave 2 promoted this to `src/scripts/uqf-bulk-migrate.js`, which survives at HEAD. It is safe-by-construction: it never re-serialises a narrative string; it deletes only scalar legacy fields; it decomposes the trivial `()=>!!S_story.` gate and keeps any other `activateCond` verbatim behind `gate:{_legacyFn:true}`; and it **throws** if an `activateCond` survives a decompose. Two hardenings from Wave 2c are still verbatim in `src/scripts/uqf-bulk-migrate.js:function trivialGateFlag(body) {@116`: the lookahead widened to `(?=[,}])` so a trivial gate that is the literal's **last** field is not missed, and the optional `"?` that strips the dead string-form duplicate. ### 4.5 Verification protocol Three protocols, chosen by what the legacy path actually did: - **Pure parity** (most families) — capture a **pre-migration golden** (legacy resolution + verbatim display fields), re-run post-migration, assert byte-for-byte. Where the legacy `checkStat` was UPPERCASE, seed the golden under **both** stat cases so a deterministic extreme drives either resolver. - **Display + mapping** (§SKILLFIX-02 families) — the roll deliberately changes, so assert (a) display untouched, (b) structure, (c) `SKILL_TO_ABILITY[checkStat] === stat` with the skill preserved and the DC unchanged, (d) the new behaviour is deterministic. **Never** against the buggy `+0` roll. - **Structure + new behaviour** (Waves 4–5) — there was no legacy behaviour to mirror; the resolver was unreachable. **Five test shapes per migrated quest or arc:** validates as UQF · activation gate (unmet ⇒ no activate, met ⇒ `'active'`) · PASS parity · FAIL behaviour (non-retryable ⇒ `'failed'` and grants nothing; retryable ⇒ stays `'active'`, logs an attempt) · completion truth-table plus a real `storyCheckQuests` `'active'`→`'complete'` flip. **Techniques worth keeping.** Force an outcome by **ability score**, not by a merely-low one: `{wis:40}` always passes, `{wis:-100}` (mod −55) always fails, but `{wis:1}` (mod −5) still clears a low DC on a d20 of 16–20 and flakes ~25 % — Wave 1a shipped exactly that bug and 1b caught it. Suppress level-up noise with `S_story.level = 20` and assert **deltas**. Drive the real entry points (`_rollCeremonia`, `storyCheckQuests`), not just the VM. And **assert on the rendered container, not `document.body`** — the body includes the inline `