# @yagejs-addons/quests

Headless quest log for YAGE (`@yagejs-addons` scope, independently versioned,
NOT in the engine `fixed` group). Pure `@yagejs/core` — no renderer, no
dialogue/inventory dependency, no presenters.

## Install

```bash
npm install @yagejs-addons/quests
npm install @yagejs/core
```

`@yagejs/core` is the only peer. No `./presenters` subpath.

## Catalog — `defineQuests`

Two-level id capture: quest ids from the outer map's keys, per-quest objective
ids from each quest's own `objectives` map keys. No `<T>` argument is ever
written — `QuestLog<TDefs>` infers both levels from the `defineQuests` return
value.

```ts yage-group="herbs"
import { defineQuests, QuestLog } from "@yagejs-addons/quests";

const quests = defineQuests({
  gatherHerbs: {
    title: "Gather Herbs", // required, non-empty
    summary: "...", // optional
    objectives: {
      herb: { title: "...", count: 5 }, // count omitted -> default 1
      turnIn: { title: "..." },
    },
    autoComplete: false, // optional; default true
    requires: [], // optional; quest ids that must be `completed` first
  },
  thinThePack: {
    title: "Thin the Pack",
    requires: ["gatherHerbs"],
    objectives: { wolf: { count: 3 } },
  },
});
```

`ObjectiveDefInput`: `{ title?; description?; count?: number (default 1, must
be integer >= 1); optional?: boolean (default false, excluded from
auto-complete rollup) }`.

Validation (throws at `defineQuests` call time): empty `title` on a quest;
non-integer or `< 1` objective `count`; a quest with no non-`optional`
objective (an empty or all-optional objective set would never gate the
auto-complete rollup, so the quest would complete immediately); a `requires`
id naming a quest absent from the whole map (forward references to a
later-declared quest are fine).

`QuestCatalog<TDefs>`: `ids` (readonly array, authoring order), `get(id)`
(throws on unknown), `tryGet(id)` (undefined on unknown), `has(id)` (type
predicate), `objectiveIds(quest)`.

## QuestLog — the runtime model

```ts yage-group="herbs"
const log = new QuestLog(quests); // TDefs inferred from `quests`, zero <T>
```

A log is saved progression that outlives scenes, so it sits at module level
next to the game's explicit save root (see Save). Components read and advance
it; reactions to its events live in components, not in `onEnter` closures.

### Lifecycle

