Skip to content

Commit f0aba67

Browse files
committed
Add declarative quest battery (ADR 0007)
- New src/state/Quests.js module: registerQuests, questStatus, questWhatsNext, questWhatsNextNode, questProgress, resolveQuestAccessor, clearQuests - Virtual quest namespace in condition evaluator (quest.<id>.<accessor>) - Export quest API from mod.js and mod.d.ts - Document quest/step terminology in CONTEXT.md - New ADR 0007-declarative-quest-battery.md
1 parent f0f50f5 commit f0aba67

7 files changed

Lines changed: 696 additions & 0 deletions

File tree

CONTEXT.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ Extract and formalize domain terminology from the codebase.
4040
| **WeatherLayer** | Overlays procedural rain/snow (Graphics lines) and/or falling leaves (sprites) | `this.weather` on AdventureScene |
4141
| **NightLayer** | Evening/night lighting overlay: tint, lit windows, moon | Per-scene optional |
4242
| **CritterHelper** | Static utility for spawning decorative ambient critters (butterfly, bird, ground) | Uses `summer-atlas` by default |
43+
| **Quest** | A composite tree of Steps the game registers at boot; the engine derives `done`/`whatsNext` by evaluating each node's `doneWhen` **Condition** over the Store. No stored status, no player-facing log | Registered via `quests` registry; evaluator peer of `PropEngine` |
44+
| **Step** | A node in a Quest — a Quest nested in a Quest. Leaf carries a `doneWhen`; composite carries ordered `steps` (hand-authored or generated from a runtime target list). `whatsNext` = DFS to first incomplete leaf | One node type, no `flag`/`collection` taxonomy |
45+
| **Quest path namespace** | Virtual `quest.<id>.<accessor>` keys the Condition evaluator resolves at eval time (not stored). `<accessor>``{ status, whatsNext, progress }` or a `<step_name>` that descends. `status``not_started``seen``started``done` (derived cascade); `progress` = count of done leaves. Same namespace for `when` and imperative reads | Step names can't shadow `status`/`whatsNext`/`progress` (boot guard) |
4346
| **DialogueBubble** | A cloud-bubble Container with optional icons + text, auto-destroying | Supports `thought` and `speech` variants |
4447
| **ContentRegistry** | Engine registry mapping inventory item IDs to atlas/frame/scale specs | `content.registerItems()` / `content.getItem()` |
4548
| **CharacterRegistry** | Engine registry mapping character IDs to render configs (sprite, animations, outfits) | `characters.register()` / `characters.resolve()` / `characters.render()` |
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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.

