Skip to content

Quests & scenes

Every .lute document declares a kind: scene, quest, or lore. The kind selects the required frontmatter keys, the admitted grammar, and the identity model. Scenes are the playable episodes you author line by line; quests are the run-to-completion goal machines that gate and reward them. Both sit on the story’s time axis. The third kind, lore (dsl 0.19.0), holds content the engine looks up rather than plays — item descriptions, found notes, barks — and has its own page: Lore entries.

A scene is one episode — a frontmatter id: that names it project-wide, plus its sections. (Older scenes name themselves with character/season/episode instead, and the name is built as {character}.{episodeId}; that still works, but new scenes use id:.) Scenes are sequenced with the frontmatter key after:, which declares the routes the checker and lute scenario assume reach this scene. It is advisory ordering metadata, not a jump: to have scenes actually play one after another, see Connect scenes into a story. Its vocabulary is exactly three predicates combined with && / ||:

  • visited("<id>") — true once the player has seen the scene with that id:;
  • completed("<questId>") — true once that quest is finished;
  • active("<questId>") — true once that quest has been taken up and not yet resolved.
after: 'visited("mira.s01ep01") && active("theCoffeeDebt")'

active is new in 0.8.0, and it closes an asymmetry. The quest lifecycle is unset → active → complete | failed, yet the prerequisite vocabulary could name only two of those three observable states — “reachable while the debt is still outstanding” had no spelling at all. For the reachability graph an active edge is identical to a completed one: both assert “that node must be reachable before this one”, so cycle and unreachability analysis is unchanged. The state envelope it carries is strictly weaker — after completed(q) a consumer may assume quest.q.state == complete; after active(q), only that the quest reached active.

There is no negation, arithmetic, or state read in after:. See Scene graph & after: for how these declarations form the reachability graph. Since 0.21.0, visited(…) is also an ordinary condition function — see Quests meet scenes and occasions below.

A quest document declares kind: quest and carries uses: for the schema it gates on. Its body is one or more <quest> declarations — quests forbid <hub>, <timeline>, and #/## headings.