- `start(quest): QuestStartResult` — `{ ok: true }` from `available`. Else
  `{ ok: false, reason }`: `"locked"` (a `requires` quest isn't `completed`),
  `"already-active"`, `"already-completed"` (also covers a `failed` quest —
  terminal states share this reason), `"unknown-quest"` (id absent from the
  catalog — never throws; a boundary check).
- `advance(quest, objective, amount = 1): void` — active-only. Clamps to the
  target; **silent no-op** (no event) if the quest isn't `active`, or if
  `amount <= 0`. Progress never decreases via `advance`.
- `setProgress(quest, objective, count): void` — active-only. Sets the count
  absolutely, `clamp(count, 0, target)` — CAN decrease. Silent no-op if
  inactive.
- `complete(quest, objective): void` — active-only; drives straight to the
  target. No-op if already done or inactive.
- `completeQuest(quest): void` — completes only when the quest is active and
  every non-optional objective is currently done. Does not alter progress.
- `forceCompleteQuest(quest): void` — escape hatch: marks every objective done
  and completes any non-terminal known quest regardless of prerequisites or
  progress.
- `fail(quest): void` — `active` or `available` -> `failed`. No-op if already
  terminal. `failed` is terminal (there is no re-open or retry) — a failed
  quest never reaches `completed`, so any quest that `requires` it stays
  `locked` permanently.

Unknown **objective** id (unreachable through the typed API) throws. Unknown
**quest** id behaves exactly like "not active" everywhere except `start`
(silent no-op, since a nonexistent quest is never active).

### Reads

- `status(quest): "locked" | "available" | "active" | "completed" | "failed"`
  — pure function of stored phase; no separate stored "locked". A quest never
  started is `locked` unless every `requires` quest is `completed`.
- `isActive(quest)` / `isCompleted(quest)`: boolean.
- `canComplete(quest): boolean` — active and every non-optional objective is
  currently done.
- `progress(quest, objective): number` (0 if untouched).
- `objectiveDone(quest, objective): boolean`.
- `available() / active() / completed(): QuestId[]` — authoring order.
- `get(quest): QuestState` — `{ status, objectives: Record<objId, number> }`,
  frozen, every declared objective present (0 default).

### Auto-complete rollup

With `autoComplete: true` (default), after any objective crosses from
below-target to at-or-above it: emits
`objectiveCompleted`, then — if every **non-`optional`** objective on that
quest is now done — the quest transitions to `completed` and emits
`questCompleted`, all before the trailing `changed`. `optional` objectives
never gate completion.

### Events — `log.on(event, fn): () => void`

`questStarted { questId }` · `objectiveProgressChanged { questId, objectiveId,
progress, count, done }` · `objectiveCompleted { questId, objectiveId }` ·
`questCompleted { questId }` · `questFailed { questId }` · `changed { questId
}` (coarse re-render signal, fires once per mutating call, after the
fine-grained event(s)). Payload ids are plain `string`: narrow with
`quests.has(questId)` before passing one to `quests.get` or a typed `log` call.

### Save

```ts
import { QuestLog as BaseQuestLog } from "@yagejs-addons/quests";
import type { QuestDefInput, QuestSnapshot } from "@yagejs-addons/quests";

declare class QuestLog<
  TDefs extends Record<string, QuestDefInput>,
> extends BaseQuestLog<TDefs> {
  snapshot(): QuestSnapshot; // { quests: Record<questId, { phase, objectives }> } — plain JSON
  restore(snapshot: QuestSnapshot): void;
}
```

Only started quests appear in a snapshot. `restore` drops quest ids the
current catalog no longer declares, drops objective ids no longer declared
within a restored quest, clamps surviving counts to the current target, and
installs the complete restored log before notifying listeners. An active quest
whose current targets are all satisfied auto-completes unless `autoComplete`
is `false`. It emits `questCompleted` before that quest's `changed`, so completion
listeners can start dependent quests against the full restored state. Restore
does not replay `objectiveCompleted`. Completed, failed, and manual-completion
quests keep their saved status. It emits one `changed` per restored (and known)
quest, in addition to changes made by listeners. Not-started quests
re-derive `locked`/`available` from `requires`. `@yagejs/save` is not a
dependency. Include this domain snapshot in the game's explicit save state and
call `restore` after loading it.

## QuestController (optional L2a)

```ts yage-group="herbs"
import { Component, type Entity } from "@yagejs/core";
import { QuestController, QuestCompletedEvent } from "@yagejs-addons/quests";

declare const player: Entity;

player.add(new QuestController({ log })); // TDefs inferred from `log`
// in any component of the scene:
class Achievements extends Component {
  onAdd(): void {
    this.listenScene(QuestCompletedEvent, ({ questId }) => {});
  }
}
```

Mirrors the log's six model events onto the host entity as engine-bus events
(`QuestStartedEvent`, `QuestObjectiveProgressChangedEvent`,
`QuestObjectiveCompletedEvent`, `QuestCompletedEvent`, `QuestFailedEvent`,
`QuestChangedEvent`) — bubble entity -> scene. Bus payloads carry `string`
ids (event tokens can't be generic). No per-frame `update`. `.log` exposes the
hosted model. Entirely optional — `log.on(...)` alone reaches the same
consequences.

Disabling `QuestController`, or deactivating its entity, stops the event mirror.
The standalone `QuestLog` remains live and keeps every model change. Events
that occur while the controller is dormant are not replayed when it enables.

## Binding objectives to other addons — no addon dependency

Quests declares only `@yagejs/core`. The game subscribes to whatever events it
likes and calls `advance`/`complete` directly — the silent-no-op-on-inactive
contract means no active-state guard is needed in the adapter:

```ts yage-group="herbs"
import { defineEvent } from "@yagejs/core";
import { InventoryItemAddedEvent } from "@yagejs-addons/inventory";

const WolfDiedEvent = defineEvent("game:wolf-died"); // the game's own event

// A component on the player (not an `entity.on` closure in onEnter):
class QuestProgress extends Component {
  onAdd(): void {
    this.listen(this.entity, InventoryItemAddedEvent, (e) => {
      if (e.itemId !== "redHerb") return;
      log.advance("gatherHerbs", "herb", e.quantity);
    });
    // WolfDiedEvent bubbles from any wolf to the scene.
    this.listenScene(WolfDiedEvent, () => log.advance("thinThePack", "wolf"));
  }
}
```

`log.on(...)` returns an unsubscribe; from a component, pass it to
`this.addCleanup(...)` so the subscription ends with the component.

For a current-inventory requirement, set `autoComplete: false` on the quest
and synchronize absolute progress on both inventory additions and removals:

```ts
import { defineQuests, QuestLog } from "@yagejs-addons/quests";
import { defineItems, Inventory } from "@yagejs-addons/inventory";

const quests = defineQuests({
  bringWood: {
    title: "Bring 10 wood",
    autoComplete: false,
    objectives: { wood: { count: 10 } },
  },
});
const log = new QuestLog(quests);
const inventory = new Inventory({
  catalog: defineItems({ wood: { name: "Wood" } }),
});

const syncWood = () =>
  log.setProgress("bringWood", "wood", inventory.count("wood"));
inventory.on("itemAdded", syncWood);
inventory.on("itemRemoved", syncWood);
log.start("bringWood");
syncWood();

if (log.canComplete("bringWood")) log.completeQuest("bringWood");
```

## Not included

The addon ships no journal or tracker presenter and no per-addon adapters;
the game draws its own UI and connects other addons through events. It does
not auto-start or auto-offer quests, abandon, reset or retry them, time or
hide objectives, branch on "any N of M", detect prerequisite cycles, or pay
rewards. There is no i18n resolver; quest and objective text is addressable
by `(questId, objectiveId)` for the game to localize.
