Skip to content

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.

Terminal window
npm install @yagejs-addons/quests
npm install @yagejs/core

@yagejs/core is the only peer. There is no ./presenters subpath — the whole package is headless.

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"));
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"); // 3
log.objectiveDone("gatherHerbs", "herb"); // false
log.canComplete("gatherHerbs"); // false
log.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.

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.

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.

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.

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.