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.
Scenes and after:
Section titled “Scenes and after:”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 thatid:;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.
The quest kind
Section titled “The quest kind”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 toOn 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: questid: haven.mainChainuses: ../world.schema.yaml<quest>
Section titled “<quest>”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: questtitle: The climbstate: 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: runEvery <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. startconjuncts. A quest withoutfollows=is anchored by the top-level&&conjuncts of itsstartthat readvisited('…'),entry.X.everRead, orquest.Y.state == '…'(any status butunset).start="entry.keeperLog.everRead && visited('arrival')"drawsentry(keeperLog) -> quest(relight) [start]andscene(arrival) -> quest(relight) [start]; an entry joins the graph as the nodeentry(<id>). An||of such reads is one anchor with several sources. Astart-driven quest therefore needs no copy of itsstartinfollows=.
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.
<objective>
Section titled “<objective>”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.
Objective titles are plain text
Section titled “Objective titles are plain text”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 lineA quest’s own title= was never interpolated and is unchanged.
<reward> ids
Section titled “<reward> ids”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: questtitle: The climbstate: 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 }]Reward effects
Section titled “Reward effects”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: questid: visits.queststitle: 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’skind=, 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-TARGETfor an unknown id (with a did-you-mean), for a quest that has astart, and for an auto-activating child. The anchor is the reward’starget=value when the id came from@target, otherwisekind=. 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@targetis not applied, with no extra diagnostic. A kind whosetargetisrequired: truealready reportsE-REWARD-TARGET. W-QUEST-NEVER-ACCEPTED. A rewardacceptsthat 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-CREDITis 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:
- the
grantrecord and itscreditswrite, as before; - the kind’s
retracts, then itsasserts, through the same path as::retract/::assert(including the runtimeE-FACT-EXCLUSIVEcheck); - its
accepts, through::accept’s path: an accept-driven quest activates on the next lifecycle round, and a quest already pastunsetis 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.
Reward-kind attributes
Section titled “Reward-kind attributes”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
truefor abool), typed by its declaration with the directive codes:rarity="silver"isE-BAD-ENUM(`silver` is not a valid value for `rarity` of `<reward kind="BADGE">` (expected one of: common, rare, gold)), arequired: trueattribute left out isE-MISSING-ATTR, and an@refvalue isE-REWARD-ATTR, since a reward’s attributes are static data the engine grants against. - Unknown attributes.
E-UNKNOWN-ATTRlists 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.
@raritybinds to the authored value, else the declareddefault:; an attribute with neither is unbound, and an effect that reads it is not applied, as for a missingtarget=. 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’sgrantrecord copies it ontoreward:
{"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.
Quests meet scenes and occasions
Section titled “Quests meet scenes and occasions”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` conditionAccepting 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: sceneid: town.boardtitle: 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: questtitle: The wreckstate: 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 tracetakes the quest up withaccepts: [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 silencesW-QUEST-NEVER-ACCEPTEDfor a quest that has neitheraccept="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 playprintsquest salvage accepted (engine)and settles the lifecycle, so the quest’squestActivehandlers run on that step.engine: { accept }takes accept-driven quests only; a quest with astartis 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 acceptedRoutes into an accept-driven quest
Section titled “Routes into an accept-driven quest”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’seffects.accepts— activates the quest at the next lifecycle round whatever itsfollows=says.followsis 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::acceptor reward naming it has no route,follows=or not:followsis ordering metadata and never a route. With afollows,reachsaysUnknown(below); without one the quest staysUnanchored, 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: questtitle: The boardstate: 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:
$ lute scenario --project . reach --node quest:gatedproject 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.
$ lute scenario --project . reach --node quest:boardproject 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)Deadlines
Section titled “Deadlines”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: questtitle: The letterstate: 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
doneis judged beforeby. So an objective whosedoneandbybecome true at the same moment counts as done, and a done objective never fails later. The step that raises anon=objective’s occasion judges that objective’sdonefirst and itsbyonly 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 makesbytrue. byis a moment: it is judged at every lifecycle settle, for every objective,on=or not (dsl 0.24.0 §2.1).thankabove fails in the settle that reaches day four withrun.answeredstill false, whether or not the player ever talks to Maud again. (0.23.1 judged anon=objective’sbyonly 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 itsdone. So the moment the occasion answers is both the judgement and the deadline, and a beat on that occasion that writes the stateuntilreads cannot fail a correct answer.sorryabove 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.untilneedson=(E-BEAT-ATTRwithout it: a deadline that holds everywhere isby=).- Quests settle after every beat an occasion presents, so a
bycan pass between two beats of oneselect: sequenceoccasion. - The failure is readable:
quest.<id>.objectives.<oid>.failedistruefrom then on, and a failed required objective sets its quest’sfailedBytobyoruntil. A new run clears both for atier="run"quest. byanduntilare checked likedone: an undeclared path isE-UNDECLARED, and a read that may be unset isE-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: questtitle: The hearingstate: 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 raisedSubquests
Section titled “Subquests”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:
| Direction | Rule |
|---|---|
| Objective completion | Compiler 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 failure | Compiler 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 cascade | Engine 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. |
| Activation | Engine 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=anddone=on one objective.E-QUEST-REF-UNKNOWN—quest=names no known quest id. Same split asaftertargets: same-document references resolve atcheck, cross-document resolution is whole-projectlute check’s.E-QUEST-MULTI-PARENT— one quest referenced byquest=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,checkcatches it early.E-QUEST-TIER-MIX— a subquest’stierdiffers from its parent’s. The error names both quests and both tiers. Same split again:checkreports it when parent and child share a document, whole-projectlute checkacross documents. A mixed tree would lock for good. Under atier="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 tounsetat 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 sametier.
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.
Taken up in dialogue: activate="accept"
Section titled “Taken up in dialogue: activate="accept"”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.
Alternatives: complete="any"
Section titled “Alternatives: complete="any"”“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: questtitle: The river crossingstate: 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: questtitle: The river crossingstate: 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 scenequests/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`Lifecycle reactions with <on>
Section titled “Lifecycle reactions with <on>”<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 handlerThe 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 bodyContent 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.
Why a quest failed
Section titled “Why a quest failed”failed says that a quest ended badly, not why. Two more reserved, read-only paths do (dsl 0.24.0
§2):
quest.<id>.failedByreadsunsetuntil the quest fails, then one offail(itsfailcondition held),byoruntil(a required objective missed that deadline),subquest(a required subquest failed),cascade(its parent ended while it was active), orsuperseded(itscomplete="any"parent completed through another route).quest.<id>.objectives.<oid>.failedreadstrueonce the objective’sbyoruntilfailed it, andfalsebefore.
Any condition or interpolation may read them, so an epilogue can tell a route the player left behind from a deadline they missed:
---kind: sceneid: river.epiloguetitle: 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.
quest.<id>.activatedAt
Section titled “quest.<id>.activatedAt”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.
JSON reports for quests
Section titled “JSON reports for quests”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.