A quest runs only its <on> handler bodies and its <objective> bodies, so since 0.52 (#208) those two, and its <reward>s, are the only things directly inside <quest>. A content line, a directive, ::set, ::label, ::jump, <branch>, <match>, ::assert or ::retract at the quest’s top level is E-GRAMMAR-NOT-ADMITTED; put it in the <on event=…> or <objective> whose moment it belongs to:

q.lute:6:3: error [E-GRAMMAR-NOT-ADMITTED] a content line at the top level of `<quest id="toll">` never runs — a quest runs only its `<on>` handlers and `<objective>` bodies: move it into the `<on event=…>` or `<objective>` whose moment it belongs to

On 0.51 such content compiled to records no run reached, except content after the last body, which played by fallthrough as part of that body. Move it into that body.

<quest id="rescueHalsinGrove" tier="run" title="Rescue the First Druid" start="run.act == 1" fail="run.npc.halsin.dead">
<objective id="reachGrove" title="Reach the Emerald Grove" done="run.region == 'grove'"/>
<objective id="freeHalsin" title="Free Halsin from the cage" done="run.npc.halsin.freed"/>
<on event="questComplete">
::set{user.xp += 300}
::set{run.metHalsin = true}
@narrator: Halsin rolled his shoulders and looked north. "Moonrise, then."
</on>
<on event="questFailed">
::set{run.groveOutcome = "halsinDead"}
@narrator: The cage held only silence now.
</on>
</quest>

(From docs/examples/quest-grove.lute.)

A quest document MAY also declare a document id: (dsl 0.19.0 §2.1) naming the file as a bundle — a quest chain written top to bottom, such as haven.mainChain. It follows the same shape rules as a scene’s id: (E-META-ID), becomes the artifact’s meta.id and the document’s key in project.index.json, and shares one project-wide namespace with scene and lore document ids (E-CONN-EPISODE-ID-DUP). It is optional: without it the document is keyed by its first declared quest id, exactly as before.

kind: quest
id: haven.mainChain
uses: ../world.schema.yaml

A <quest id> needs a project-unique CEL-safe id (it keys the quest.<id>.* state tier). Its optional predicates are CEL strings: start transitions the quest unset → active when it holds; fail transitions active → failed. fail takes precedence over completion — if both hold in the same state, the quest fails (a deterministic tie-break).

tier (dsl 0.22.0) says how long a quest’s outcome lasts. The default, tier="user", keeps its status across runs, as every quest did before 0.22.0. tier="run" resets it: when a new run starts, the quest’s status returns to unset and its objectives to not done. A roguelike’s per-run goal is then taken up, completed, or failed afresh each run, beside a user-tier quest that tracks the whole save:

---
kind: quest
title: The climb
state:
run.floor: { type: int, default: 0 }
user.bestFloor: { type: int, default: 0 }
---
<quest id="climb" title="Reach the tenth floor" start="true" tier="run">
<objective id="top" title="Reach floor ten" done="run.floor >= 10"/>
<on event="questComplete">
@narrator: The wind at the top is colder than you expected.
</on>
</quest>
<quest id="legend" title="Become a legend" start="true">
<objective id="deep" title="Reach floor ten in any run" done="user.bestFloor >= 10"/>
</quest>

After the reset the lifecycle settles as usual: climb’s start holds again, so it re-activates at the start of every run, while an accept-driven run-tier quest stays unset until it is accepted again. legend keeps its status across runs. The tier belongs to each <quest>, not to the document, so one quest document can mix both. Any value but run, user, or season:<name> (see below) is E-ATTR-TYPE. A subquest is the exception: it must share its parent’s tier (E-QUEST-TIER-MIX). lute play performs the reset at every newRun step, so a play script can walk several runs and assert each one.

A game whose quests are all run quests says so once, in lute.project.yaml (dsl 0.26.0 §2.4):

defaults:
questTier: run

Every <quest> that writes no tier= then takes run, and one that writes tier="user" keeps it. questTier takes run, user or season:<name>; without it the default stays user. See Project defaults.

A quest declares its place in the scenario graph as an attribute on the element, follows=, not as a frontmatter key — the one place this page’s opening heading, “Scenes and after:”, does not apply:

<quest id="manifestGap" tier="run" title="The Manifest Gap" start="true" follows="visited('haven.s01ep06') && completed('whoWakes')">
<objective id="reconcile" title="Reconcile the count" done="true"/>
</quest>

follows= is graph metadata only: it records the edges lute scenario draws and never gates the quest. The quest above activates as soon as its start holds, whether or not haven.s01ep06 was played. To make a quest wait for a scene, put the condition in start: start="visited('haven.s01ep06')". (Before 0.28.0 the attribute was spelled after=; that spelling is now E-UNKNOWN-ATTR, and the message names follows=.)

follows= takes the same restricted prerequisite profile as a scene’s after: key — visited("id"), completed("id"), active("id"), &&, ||, and nothing else (E-CONN-PROFILE). Writing after: or follows: in a quest’s frontmatter is E-META-UNKNOWN-KEY, and the diagnostic names the attribute form:

error [E-META-UNKNOWN-KEY] unknown top-level meta key `after` (not a core key and not owned by an active plugin) — a quest's graph edge is the `follows=` ATTRIBUTE on its `<quest>` element, not a frontmatter key; it does not gate the quest (to wait, write `start="visited('<scene id>')"`)

A visited() in follows= (or in a scene’s after:) may name a bundle beat by its canonical id, follows="visited('talks.maud')" (dsl 0.24.0 §2). Before 0.24.0 that was E-CONN-UNKNOWN-NODE, “a bundle beat, not a scene”.

An accept-driven quest (no start, see below) usually needs no follows= at all. Without one, it is anchored at every scene, bundle beat, and quest body that ::accepts it: lute scenario draws an accept edge from each of them (beat(talks.maud) -> quest(salvage) [accept]), and the quest no longer counts as unanchored.

Two more kinds of anchor need no follows= either (dsl 0.25.0 §4):

  • Subquests. Every nested quest hangs off its parent: quest(relight) -> quest(oil) [subquest]. A parent with children is no longer listed as unanchored, and neither is a child.
  • start conjuncts. A quest without follows= is anchored by the top-level && conjuncts of its start that read visited('…'), entry.X.everRead, or quest.Y.state == '…' (any status but unset). start="entry.keeperLog.everRead && visited('arrival')" draws entry(keeperLog) -> quest(relight) [start] and scene(arrival) -> quest(relight) [start]; an entry joins the graph as the node entry(<id>). An || of such reads is one anchor with several sources. A start-driven quest therefore needs no copy of its start in follows=.

lute scenario --project <DIR> reach --node quest:<id> lists a quest’s anchors (anchors in --json). Anchors never prove a quest unreachable, and an anchor that would close a cycle is not drawn. An explicit follows= keeps only the edges it declares, in place of the start anchors; the subquest edge stays, and the accept anchors stay too, because follows= never gates an accept (see Routes into an accept-driven quest).

Quests that come back: season tiers and rearm

Section titled “Quests that come back: season tiers and rearm”

Some quests return on a schedule rather than with a run: a harvest errand board that opens every autumn, a festival that comes back each year. Two attributes cover them (dsl 0.27.0 §5).

tier="season:<name>" makes a quest belong to a season a schema declares. When that season opens again, the quest returns to unset and its objectives to not done, as a run-tier quest does at a new run. A season no schema declares is E-SEASON-DECL.

rearm="<condition>" reruns one quest without a season. Each time the condition turns from false to true, the quest returns to unset: its objectives are undone, its failedBy is cleared, and its deadlines are forgotten. It can then be accepted or started again, and a start that holds activates it in the same settle:

<quest id="harvestMissions" title="Harvest missions" start="@harvestLive" rearm="@harvestLive">
<objective id="bring" title="Bring in this week's barley" done="run.weekBarley >= 10"/>
</quest>

Here @harvestLive is a def over the clock, say clock.weekday == 5. The quest starts the first Saturday, and each later Saturday it is rearmed and starts afresh, whether the player finished it the week before, failed it, or left it open. The engine watches the condition at every quest settle of a playthrough, including every clock position a long advance: passes on the way, so an advance from Friday to Monday rearms the quest on the Saturday it crosses. The first time it sees the condition is only the baseline, so a condition already true when play begins does not rearm. lute play prints the reset with the status it ended, quest harvestMissions -> unset (rearmed; was complete); a season-tier quest’s reset reads quest missions -> unset (season:harvest opened; was complete). The condition compiles to rearm: {cel, expr, authored?} on the quest’s QuestCmd.

A rearm undoes the objectives, not the state they read. run.weekBarley is a counter the engine sets back to 0 when the week turns. Had the objective read a run-long total, done="run.barley >= 10", it would still hold on the next Saturday, and the settle that rearmed the quest would start it and complete it again at once, rewards and all. Give a rearmed quest’s objectives state that starts over with it: a counter the engine resets no later than the moment the condition turns true (in a play script, an engine: step before the advance: that reaches that moment; an advance’s own engine: writes land before the clock moves), or a season.<name>.* path under tier="season:<name>". The checker does not warn about this.

Because only the false→true turn rearms, a constant condition never does: rearm="true", rearm="false", or a def or comparison that folds to a constant is W-QUEST-REARM-CONSTANT. There is no repeatable= or repeat=; writing one is E-UNKNOWN-ATTR, and the message points at rearm="<condition>". A subquest (a quest an <objective quest=…> names) cannot rearm: it activates with its parent, so once the parent has ended a rearmed child would stay unset for good. Its rearm= is E-SUBQUEST-REARM; put the condition on the parent, whose rearm starts a new round.

An <objective id done> requires a done completion predicate over declared state. visibleWhen controls only whether the objective is shown and tracked — it never gates done (to make completion wait for a condition, put it in done). optional excludes it from completion; by and until set a deadline. An empty-body objective should be written self-closing (<objective …/>); a body — a log line, a per-objective ::set reward — emits once, when the objective first becomes done.

Completion is derived, never author-written: a quest becomes complete when every non-optional objective is done (or any one of them, under complete="any"). Objective completion is monotonic — once done, it stays recorded.

A quest UI shows an objective’s title as written; nothing renders it. So since 0.52 a {{ in an <objective title> — a marker, escaped or not — is E-OBJECTIVE-TITLE-INTERP. Write the words as plain text, and show a value that changes in a line:

q.lute:7:30: error [E-OBJECTIVE-TITLE-INTERP] `<objective id="pay">`'s `title` is shown as written — a quest UI does not render `{{run.gold}}` — so it takes no `{{…}}`: write the words as plain text (`title="Pay the toll"`), and show a value that changes in a line

A quest’s own title= was never interpolated and is unchanged.

A <reward> (a direct child of <quest> or <objective>) may carry an optional id: a bounded ASCII token matching [A-Za-z][A-Za-z0-9_-]{0,63}. The id is unique among its quest’s rewards, its objectives’ rewards included; a second <reward id="clearPay"> in the same quest is E-REWARD-DUP. It lowers to RewardEntry.id and is the reward’s stable key in source maps and engine models, so reordering or inserting rewards does not move it. An untagged reward has no id in the IR and is keyed by its declaration index instead.

---
kind: quest
title: The climb
state:
run.floor: { type: int, default: 0 }
---
<quest id="climb" title="Reach the tenth floor" start="true" tier="run">
<objective id="top" title="Reach floor ten" done="run.floor >= 10">
<reward id="topBonus" kind="EMBERS" amount="20"/>
</objective>
<reward id="clearPay" kind="EMBERS" amount="50"/>
<reward kind="BADGE" amount="1"/>
</quest>
"rewards": [
{ "id": "clearPay", "kind": "EMBERS", "amount": 50 },
{ "kind": "BADGE", "amount": 1 }
]

A plugin’s reward kind may declare effects (effects: in the plugin manifest, since 0.50): facts the kind asserts or retracts, and quests it accepts, whenever a <reward> of that kind is granted. A host that used to read a BADGE reward and write met(mira) itself now has the checker and the runtime do it, so the fact is visible to reachability and to guards. The manifest side is on the manifests page:

rewardKinds:
BADGE: { target: { entity: person, required: true }, effects: { asserts: ["met(@target)"] } }
REVOKE: { target: { entity: person, required: true }, effects: { retracts: ["met(@target)"] } }
OFFER_QUEST: { target: { required: true }, effects: { accepts: ["@target"] } }

@target is the reward’s quoted target=. Since 0.52 an effect may also read @<attr>, an attribute the kind declares (below). writes is not an effect: a reward’s state write is credits:.

The plugin-surfaces example declares those three kinds and grants them:

---
kind: quest
id: visits.quests
title: Visits
---
/*
Reward effects (0.50):
- firstVisit's BADGE asserts met(mira) at the grant; secondVisit starts on
that fact, so it is reachable only because the reward produces it. Its
`follows` places it in the scenario graph, where `lute scenario --facts`
draws the fact edge firstVisit -> secondVisit [met(mira)].
- firstVisit's OFFER_QUEST accepts giftErrand, an accept-driven quest no
`::accept` names.
- secondVisit's REVOKE retracts met(mira) when it completes.
*/
<quest id="firstVisit" tier="run" title="Say hello to Mira" start="true">
<reward kind="BADGE" amount="1" target="mira"/>
<reward kind="OFFER_QUEST" target="giftErrand"/>
<objective id="talk" title="Talk to Mira" done="run.talked"/>
</quest>
<quest id="secondVisit" tier="run" title="Visit Mira again" follows="completed('firstVisit')" start="holds('met', ['mira'])">
<reward kind="REVOKE" target="mira"/>
<objective id="chat" title="Have a second chat" done="run.chatted"/>
</quest>
<quest id="giftErrand" tier="run" title="Carry Mira's gift to Tomas">
<objective id="deliver" title="Deliver the gift" done="run.delivered"/>
</quest>

Checks, per <reward>. Each reward whose kind declares effects is judged as if its resolved effects were written as ::assert, ::retract and ::accept:

  • Facts. Each resolved pattern gets the checks of ::assert / ::retract: the relation is declared, the arity and argument kinds and members fit, and closed-kind membership holds. The codes are the ones those directives draw, reported at the reward’s kind=, and the message is prefixed `<reward kind="BADGE">` asserts `met(mira)` (its kind's `effects`):.
  • Accepts. Each resolved quest id gets ::accept’s rule, project-wide: E-ACCEPT-TARGET for an unknown id (with a did-you-mean), for a quest that has a start, and for an auto-activating child. The anchor is the reward’s target= value when the id came from @target, otherwise kind=. The message names the reward: `<reward kind="OFFER_QUEST" target="secnd">` accepts quest `secnd` (its kind's `effects.accepts`), but no quest in the project declares that id — did you mean `second`?
  • No target=. An effect that reads @target is not applied, with no extra diagnostic. A kind whose target is required: true already reports E-REWARD-TARGET.
  • W-QUEST-NEVER-ACCEPTED. A reward accepts that names a quest counts like a scene’s ::accept. The message now reads … but no `::accept` or reward `accepts` in the project names it ….
  • W-REWARD-DOUBLE-CREDIT is unchanged.

Order at the grant. When a reward is granted (its outcome matches the transition and its when= holds), in declaration order and per reward:

  1. the grant record and its credits write, as before;
  2. the kind’s retracts, then its asserts, through the same path as ::retract / ::assert (including the runtime E-FACT-EXCLUSIVE check);
  3. its accepts, through ::accept’s path: an accept-driven quest activates on the next lifecycle round, and a quest already past unset is left alone, with the record saying so.

Then the next reward. As before, objective rewards are granted before quest rewards, and every grant of a transition precedes that transition’s lifecycle handler bodies, so a questComplete handler reads the fact the reward asserted. An effect fires exactly once per grant; replay and restore follow the grant’s own instance identity. A <reward when="…"> whose guard is false grants nothing, effects included.

The analyses read each effect at the owner’s grant point: a quest reward without outcome at the quest’s completion (before its questComplete handlers), outcome="failed" at its failure (before questFailed), and an objective reward at the objective’s completion. A reward’s assert is a producer only: it makes a quest gated on the fact reachable, but it is never guaranteed to hold afterwards (handlers may be deferred past other grants, and a host may seed a quest complete), so a guard testing it draws no W-FACT-GUARANTEED or E-ARM-DEAD. A reward’s retract removes the fact from what is guaranteed. The consequences are on facts and Datalog and reachability.

A reward kind may declare attrs: in its manifest, the way a directive declares its attributes. Since 0.52 (#201) a <reward> of that kind authors them, and the kind’s effects read them as @<attr>:

rewardKinds:
BADGE:
target: { entity: person, required: true }
attrs:
- { name: rarity, type: { enum: [common, rare, gold] }, default: common }
effects: { asserts: ["badge(@target, @rarity)"] }
<objective id="pay" done="run.gold > 2">
<reward kind="BADGE" target="mira" rarity="gold"/>
</objective>
  • Checked like a directive’s attributes. The value is a quoted literal (a bare flag is true for a bool), typed by its declaration with the directive codes: rarity="silver" is E-BAD-ENUM (`silver` is not a valid value for `rarity` of `<reward kind="BADGE">` (expected one of: common, rare, gold)), a required: true attribute left out is E-MISSING-ATTR, and an @ref value is E-REWARD-ATTR, since a reward’s attributes are static data the engine grants against.
  • Unknown attributes. E-UNKNOWN-ATTR lists what the kind declares: `<reward>` has no attribute `rarety` — did you mean `rarity`?; its attributes are `id`, `kind`, `target`, `amount`, `when`, `outcome`, and kind `BADGE` declares `rarity`.
  • Effects. @rarity binds to the authored value, else the declared default:; an attribute with neither is unbound, and an effect that reads it is not applied, as for a missing target=. The checker judges the bound facts as above (badge(mira, gold)).
  • Artifact and grant. The compiled reward carries attrs (each authored attribute and each default not authored, typed), and the runtime’s grant record copies it onto reward:
{"index":0,"instance":1,"kind":"grant","objective":"pay","quest":"first","reward":{"amount":1,"attrs":{"rarity":"gold"},"kind":"BADGE","target":"mira"}}

A reward of a kind with no attrs: compiles and grants exactly as before. The declaration rules are on the manifests page.

Three small additions (dsl 0.21.0 §7a) let a scene drive a quest directly, instead of relaying a flag through state.

visited() in any condition. visited('<scene id>') — until 0.21.0 legal only inside after: — is a Lute-CEL function in every condition slot: quest start / fail, objective done, beat and entry when, and content-line and choice when=. It is true once that scene has been presented in this save (the same visited set after: reads; a new run does not clear it), so a scene advances a quest simply by being played:

<objective id="heardVesna" title="Hear Vesna out" done="visited('haven.s01ep04')"/>

The argument is one string literal. An id that names no scene in the project is E-CONN-UNKNOWN-NODE at whole-project lute check, exactly as in after:. A single-file check cannot know whether a scene has been played, so it never decides a lone visited() true or false. It still sees a contradiction in the condition itself: visited('a.b') && !visited('a.b') is false whatever was played (dsl 0.23.0, see How a when is decided). A bundle beat is visited too, under its canonical id <document id>.<beat id>.

Objectives judged at an occasion. <objective on="<occasion>"> evaluates that objective’s done only when the occasion is raised while the quest is active — the end-of-run check point that a continuously evaluated condition cannot express. Without on, an objective is evaluated continuously, as before.

<objective id="lowPressure" title="Keep the shed calm" on="runEnd" done="run.shedPressure < 2"/>

Since dsl 0.23.0 an on objective may also name a target, the way a beat does: <objective on="talk" target="npc.maud" …> is judged only when talk is raised for npc.maud. Talking to anyone else leaves it alone. Without target, an objective is judged whenever its occasion is raised, whatever the target.

<objective id="thankMaud" title="Thank Maud in person" on="talk" target="npc.maud" done="run.answered"/>

The occasion is checked against the vocabulary exactly as a beat’s on (see Beats): E-OCCASION-UNKNOWN when a plugin declares occasions and this one is not among them, shape-only otherwise; an on that is not a name is E-BEAT-ATTR. A target is checked like a beat target, also as E-BEAT-ATTR: it must be a quoted dotted id, it needs on, the occasion must take a target, and a target domain must contain it. lute trace / lute run raise occasions for a quest walk with the mock key occasions: [runEnd] or --occasion runEnd (repeatable), applied in order after the walk settles, and raise one for a target as <occasion>@<target> (occasions: [talk@npc.maud], --occasion talk@npc.maud). lute play judges them on every step that raises the occasion, for the step’s target:, and an occasion that only objectives reference is a legal step. When a world event of the same name is declared, every raise fires it first, so an active quest’s <on event> handler for that name runs before the objectives are judged and they can read what it wrote (see Beats).

By default an occasion presents its beats first and judges its objectives after, so a beat on runEnd still sees the quest active. An occasion that closes a chapter often wants the reverse, an epilogue that knows how the chapter’s quests ended. A plugin declares that occasion judge: before (dsl 0.24.0 §2) in its occasions: export:

occasions:
chapterEnd: { judge: before }

Raising chapterEnd then judges its on= objectives and settles the quests before its beats are decided, so a beat with when: "quest.tide.state == 'complete'" can win the same raise that completed tide. Only the judging moves. The handler bodies the raise answers, the <on event="chapterEnd"> handlers and the questComplete / questFailed handlers of the quests it settled, still run after the beats, so their narration follows the scene:

── step 3 · chapterEnd ──────────────
tide.home done
quest tide -> complete
✓ end.tide [scene, priority 5]
→ end.tide
@narrator: Home before the water.
@narrator: Handler on chapterEnd.
@narrator: Tide beaten.

lute play prints the transitions under the step header, above the candidates (--json: the step’s judgedBefore; its quests are the ones after, with the handler bodies). The default, judge: after, keeps the 0.21.0 order.

Accepting a quest from a scene. A quest without start is accept-driven. The scene-side form of the engine’s “accept quest” action (and of lute trace --accepted-quest) is the core directive ::accept{quest="<id>"}, typically inside the choice where the player agrees:

<branch id="request">
<choice id="accept" text="I'll keep it calm">
::accept{quest="calmTheShed"}
@vesna: Thank you.
</choice>
<choice id="decline" text="Not now">
@vesna: Another time, then.
</choice>
</branch>

Accepting while the quest is already active, complete, or failed does nothing. An ::accept may carry a guard, ::accept{quest="calmTheShed" when="run.shedPressure > 2"} (dsl 0.26.0 §4, see Guarding a directive), and is then skipped when the guard is false. Whole-project lute check warns W-QUEST-NEVER-ACCEPTED (dsl 0.24.0 §2) at an accept-driven quest that no ::accept or reward accepts in the project names: nothing can ever activate it. Since dsl 0.25.0 §5 an accepts: mock (mocks/*.yaml, *.test.yaml) no longer counts. A mock proves a test, not the game, so a quest only a mock accepts still warns, and the message names the mock file. A quest the engine accepts outside any document declares accept="external" instead. --deny W-QUEST-NEVER-ACCEPTED makes the warning an error.

./quests/lamp.lute:24:12: warning [W-QUEST-NEVER-ACCEPTED] quest `lostDog` is accept-driven (no `start`), but no `::accept` or reward `accepts` in the project names it (only the `accepts:` mock of `tests/dog.test.yaml` does, and a test mock is no acceptance in the game), so it never activates; accept it from a scene with `::accept{quest="lostDog"}`, declare `accept="external"` if the engine accepts it outside the script (a quest board, a menu), or give it a `start` condition

Accepting for the next run. A run-tier quest accepted between runs, at a hub or a bounty board after the run has ended, would activate in the ending run and then be reset by newRun. ::accept{quest="<id>" at="nextRun"} (dsl 0.24.0 §2) queues the acceptance instead. It applies right after the next newRun reset, so the quest activates in the new run’s first settle:

---
kind: scene
id: town.board
title: The bounty board
---
## Board
@maud: The bounties reset at dawn. Sign now and the hunt is yours next run.
<branch id="bounty">
<choice id="sign" text="Sign for the next run">
::accept{quest="wolfBounty" at="nextRun"}
@maud: Your name's on the board.
</choice>
<choice id="later" text="Not this time">
@maud: Suit yourself.
</choice>
</branch>

nextRun is the only value at takes; any other is E-ACCEPT-TARGET. lute play prints quest wolfBounty accepted (queued: applies after the next newRun) where the choice ran, and quest wolfBounty accepted (queued at="nextRun") under the next newRun step.

Quest outcomes in scenario tests. lute test asserts the lifecycle the trace ran with expect.quests: {<questId>: unset | active | complete | failed} — no side-effect ::set needed to observe completion. A test, a trace mock, or a play script can also start from a saved status with a top-level quests: {<questId>: complete} (dsl 0.22.0). That is the way to seed one: a play script’s engine: step refuses a quest.* write, because a status change belongs to the lifecycle, whose transitions fire handlers and grants. Since dsl 0.26.0 §7 a test or lute trace --project resolves the quest ids in accepts: against the whole project, so a scene’s test may accept a quest declared in another document (an id no quest of the project declares is E-TRACE-ACCEPT), and a test of a quest document may seed its own quests (quests: { salvage: active }): the walk starts the quest there.

Accepted outside the script: accept="external"

Section titled “Accepted outside the script: accept="external"”

Some quests are taken up where no document is: a quest board, a menu, a journal UI. The moment of acceptance is the engine’s, and no scene can ::accept it. <quest accept="external"> (dsl 0.25.0 §5) says so:

---
kind: quest
title: The wreck
state:
run.dived: { type: bool, default: false }
---
<quest id="salvage" title="Salvage the wreck" accept="external">
<objective id="dive" title="Dive the wreck" done="run.dived"/>
</quest>

The quest is accept-driven: it stays unset until the engine accepts it, whenever the player chooses, and the IR carries "accept": "external" on its QuestCmd (omitted by default). accept="external" is the one non-content source of acceptance that silences W-QUEST-NEVER-ACCEPTED. A scene may still ::accept it as well.

The toolchain stands in for the engine’s acceptance:

  • A test or lute trace takes the quest up with accepts: [salvage] (or --accepted-quest salvage). That is how a test walks an external quest, and it proves the test only: since 0.25.0 it no longer silences W-QUEST-NEVER-ACCEPTED for a quest that has neither accept="external" nor an ::accept.

  • A play script accepts it mid-play with an engine: step (dsl 0.26.0 §7), as a quest board would, rather than seeding a status up front:

    steps:
    - engine: { accept: [salvage] }
    expect: { quests: { salvage: active } }

    lute play prints quest salvage accepted (engine) and settles the lifecycle, so the quest’s questActive handlers run on that step. engine: { accept } takes accept-driven quests only; a quest with a start is refused.

accept takes only external; any other value is E-ATTR-TYPE. So is accept="external" beside start, since the quest would have two ways to activate; the message names follows= as the spelling for a host condition (below). On a subquest child that activates with its parent, the engine’s acceptance would do nothing, so whole-project lute check reports E-ACCEPT-TARGET. Declare activate="accept" on the child as well, and it waits for the engine while its parent is active:

./quests/board.lute:10:41: error [E-ACCEPT-TARGET] quest `child` declares `accept="external"`, but it activates with its parent `board`, so the engine's acceptance does nothing; declare `activate="accept"` on it too so it waits to be accepted

An accept-driven quest activates when it is accepted, and three things can accept it. They differ in who decides:

  • An in-script accept — a scene’s ::accept, or a reward kind’s effects.accepts — activates the quest at the next lifecycle round whatever its follows= says. follows is metadata for the graph and the host; the runtime never reads it.
  • The host’s grant of an accept="external" quest (a board, a menu, the server) is the host’s own decision. follows= on such a quest is the condition under which the host grants it: Lute carries it to the host and analysis reads it as host policy. Lute does not enforce it.
  • Nothing else. A quest that is not accept="external" and has no ::accept or reward naming it has no route, follows= or not: follows is ordering metadata and never a route. With a follows, reach says Unknown (below); without one the quest stays Unanchored, the conservative reading of a quest on no edge.

A quest the host grants only once other quests are done is therefore written accept="external" follows="…"; accept="external" beside start stays refused. Both quests below check clean, and salvage is reachable through the host once survey is complete:

---
kind: quest
title: The board
state:
run.dived: { type: bool, default: false }
---
<quest id="survey" title="Survey the reef" tier="run" start="true">
<objective id="look" title="Look at the reef" done="run.dived"/>
</quest>
<quest id="salvage" title="Salvage the wreck" tier="run" accept="external" follows="completed('survey')">
<objective id="dive" title="Dive the wreck" done="run.dived"/>
</quest>

lute scenario reach lists the host route first among the quest’s accept-anchor sources. An external quest has that anchor when it declares follows or something in the script accepts it; one with neither has no anchor and stays Unanchored (no [accept] line). With no follows the host route reads [accept] the host (accept="external"), and the evidence stays proven. With a follows the host route rests on host policy, so a verdict that depends only on host routes is heuristic (an in-script accept that can run keeps it proven); the follows line is marked as metadata:

Terminal window
$ lute scenario --project . reach --node quest:gated
project root: .
reach quest(gated):
verdict: Reachable — reachable if the host grants it once `follows` holds (host policy; Lute does not enforce it). [heuristic]
follows: completed("intro") (declared ordering; not an activation route)
anchored; each anchor holds before it activates, through any one of its sources:
[accept] the host once follows: completed("intro")
referenced node(s) (see `follows` and the anchors above — this is NOT a flat requirement list):
- quest(intro): Reachable — a satisfiable route exists under your declared routes.

A quest that is accept-driven, not accept="external", declares follows, and that no ::accept or reward names has no route at all: reach is Unknown and W-QUEST-NEVER-ACCEPTED fires. The follows edge is still drawn.

Terminal window
$ lute scenario --project . reach --node quest:board
project root: .
reach quest(board):
verdict: Unknown — this analysis cannot prove reachability either way under your declared routes. [unknown]
follows: completed("intro") (declared ordering; not an activation route)
anchored; each anchor holds before it activates, through any one of its sources:
[accept] nothing accepts it (no ::accept or reward names it, and it is not accept="external")
referenced node(s) (see `follows` and the anchors above — this is NOT a flat requirement list):
- quest(intro): Reachable — a satisfiable route exists under your declared routes.

Accept deadlocks. A cycle of start conditions and in-script accepts that no source outside the cycle enters and no host route opens is E-QUEST-START-CYCLE, one per strongly connected set. It is reported at the least quest’s start, or at its id when that quest has no start. The path names each edge kind (-[start]->, -[accept]->). Here intro starts when letter is active, and letter is only accepted by intro’s reward:

./quests/q.lute:7:36: error [E-QUEST-START-CYCLE] quest start cycle: quest(intro) -[start]-> quest(letter) -[accept]-> quest(intro) — each waits for the next (a `start` for its condition, an accept for the `::accept` or reward that grants it) and no source outside the cycle starts any of them; start one of them from something outside the cycle, or let the host grant one with `accept="external"`

Two quests whose rewards accept each other deadlock the same way, anchored at the id of a:

./quests/q.lute:7:12: error [E-QUEST-START-CYCLE] quest start cycle: quest(a) -[accept]-> quest(b) -[accept]-> quest(a) — each waits for the next (a `start` for its condition, an accept for the `::accept` or reward that grants it) and no source outside the cycle starts any of them; start one of them from something outside the cycle, or let the host grant one with `accept="external"`

Any third source breaks the cycle: a quest that starts on its own and accepts one of them, a scene’s ::accept, or accept="external" on one of them.

Messages that point to follows=. accept="external" beside start is E-ATTR-TYPE; a start that reads its own quest’s state is E-QUEST-START-SELF; and accepting a quest that has a start is E-ACCEPT-TARGET. Each now says what to write instead:

./quests/q.lute:7:35: error [E-ATTR-TYPE] `<quest id="bad">` carries both `accept="external"` and `start`: a `start` condition activates a quest by itself, so the host could never decide; drop `start`. If the host grants it only once some scenes or quests are done, write that as `follows="…"` (for example `follows="completed('intro')"`): the host applies it, and analysis reads it as the host's condition
./quests/q.lute:11:54: error [E-QUEST-START-SELF] `start` of quest `selfq` reads its own state (`quest.selfq.state`), which has no value before the quest starts, so this condition can never start it as intended; a quest that only the host grants is written `accept="external"` without a `start`; if the host grants it only once other scenes or quests are done, write those as `follows="…"`, which the host applies and analysis reads
./quests/q.lute:21:38: error [E-ACCEPT-TARGET] `<reward kind="OFFER_QUEST" target="tgt">` accepts quest `tgt` (its kind's `effects.accepts`), which has a `start` condition and activates on its own; only an accept-driven quest (no `start`) can be accepted; drop its `start` — the accept activates it whatever other condition it had (a `follows="…"` records such conditions for analysis and the host, but does not gate the accept)

Some goals have a window: answer the letter before the fourth day, reach the gate before the bell. An objective’s by (dsl 0.23.0) closes the window. It is a condition slot like done:

---
kind: quest
title: The letter
state:
run.day: { type: int, default: 1 }
run.answered: { type: bool, default: false }
run.apologized: { type: bool, default: false }
---
<quest id="letter" title="Answer the letter" start="true" tier="run">
<objective id="reply" title="Write back before the fourth day" done="run.answered" by="run.day >= 4"/>
<objective id="thank" title="Thank Maud in person" on="talk" target="npc.maud" done="run.answered" by="run.day >= 4"/>
<objective id="sorry" title="Apologize to Oskar when you next see him" on="talk" target="npc.oskar" done="run.apologized" until="run.day >= 4" optional/>
<on event="questFailed">
@narrator: The letter goes unanswered. Maud stops asking.
</on>
</quest>

While the objective is not done, the first time by becomes true the objective fails, and it is never judged again. A failed required objective fails its quest, as a fail condition would: the quest’s failed rewards are granted, its questFailed handlers run, and the failure cascades to its still-active subquests. A failed optional objective leaves its quest alone.

  • In every settle done is judged before by. So an objective whose done and by become true at the same moment counts as done, and a done objective never fails later. The step that raises an on= objective’s occasion judges that objective’s done first and its by only after, in all of that step’s presentations and settles, so a hearing whose beat files the right verdict completes the objective even when that verdict also makes by true.
  • by is a moment: it is judged at every lifecycle settle, for every objective, on= or not (dsl 0.24.0 §2.1). thank above fails in the settle that reaches day four with run.answered still false, whether or not the player ever talks to Maud again. (0.23.1 judged an on= objective’s by only at its occasion; 0.24.0 reverses that.)
  • until="<condition>" is the place-bound deadline: it is judged only when the objective’s occasion is raised (for its target, when it names one), right after its done. So the moment the occasion answers is both the judgement and the deadline, and a beat on that occasion that writes the state until reads cannot fail a correct answer. sorry above fails only if the player talks to Oskar on day four or later without having apologized; a player who never sees him again leaves it open. until needs on= (E-BEAT-ATTR without it: a deadline that holds everywhere is by=).
  • Quests settle after every beat an occasion presents, so a by can pass between two beats of one select: sequence occasion.
  • The failure is readable: quest.<id>.objectives.<oid>.failed is true from then on, and a failed required objective sets its quest’s failedBy to by or until. A new run clears both for a tier="run" quest.
  • by and until are checked like done: an undeclared path is E-UNDECLARED, and a read that may be unset is E-MAYBE-UNSET.

lute trace records the objective’s decision as failed, and lute run / lute play print letter.thank failed (by) or failed (until); all three judge done and the deadlines in the same order. The quest transition names its failedBy the same way: quest letter -> failed (by), and likewise (until), (fail), (subquest), (cascade) and (superseded). See Playing a story.

An on= objective with by= (and no until=) whose done provably implies its by can complete only in the step that raises its occasion. Anywhere else, by comes true no later than done, at a settle where the occasion is not raised, and fails the objective before done is ever judged. That is content written for 0.23.1’s raise-only by. lute check and whole-project lute check warn W-DEADLINE-BEFORE-DONE (dsl 0.24.0 §2.1) at the by= and suggest until= with the same condition; --deny W-DEADLINE-BEFORE-DONE makes it an error:

---
kind: quest
title: The hearing
state:
run.verdict: { type: { enum: [undecided, guilty, acquitted] }, default: undecided }
---
<quest id="hearing" title="See the hearing through" tier="run" start="true">
<objective id="verdict" title="Hear the verdict" on="hearing" done="run.verdict == 'guilty'" by="run.verdict != 'undecided'"/>
</quest>
hearing.lute:9:100: warning [W-DEADLINE-BEFORE-DONE] objective `verdict`'s `by="run.verdict != 'undecided'"` holds whenever its `done="run.verdict == 'guilty'"` does, and `by` is a moment judged at every settle: it fails the objective at the settle it comes true, before occasion `hearing` ever judges `done` (unless both happen in the step that raises `hearing`) — write `until="run.verdict != 'undecided'"` to judge the deadline only when `hearing` is raised

An <objective quest="childId"/> names a child quest whose completion is the objective. quest= and done= are mutually exclusive on one objective (E-OBJECTIVE-QUEST-DONE — exactly one is required); every other objective attribute admits alongside quest= (when=, optional, title=, a completion body), and the body still plays exactly once — when the objective first becomes done, i.e. when the child completes — which is the natural “child resolved” journal slot. The child is an ordinary <quest> in the same or another document; the one-line tag rule, id rules, and quest-body grammar are unchanged, and authored done= and subquest quest= objectives mix freely in one parent.

<quest id="saveTheGrove" tier="run" title="Save the Grove" start="run.act == 1">
<objective id="halsin" title="Find Halsin" quest="findHalsin"/>
<objective id="ritual" title="Stop the ritual" quest="stopRitual"/>
<objective id="scout" title="Scout the perimeter" quest="scoutPerimeter" optional/>
<objective id="talkRath" title="Speak to Rath" done="run.spokeRath"/>
</quest>

There is no new tag, no new command kind, and no parent= attribute anywhere — the reference points parent → child, and the mechanism reuses two existing surfaces plus two engine-derived rules:

DirectionRule
Objective completionCompiler synthesizes done = "quest.<child>.state == 'complete'". Derived parent completion — “all non-optional objectives done” — is unchanged; optional on a subquest objective means the child’s outcome does not gate the parent. A complete="any" parent needs only one.
Upward failureCompiler synthesizes the parent’s fail as the disjunction of the authored fail (if any) and one quest.<c>.state == 'failed' test per required child, in document order. fail’s precedence over completion is unchanged, so a required child failing resolves the parent to failed (failedBy: subquest) even if the remaining objectives could otherwise complete. Under complete="any" the synthesized part is a conjunction instead.
Downward cascadeEngine rule: on a parent’s terminal transition (failed or complete) every child still active transitions to failed (failedBy: cascade). A required child cannot be active at parent completion — its complete is part of the derived completion — so the complete arm only fails still-running optional children; under complete="any" it fails every other running child, as superseded. Recursive.
ActivationEngine rule: a referenced child with no start activates when its parent activates (replacing the accept-driven default), unless it declares activate="accept"; one with start evaluates the predicate only while the parent is active (effective gate is the conjunction). Unreferenced quests keep today’s semantics exactly.

The project’s parent→child tree is derived, not written: ObjectiveEntry.quest records the reference per artifact, and engines union the field across artifacts exactly as they union relations/rules/ prereqEdges. Multi-level trees fall out naturally — a child may itself carry quest= objectives — bounded only by the structural checks below. Child ids stay flat and project-unique; there is no parent.child namespacing.

Five diagnostics guard the shape:

  • E-OBJECTIVE-QUEST-DONE — quest= and done= on one objective.
  • E-QUEST-REF-UNKNOWN — quest= names no known quest id. Same split as after targets: same-document references resolve at check, cross-document resolution is whole-project lute check’s.
  • E-QUEST-MULTI-PARENT — one quest referenced by quest= from two parents (tree, not DAG).
  • E-QUEST-TREE-CYCLE — the parent→child edges form a cycle; self-reference is a length-1 cycle and, when parent and child share a document, check catches it early.
  • E-QUEST-TIER-MIX — a subquest’s tier differs from its parent’s. The error names both quests and both tiers. Same split again: check reports it when parent and child share a document, whole-project lute check across documents. A mixed tree would lock for good. Under a tier="run" parent, the parent’s end cascades into a user-tier child that keeps that status across runs, so the parent restarts next run waiting on a child that never becomes active again. Under a user-tier parent, a run-tier child resets to unset at every new run while the parent keeps its status, so once the parent has ended the child is never activated again. Give both quests the same tier.

Reachability propagates through the reference: a required subquest objective whose child is E-QUEST-UNREACHABLE reports E-OBJECTIVE-UNSATISFIABLE against the objective’s quest= span. The full engine treatment (both cascade directions, activation, tree reconstruction) is in quest-lifecycle.md; worked example: docs/examples/quest-subquest.lute.

A child without start joins the journal the moment its parent activates. A side path the player takes up in conversation should join only when it is offered. activate="accept" on the child (dsl 0.24.0 §2) makes it wait for an ::accept (or an accepts: mock, or lute trace --accepted-quest), like a root quest without start. It activates only while its parent is active: an accept that runs while the parent is not active is spent without effect, and the tools say so. lute play prints note: accept of quest firewood spent — its parent quest camp is not active yet … (--json: an acceptSpent record), and lute trace / lute test note the same for an accepts: mock.

“Cross the river by parley, by toll, or by force” is one goal with three routes. A parent declaring complete="any" (dsl 0.24.0 §2) completes when any one required objective is done, a child quest or a plain objective. Its other still-active children then fail with failedBy: superseded, so the routes the player did not take close themselves, and nobody copies the cascade into each sibling’s fail=. The default is complete="all".

---
kind: quest
title: The river crossing
state:
run.talked: { type: bool, default: false }
run.paid: { type: bool, default: false }
run.won: { type: bool, default: false }
---
<quest id="crossing" title="Cross the river" start="true" complete="any">
<objective id="byParley" title="Talk your way across" quest="parley"/>
<objective id="byToll" title="Pay the toll" quest="toll"/>
<objective id="byForce" title="Force the bridge" quest="force"/>
</quest>
<quest id="parley" title="Parley with the warden" activate="accept">
<objective id="terms" title="Win the warden over" done="run.talked"/>
</quest>
<quest id="toll" title="Pay the toll" activate="accept">
<objective id="pay" title="Pay the ferryman" done="run.paid"/>
</quest>
<quest id="force" title="Force the bridge">
<objective id="fight" title="Beat the guards" done="run.won"/>
</quest>

force has no activate, so it activates with crossing. parley and toll wait for a scene to ::accept them. Say the player accepts toll, then parley, then wins the warden over: parley completes, crossing completes, and the routes still open, toll and force, fail as superseded. Had the player never accepted toll, it would stay unset. lute play prints the settle:

parley.terms done
quest parley -> complete
crossing.byParley done
quest crossing -> complete
quest toll -> failed (superseded)
quest force -> failed (superseded)

One failed route leaves the others open. The synthesized fail of a complete="any" quest is the conjunction over its required objectives, each child by quest.<c>.state == 'failed' and each plain objective by its quest.<q>.objectives.<o>.failed flag, so the quest fails (failedBy: fail) only once no route is left.

Three diagnostics guard the accept side. activate="accept" beside start is E-ATTR-TYPE (pick one). ::accept of a child that activates with its parent used to do nothing; it is now E-ACCEPT-TARGET at whole-project lute check, naming the parent. And W-QUEST-NEVER-ACCEPTED flags an activate="accept" child no ::accept reaches:

---
kind: quest
title: The river crossing
state:
run.talked: { type: bool, default: false }
---
<quest id="crossing" title="Cross the river" start="true">
<objective id="byParley" title="Talk your way across" quest="parley"/>
</quest>
<quest id="parley" title="Parley with the warden" activate="accept" start="true">
<objective id="terms" title="Win the warden over" done="run.talked"/>
</quest>
scenes/ferry.lute:17:21: error [E-ACCEPT-TARGET] `::accept` targets quest `force`, which activates with its parent `crossing`; declare `activate="accept"` on it to accept it from a scene
quests/crossing.lute:20:12: warning [W-QUEST-NEVER-ACCEPTED] quest `toll` waits for an acceptance (`activate="accept"`), but no `::accept` or reward `accepts` in the project names it, so it never activates; accept it from a scene with `::accept{quest="toll"}`, declare `accept="external"` if the engine accepts it outside the script (a quest board, a menu), or remove `activate="accept"` so it activates with its parent `crossing`

<on event> is the language’s event-condition-action rule: when its event fires and its optional when guard holds, the arm’s nodes are emitted. Three built-in lifecycle events are quest-scoped — questActive, questComplete, questFailed — each firing only for its own enclosing quest. World events (e.g. combatEnd) are capability-provided by plugins.

A world event that is also a targeted occasion can be answered for one target only (dsl 0.24.0 §2). <on event="talk" target="npc.maud"> runs when talk is raised for npc.maud, and never for another target, a plain event: step, or a lifecycle transition:

<on event="talk" target="npc.maud">
@maud: Coin first, talk later.
</on>

The target is checked like an objective’s (E-BEAT-ATTR): it must be a quoted dotted id, the occasion named like the event must take a target, and the target must lie inside its domain. A lifecycle event is never raised for a target, so target on questActive, questComplete, or questFailed is E-BEAT-ATTR too. Without target, a handler answers every raise of its event.

A handler body may ::accept another quest of its document, so taking up one quest can open the next:

<quest id="salvage" title="Salvage the wreck" accept="external">
<objective id="dive" title="Dive the wreck" done="run.dived"/>
<on event="questActive">
@narrator: The wreck is marked on your map.
::accept{quest="tides"}
</on>
</quest>

lute play applies such an accept. Since dsl 0.26.0 §7 lute trace and lute test apply it too, so a test of this document with accepts: [salvage] ends with tides active, as the play does. Before 0.26.0 trace and test did not apply it, and disagreed with play.

A questFailed handler on a quest that can never fail is dead code. Whole-project lute check warns W-QUEST-HANDLER-DEAD at the handler’s event when its quest has no fail, no required objective with a by= or until= deadline, no required subquest objective whose child can itself fail (a failing required child fails its parent), and no parent quest (a parent’s end cascade-fails its still-active children). Add a fail= condition, or remove the handler:

./q.lute:10:14: warning [W-QUEST-HANDLER-DEAD] `<on event="questFailed">` never runs: quest `lampOut` cannot fail — it has no `fail` condition, no required objective with a `by=` deadline, no required subquest that can fail (a failing required child fails it), and no parent quest whose end would cascade to it; add a `fail=` condition or remove the handler

The same code (dsl 0.24.0) names a world-event handler that can only fire on a completed quest: one whose when contains every required objective’s done (any one, under complete="any"), @defs expanded. The quest settles complete as soon as that when holds, and an event reaches only an active quest’s handlers. lute check reports it too. Lifecycle events, and quests with an on= or quest= objective, are exempt:

quests/road.lute:25:14: warning [W-QUEST-HANDLER-DEAD] `<on event="combatEnd">` never runs: its `when` implies every required objective of quest `brawl` is done, so the quest has already completed when it holds, and an event reaches only an active quest's handlers; move the body to `<on event="questComplete">` or the objective's own body

Content elsewhere can also gate on quest lifecycle by reading the reserved quest.<id>.state path. It is always assigned: unset until the quest activates, then active, complete, or failed. So a read needs no has guard (has(quest.<id>.state) is always true, W-QUEST-STATE-HAS), when="quest.rescueHalsin.state == 'complete'" is a complete guard, and “not taken up yet” is quest.rescueHalsin.state == 'unset' or a <when is="unset"> arm:

<match subject="quest.rescueHalsin.state">
<when is="complete">
@shadowheart: You did well back there.
</when>
<when is="failed">
@shadowheart: We were too late.
</when>
<otherwise>
@shadowheart: We should keep moving.
</otherwise>
</match>

Quests can gate on relational facts too — start="holds('inParty', ['shadowheart'])" — see Facts & Datalog for the fact surface, worked in full by docs/examples/quest-rescue-halsin.lute.

failed says that a quest ended badly, not why. Two more reserved, read-only paths do (dsl 0.24.0 §2):

  • quest.<id>.failedBy reads unset until the quest fails, then one of fail (its fail condition held), by or until (a required objective missed that deadline), subquest (a required subquest failed), cascade (its parent ended while it was active), or superseded (its complete="any" parent completed through another route).
  • quest.<id>.objectives.<oid>.failed reads true once the objective’s by or until failed it, and false before.

Any condition or interpolation may read them, so an epilogue can tell a route the player left behind from a deadline they missed:

---
kind: scene
id: river.epilogue
title: Across the river
---
## Epilogue
<match subject="quest.toll.failedBy">
<when is="superseded">
@narrator: The ferryman is still waiting for a coin that never came.
</when>
<when is="by|until">
@narrator: You reached the ferry too late.
</when>
<when is="fail|subquest|cascade">
@narrator: The ferry burned before you could pay.
</when>
<when is="unset">
@narrator: The river is behind you.
</when>
</match>
@narrator{when="quest.letter.objectives.thank.failed"}: Maud's letter is still in your coat.

failedBy is an enum of those seven values, so the match above is exhaustive without <otherwise>, and a misspelled arm (is="superceded") is E-WHEN-LITERAL-DOMAIN. When a missed objective and fail fail a quest in the same settle, the objective’s kind wins. Declaring either path in state: is E-QUEST-RESERVED-DECL, and writing one with ::set is E-QUEST-RESERVED-WRITE. A run-tier quest’s newRun reset clears both. lute run, lute play, lute trace, and lute test agree on the values, and lute run --json carries failedBy on the failing quest record.

Since 0.49.0 whole-project lute check resolves every such read, not only the ones in a condition: a {{ quest.<id>.state }} interpolation in a line or a choice label, an interpolation argument, and a def’s cel: body in defs: each report E-QUEST-REF-UNKNOWN (quest.nope.state references quest nope, which no project quest defines) when no project quest has that id. Since 0.52 (#212) the same pass reads every value that takes the label grammar: a <branch>/<hub> prompt and a plugin directive’s text attribute too, so ::banner{… subtitle="{{quest.finalle.state}}"} reports as the same read in a line does.

Beside quest.<id>.state, every quest carries a second reserved path: quest.<id>.activatedAt, the narrativeTime instant the engine stamps at the unset → active transition. Like quest.<id>.state it is engine-populated, so it is neither author-declarable (E-QUEST-RESERVED-DECL) nor author-writable (E-QUEST-RESERVED-WRITE) — but content may read it, which is the whole point: it is the time anchor validAt(rel, t) never had.

<objective id="visitedSinceAccept" title="Go back to the station" done="holds('arrivedSpace', ['station_front']) && !validAt('arrivedSpace', ['station_front'], quest.theCoffeeDebt.activatedAt)"/>

A tag and all of its attributes must sit on one physical line, so do not wrap a long <objective …/> for readability — it is a parse error. Note the shape: validAt is a point-in-time query, true iff the fact was valid at that instant, so “since activation” is the conjunction “true now, and not yet true at activation” — a bare validAt(R, …activatedAt) asks the opposite question. Away from validAt, a narrative-time value admits only the ordering comparisons <, <=, ==, >, >=; != is E-TEMPORAL-ARG, as is any arithmetic on one. See Facts & Datalog for the interval semantics.

activatedAt is also exempt from E-MAYBE-UNSET, because a maybe-unset verdict on it would be undischargeable: no literal inhabits narrativeTime, so the slot can carry no default:, and both guard forms — has(p) and has(p) — are themselves E-TEMPORAL-ARG on a narrative-time operand. (quest.<id>.state is exempt for a different reason: it is always assigned.) The engine guarantees the stamp exists for any activated instance, and a read is only meaningful inside one.

lute scenario --json returns one report object with command, schemaVersion: "0.43.0.scenario", and ok; scenario reach --json uses 0.43.0.scenario-reach. These are reports, while compiled artifacts and run --json remain their own contracts. In automation, inspect the envelope before reading anchors, edges, or quest state. Use LUTE_TELEMETRY=0 for reproducible command examples.