|
| 1 | +# 0007 — Declarative Quest battery |
| 2 | + |
| 3 | +Status: **accepted** Date: 2026-06-04 |
| 4 | + |
| 5 | +## Decision |
| 6 | + |
| 7 | +The Engine provides a **Quest battery** — a generic, game-agnostic system for declaring task structures and querying |
| 8 | +their progress — as a peer to the Prop framework (ADR 0002) and the declarative NPC cast (ADR 0004). It has four parts: |
| 9 | + |
| 10 | +1. **A single recursive node type.** A Quest is a composite tree. Each node is one shape: |
| 11 | + - **leaf** — `{ id, doneWhen: Condition }` |
| 12 | + - **composite** — `{ id, steps: Node[] }` |
| 13 | + - **collection** — `{ id, collect: { items, in, selfCombineInto? } }` — generates one leaf per item (each `doneWhen` |
| 14 | + = `has(in, item)`); `items` may be a function so the set can be rolled at runtime. |
| 15 | + |
| 16 | + A node is **done** when, if it has steps, all steps are done; otherwise its `doneWhen` passes. A Step _is_ a Quest — |
| 17 | + there is no separate "sub-quest" type and no `flag`/`collection` taxonomy at the type level. Any node may carry an |
| 18 | + **optional `icon`** (a hint for the Thought-Bubble nudge or a future quest log), shown when present and hidden when |
| 19 | + absent — so the unit→icon mapping lives on the node, not in a separate Game table. |
| 20 | + |
| 21 | +2. **A `quests` registry** the Game populates at boot (`quests.register(def)`), exactly like `content`, `castRegistry`, |
| 22 | + and `wearables`. Definitions are plain declarative data; the Engine never hardcodes a quest. |
| 23 | + |
| 24 | +3. **A derived evaluator.** `status` and `whatsNext` are **computed on read** from the Store via the **Condition** DSL — |
| 25 | + never stored. `status` resolves by a priority cascade: |
| 26 | + |
| 27 | + ``` |
| 28 | + done if all leaves done |
| 29 | + started else if startWhen passes (default: any leaf done) |
| 30 | + seen else if seenWhen passes (optional; typically an intro-seen fact) |
| 31 | + not_started otherwise |
| 32 | + ``` |
| 33 | + |
| 34 | + `whatsNext` is a depth-first walk to the first incomplete leaf (the concrete unit still outstanding: a toy id, an |
| 35 | + ingredient id), or `null` once done. `progress` is the count of done leaves in the subtree (a scalar; `total` is |
| 36 | + known from the definition, so percent is derivable). |
| 37 | + |
| 38 | +4. **A virtual path namespace** in the Condition evaluator. State is read as `quest.<id>.<accessor>`, where `<accessor>` |
| 39 | + is one of three reserved keys (`status`, `whatsNext`, `progress`) or a `<step_name>` that descends into that child |
| 40 | + and reopens the same accessors. The **same namespace serves both `when` blocks and imperative reads** — there is no |
| 41 | + second API for querying quests. The evaluator recognizes the `quest.` prefix and routes to the Quest resolver instead |
| 42 | + of the flat Store. A `when` therefore reads quest state with plain, serializable `eq`: |
| 43 | + |
| 44 | + ```js |
| 45 | + when: { "quest.backpack.status": { eq: "started" } } |
| 46 | + when: { "quest.backpack.fill.pencil_case.whatsNext": { eq: "ruler" } } |
| 47 | + ``` |
| 48 | + |
| 49 | + Step ids may not shadow the reserved accessors `status` / `whatsNext` / `progress`; registration throws if they do. |
| 50 | + |
| 51 | +The Game owns only the **catalog** of definitions (including each node's optional `icon`). The Engine owns the node |
| 52 | +type, registry, evaluator, and namespace. |
| 53 | + |
| 54 | +## Context |
| 55 | + |
| 56 | +State across an adventure game is a sprawl of booleans, and the same composite predicate gets hand-re-derived wherever |
| 57 | +it is needed. In the reference game, the two kitchen-doorway hotspots each inlined a character-for-character copy of the |
| 58 | +`isBreakfastInProgress` predicate in raw DSL keys; the "what is the next thing to do" question was answered by ~6 |
| 59 | +bespoke cascades (`getMissingToys`, `getPancakeIngredientsMissing`, `getOutOfBoundsBlockers`, |
| 60 | +`getNextBackpackQuestStepIcon`, `getBreakfastReminderIcons`, `getEndGameBlocker`); and a pile of intro-seen flags |
| 61 | +(`summerIntroSeen`, `mamaBackpackIntroSeen`, …) tracked lifecycle by hand. Changing one rule meant editing every copy in |
| 62 | +lockstep; missing one shipped a regression. |
| 63 | + |
| 64 | +A survey of how the genre handles this confirmed the failure mode rather than offering an escape: |
| 65 | + |
| 66 | +- **Adventure Game Studio** uses one global `int` per quest (`0`/`1`/`-1`), advanced by hand — the same boolean sprawl |
| 67 | + with a sanctioned name. |
| 68 | +- **Bethesda (Skyrim/Fallout)** uses stored stage integers (`SetStage`) plus objective flags, advanced by quest scripts. |
| 69 | + Powerful, but the canonical source of "permanently broken quest" bugs — a script path that forgets to advance a stage. |
| 70 | + This is precisely our regression class at AAA scale, and the argument against any _stored_ status. |
| 71 | +- **Unity** best practice is ScriptableObject-based quests: author as data at edit time, evaluate at runtime. The one |
| 72 | + durable approach, and structurally identical to this battery. |
| 73 | +- **Phaser** offers only the registry/DataManager — a key-value store (our `Store`); no quest concept. |
| 74 | + |
| 75 | +The Engine already had every ingredient: a pure, serializable **Condition** DSL (ADR 0002) shared by props, cast, and |
| 76 | +wearables; the declarative-registry idiom; and a reactive `Store`. A Quest's "is it done" check is structurally |
| 77 | +identical to a Prop's `when`. So this battery formalizes a pattern the Engine was already shaped for, rather than |
| 78 | +introducing a foreign mechanism. |
| 79 | + |
| 80 | +## Consequences |
| 81 | + |
| 82 | +- **Status cannot desync.** Because `status`/`whatsNext` are derived from existing facts on every read, there is no |
| 83 | + stored quest field to fall out of agreement with the world — structurally avoiding the Bethesda/AGS failure mode. |
| 84 | +- **One `whatsNext`, not six cascades.** Every "what's the next step" question becomes a single DFS over a declared |
| 85 | + tree. Drilling (which quest → which step → which item) falls out of the recursion. |
| 86 | +- **`when` blocks reference quests, not raw keys.** Props, exits, cast reactions, and gates read `quest.<id>.<accessor>` |
| 87 | + instead of re-spelling composite conditions. One definition, many readers. |
| 88 | +- **The Condition evaluator gains a virtual-namespace seam.** It must route prefixed keys to a resolver. This is general |
| 89 | + (future namespaces can reuse it) but it is now load-bearing for props/cast/wearables, which makes the seam **hard to |
| 90 | + reverse** — backing it out means re-inlining conditions across every consumer. |
| 91 | +- **Facts must be monotonic.** Lifecycle stages derived from transient signals (e.g. "an item is on-screen now") will |
| 92 | + flicker/regress. Transients must be latched into a stored monotonic fact that the Quest reads. The derived-status |
| 93 | + guarantee holds only over a monotonic fact substrate. |
| 94 | +- **Reserved accessors constrain step names.** `status` and `whatsNext` are reserved; a boot guard enforces it. |
| 95 | +- **Another engine surface to maintain,** and the Engine is now committed to the Quest concept fairly permanently. |
| 96 | + |
| 97 | +## Considered alternatives |
| 98 | + |
| 99 | +- **Stored status machine (AGS int / Bethesda stage).** Rejected: reintroduces the desync/regression class that |
| 100 | + motivated the work; adds an (N+1)th field that must agree with N others. |
| 101 | +- **Materialized derived Store keys** (recompute status into reserved keys so plain `eq` works). Rejected: a cache that |
| 102 | + exists only to be read by conditions, and still "stored" in spirit; the virtual namespace gets the same `eq` |
| 103 | + ergonomics with nothing materialized. |
| 104 | +- **A dedicated `quest` Condition op.** Rejected: hard-couples the Engine's most shared primitive (the Condition DSL) to |
| 105 | + one concept; the virtual path namespace needs no new op. |
| 106 | +- **Game-only quests (no Engine battery).** Rejected: the evaluator is pure tree-traversal + Condition eval with zero |
| 107 | + game knowledge — the textbook definition of an Engine battery under ADR 0005 — and every title in this genre needs it. |
| 108 | + Keeping it game-side would trap reusable code and duplicate the Engine's existing evaluation path. |
| 109 | +- **Flat step list with a closed `flag`/`collection` taxonomy.** Rejected: real data nests three levels |
| 110 | + (`backpack → fill → pencil_case → parts`) and target sets are sometimes runtime-rolled; a uniform recursive node with |
| 111 | + generated children covers all of it with one `whatsNext` traversal. |
| 112 | +- **Distributed, entity-owned state** (each object tracks itself; no central quest). Rejected for the "what's next / is |
| 113 | + this flow done" question specifically: it needs an ordered view _across_ entities, which a uniform Quest tree provides |
| 114 | + and scattered self-state does not. |
0 commit comments