Quests
@yagejs-addons/quests is a headless quest log: a QuestLog model that owns
quest phases (active/completed/failed), per-objective progress, prerequisite
chains, and auto-complete rollup — the logic every RPG rewrites. It is pure
@yagejs/core: no renderer, no dialogue or inventory dependency, no
presenters. A journal or tracker HUD reads the model directly; the addon just
tracks state and emits consequences.
Install
Section titled “Install”npm install @yagejs-addons/questsnpm install @yagejs/core@yagejs/core is the only peer. There is no ./presenters subpath — the
whole package is headless.
Define quests, start one
Section titled “Define quests, start one”Quest ids come from the map keys; each quest’s objective ids come from its own
nested objectives map — so log.advance("gatherHerbs", "herb") type-checks
and log.advance("gatherHerbs", "wolf") doesn’t, with no type argument written
anywhere.
import { defineQuests, QuestLog } from "@yagejs-addons/quests";
const quests = defineQuests({ gatherHerbs: { title: "Gather Herbs", summary: "The healer needs red herbs.", objectives: { herb: { title: "Collect red herbs", count: 5 }, // target 5 turnIn: { title: "Return to the healer" }, // count omitted -> target 1 }, }, thinThePack: { title: "Thin the Pack", requires: ["gatherHerbs"], // locked until gatherHerbs is completed objectives: { wolf: { title: "Slay wolves", count: 3 } }, },});
const log = new QuestLog(quests);log.start("gatherHerbs"); // { ok: true } — prereqs met (none)Bind objectives to other addons — no addon dependency
Section titled “Bind objectives to other addons — no addon dependency”Quests declares no dependency on dialogue, inventory, or any other addon.
Instead, the game subscribes to whatever events it likes and calls
advance/complete — a one-line adapter per binding, with no active-state
guard needed: the log silently ignores progress calls on a quest that isn’t
currently active, so firing unconditionally on every pickup/kill is correct.
import { InventoryItemAddedEvent } from "@yagejs-addons/inventory";
// Inventory pickup -> advance the collect objective.player.on(InventoryItemAddedEvent, (e) => { if (e.itemId === "redHerb") log.advance("gatherHerbs", "herb", e.quantity);});
// Dialogue command -> complete the turn-in objective (the healer's script// runs `[turnIn/]` via a command handler injected at play() time).dialogue.play(healerScript, { commands: { turnIn: () => log.complete("gatherHerbs", "turnIn") },});
// The game's own entity event -> advance wolf kills.wolf.on(WolfDiedEvent, () => log.advance("thinThePack", "wolf"));React to consequences
Section titled “React to consequences”log.on("questCompleted", ({ questId }) => { hud.toast(`Quest complete: ${quests.get(questId).title}`); if (questId === "gatherHerbs") log.start("thinThePack"); // chain, one line});
log.on("objectiveProgressChanged", ({ questId, objectiveId, progress, count }) => { tracker.set(objectiveId, `${progress}/${count}`); // "3/5"});Model events: questStarted, objectiveProgressChanged { progress, count, done },
objectiveCompleted, questCompleted, questFailed, and a coarse changed
after every mutating call.
Reading state (what a journal/tracker needs)
Section titled “Reading state (what a journal/tracker needs)”log.status("thinThePack"); // "locked" | "available" | "active" | "completed" | "failed"log.available(); // QuestId[] — startable now (prereqs met, not started)log.active(); // QuestId[]log.progress("gatherHerbs", "herb"); // 3log.objectiveDone("gatherHerbs", "herb"); // falselog.canComplete("gatherHerbs"); // falselog.get("gatherHerbs"); // full QuestState { status, objectives: {...counts} }status() returns the stored phase (active/completed/failed) for a started
quest. A never-started quest is available once every quest in its requires
is completed, and locked until then.
Prerequisites
Section titled “Prerequisites”requires is a flat list of quest ids that must be completed before a quest
unlocks — no nested graph object. Forward references (naming a later-declared
quest) are fine; naming a quest absent from the whole defineQuests call throws
at definition time. A prerequisite cycle leaves its
quests locked forever (no crash) — each waits on another that never completes.
fail is terminal, so a failed quest never reaches completed again — any
quest that requires it stays locked permanently.
Auto-complete rollup
Section titled “Auto-complete rollup”A quest completes automatically the moment every non-optional objective
reaches its target — no explicit “finish quest” call needed for the common
case. optional objectives never gate completion:
defineQuests({ q: { title: "Explore the Ruins", objectives: { artifact: { title: "Recover the artifact" }, // required secretDoor: { title: "Find the secret door", optional: true }, }, },});// Completing "artifact" alone completes the quest; "secretDoor" can stay// untouched.Set autoComplete: false when the game must explicitly turn in a quest.
completeQuest(quest) then completes the quest only while it is active and
every required objective is currently done. forceCompleteQuest(quest) marks
every objective done and completes the quest regardless of prerequisites or
progress; use it for scripts, cheats, and tests.
Current-state objectives
Section titled “Current-state objectives”Use setProgress() when an objective represents current state rather than a
cumulative total. The quest must use autoComplete: false so reaching the
target does not make completion permanent before the player turns it in.
const quests = defineQuests({ bringWood: { title: "Bring Robin 10 wood", autoComplete: false, objectives: { wood: { title: "Have 10 wood", count: 10 } }, },});
const syncWood = () => log.setProgress("bringWood", "wood", inventory.count("wood"));
inventory.on("itemAdded", syncWood);inventory.on("itemRemoved", syncWood);log.start("bringWood");syncWood(); // Include wood already held when the quest starts.If the player reaches 10 wood and then drops to 9, the objective becomes incomplete again. The NPC can finish the quest only while the requirement is satisfied:
if (log.canComplete("bringWood")) { log.completeQuest("bringWood");}Use advance() instead for an “obtain 10 wood” objective whose progress must
only increase.
Optional engine-bus mirror
Section titled “Optional engine-bus mirror”For a HUD or achievements system that shouldn’t hold a log reference
directly, mount a QuestController on any persistent entity — it re-emits the
model’s six events onto the engine bus (entity → scene):
import { QuestController, QuestCompletedEvent } from "@yagejs-addons/quests";
player.add(new QuestController({ log }));scene.on(QuestCompletedEvent, ({ questId }) => achievements.check(questId));The controller is entirely optional — log.on(...) reaches the same
consequences with no component at all.
Disabling QuestController, or deactivating its entity, stops this event
mirror. The standalone QuestLog remains live. Model events that occur while
the controller is dormant are not replayed when it enables.
snapshot() / restore() round-trip the whole log as plain JSON. Wire it to
@yagejs/save as a snapshot extra (no dependency needed):
snapshotService.registerSnapshotExtra("quests", { serialize: () => log.snapshot(), restore: (data) => log.restore(data),});restore drops quest ids the current catalog no longer declares and objective
ids no longer declared within a restored quest, clamping surviving counts to
the current target. A quest never started stays absent from the snapshot; its
status re-derives from requires on restore, same as before any snapshot
existed.