mod.d.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4106,6 +4106,131 @@ export type RunState = {
41064106
collections: Record<string, Set<string>>;
41074107
items: Record<string, string>;
41084108
};
4109+
/**
4110+
* Register quest definitions (merges with any already registered). Throws if a
4111+
* step id shadows a reserved accessor.
4112+
* @param {Record<string, QuestNode>} entries
4113+
*/
4114+
export function registerQuests(entries: Record<string, QuestNode>): Record<string, QuestNode>;
4115+
/**
4116+
* Resolve a `quest.<id>.<...>.<accessor>` path against the registry. Each
4117+
* non-reserved segment descends into the child node with that id; a reserved
4118+
* segment (which must be terminal) yields the derived value. A path that ends
4119+
* on a node defaults to its `status`.
4120+
* @param {string} path
4121+
* @returns {string | number | null | undefined}
4122+
*/
4123+
export function resolveQuestAccessor(path: string): string | number | null | undefined;
4124+
/** @param {string} id @returns {"not_started"|"seen"|"started"|"done"|undefined} */
4125+
export function questStatus(id: string): "not_started" | "seen" | "started" | "done" | undefined;
4126+
/** @param {string} id @returns {string | null | undefined} first outstanding unit id. */
4127+
export function questWhatsNext(id: string): string | null | undefined;
4128+
/** @param {string} id @returns {QuestNode | null | undefined} the outstanding leaf node (carries its optional `icon`). */
4129+
export function questWhatsNextNode(id: string): QuestNode | null | undefined;
4130+
/** @param {string} id @returns {{ done: number, total: number } | undefined} */
4131+
export function questProgress(id: string): {
4132+
done: number;
4133+
total: number;
4134+
} | undefined;
4135+
/** Reset the registry (tests). */
4136+
export function clearQuests(): void;
4137+
/**
4138+
* Engine — declarative Quest battery (ADR 0007).
4139+
*
4140+
* A Quest is a composite tree of nodes; a Step *is* a Quest nested in a Quest.
4141+
* Status (`not_started`→`seen`→`started`→`done`), `whatsNext` and `progress`
4142+
* are DERIVED on read by evaluating each node's Condition against the Store —
4143+
* nothing is stored, so quest state cannot desync from the world.
4144+
*
4145+
* The Game registers a catalog at boot ({@link registerQuests}); the Engine
4146+
* never hardcodes a quest. State is read through the `quest.<id>.<accessor>`
4147+
* virtual namespace the Condition evaluator resolves (see conditions.js), so a
4148+
* `when` reads quest state with the same DSL — and the same names — that
4149+
* imperative code uses.
4150+
*
4151+
* Node shapes (one type, no taxonomy):
4152+
* - leaf: `{ id, doneWhen: Condition, icon? }`
4153+
* - composite: `{ id, steps: Node[], icon? }`
4154+
* - collection: `{ id, collect: { items, in, selfCombineInto? }, icon? }`
4155+
* `items` is a list of ids (or nested nodes), or a `() => list` for
4156+
* runtime-rolled sets; each generated leaf is done when its id is in the
4157+
* `in` collection (or already moved into `selfCombineInto`).
4158+
* A root may also carry `seenWhen` / `startWhen` Conditions (see {@link statusOf}).
4159+
*
4160+
* @typedef {Record<string, any>} Condition
4161+
* @typedef {{
4162+
* id?: string,
4163+
* doneWhen?: Condition,
4164+
* steps?: QuestNode[],
4165+
* collect?: { items: Array<string | QuestNode> | (() => Array<string | QuestNode>), in: string, selfCombineInto?: string },
4166+
* seenWhen?: Condition,
4167+
* startWhen?: Condition,
4168+
* icon?: any,
4169+
* _collect?: { in: string, selfCombineInto?: string },
4170+
* }} QuestNode
4171+
*/
4172+
/** Accessor names that may not be used as step ids (they'd shadow the namespace). */
4173+
export const RESERVED_ACCESSORS: readonly [
4174+
"status",
4175+
"whatsNext",
4176+
"progress"
4177+
];
4178+
/**
4179+
* The live quest registry — a plain id → QuestNode map the Game populates at
4180+
* boot. Read by reference, so late registration before the first read is fine.
4181+
* @type {Record<string, QuestNode>}
4182+
*/
4183+
export const questRegistry: Record<string, QuestNode>;
4184+
export namespace quests {
4185+
export { registerQuests as registerQuests };
4186+
export { questStatus as questStatus };
4187+
export { questWhatsNext as questWhatsNext };
4188+
export { questWhatsNextNode as questWhatsNextNode };
4189+
export { questProgress as questProgress };
4190+
export { resolveQuestAccessor as resolveQuestAccessor };
4191+
export { questRegistry as questRegistry };
4192+
}
4193+
type Condition$1 = Record<string, any>;
4194+
/**
4195+
* Engine — declarative Quest battery (ADR 0007).
4196+
*
4197+
* A Quest is a composite tree of nodes; a Step *is* a Quest nested in a Quest.
4198+
* Status (`not_started`→`seen`→`started`→`done`), `whatsNext` and `progress`
4199+
* are DERIVED on read by evaluating each node's Condition against the Store —
4200+
* nothing is stored, so quest state cannot desync from the world.
4201+
*
4202+
* The Game registers a catalog at boot ({@link registerQuests}); the Engine
4203+
* never hardcodes a quest. State is read through the `quest.<id>.<accessor>`
4204+
* virtual namespace the Condition evaluator resolves (see conditions.js), so a
4205+
* `when` reads quest state with the same DSL — and the same names — that
4206+
* imperative code uses.
4207+
*
4208+
* Node shapes (one type, no taxonomy):
4209+
* - leaf: `{ id, doneWhen: Condition, icon? }`
4210+
* - composite: `{ id, steps: Node[], icon? }`
4211+
* - collection: `{ id, collect: { items, in, selfCombineInto? }, icon? }`
4212+
* `items` is a list of ids (or nested nodes), or a `() => list` for
4213+
* runtime-rolled sets; each generated leaf is done when its id is in the
4214+
* `in` collection (or already moved into `selfCombineInto`).
4215+
* A root may also carry `seenWhen` / `startWhen` Conditions (see {@link statusOf}).
4216+
*/
4217+
export type QuestNode = {
4218+
id?: string;
4219+
doneWhen?: Condition$1;
4220+
steps?: QuestNode[];
4221+
collect?: {
4222+
items: Array<string | QuestNode> | (() => Array<string | QuestNode>);
4223+
in: string;
4224+
selfCombineInto?: string;
4225+
};
4226+
seenWhen?: Condition$1;
4227+
startWhen?: Condition$1;
4228+
icon?: any;
4229+
_collect?: {
4230+
in: string;
4231+
selfCombineInto?: string;
4232+
};
4233+
};
41094234
/**
41104235
* Fade the camera out, then start `targetKey`.
41114236
*

mod.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ export { NightLayer } from "./src/environment/NightLayer.js";
2121
export { exitApproaches, PropEngine } from "./src/interaction/PropEngine.js";
2222
export { SceneEditor } from "./src/ui/SceneEditor.js";
2323
export { Store, store } from "./src/state/Store.js";
24+
export {
25+
clearQuests,
26+
questProgress,
27+
questRegistry,
28+
quests,
29+
questStatus,
30+
questWhatsNext,
31+
questWhatsNextNode,
32+
registerQuests,
33+
RESERVED_ACCESSORS,
34+
resolveQuestAccessor,
35+
} from "./src/state/Quests.js";
2436
export { SubsceneStack } from "./src/scene/SubsceneStack.js";
2537
export { showSuccessMessage } from "./src/cutscene/SuccessMessage.js";
2638
export { DialogueBubble } from "./src/cutscene/DialogueBubble.js";

src/core/conditions.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { store } from "../state/Store.js";
2+
import { resolveQuestAccessor } from "../state/Quests.js";
23

34
/**
45
* Declarative condition evaluator for the prop framework (see
@@ -77,6 +78,16 @@ function evaluateLeaf(key, constraint, ctx) {
7778
return ctx.draggedId === constraint;
7879
}
7980

81+
// Virtual quest namespace: `quest.<id>.<accessor>` resolved by the Quest
82+
// battery (ADR 0007). Same scalar sugar as a value key.
83+
if (key.startsWith("quest.")) {
84+
const actual = resolveQuestAccessor(key);
85+
if (constraint !== null && typeof constraint === "object") {
86+
return evaluateOps(actual, constraint);
87+
}
88+
return actual === constraint; // bare value = eq
89+
}
90+
8091
if (store.isCollection(key)) {
8192
return evaluateCollection(key, constraint, ctx);
8293
}

0 commit comments

Comments
 (0)