Beats
A lot of games do not advance on a clock. The story moves when something happens: a hub visit, entering a room, talking to an NPC, a new day, the start of a run. At that moment the game picks one piece of story whose conditions hold, or none.
Lute calls those moments occasions and the pieces of story that answer them beats (dsl
0.21.0). The engine raises occasions. Lute declares which beats answer each one, when each is
eligible, and which eligible beat wins. A scene, a lore entry, or a
<beat> block in a lore document (a bundle beat) becomes a beat by naming the
occasion it answers. Everything else about the document stays the same.
A scene beat
Section titled “A scene beat”---kind: sceneid: achilles.gifton: talktarget: npc.achilleswhen: 'user.runs >= 10 && !run.giftRefused'priority: 50once: userstate: user.runs: { type: int, default: 0 } run.giftRefused: { type: bool, default: false }---
## Opening {#opening}
@achilles: You have been at this a while, lad. Take this.When the engine raises talk for npc.achilles, this scene is a candidate. It is eligible once
the player has started ten runs and has not refused the gift this run. It has never been shown
before, because once: user spends it for good the first time it plays.
Scene beat keys
Section titled “Scene beat keys”| Key | Meaning |
|---|---|
on | the occasion this scene answers; it is what makes the scene a beat |
target | optional; the scene is a candidate only when the occasion is raised for this target, a dotted id in the <entry target> shape (npc.achilles, place.lab_b2). When the occasion declares a target domain, it must be <prefix>.<member> of that domain, and one of its listed members: when it lists them. kind:<kind> (dsl 0.26.0 §5) answers every member of a kind instead: see Kind targets |
when | optional CEL condition over run / user / app state, quest.*, entry.<id>.read / entry.<id>.everRead, and fact queries (holds(…), count(…)) |
priority | optional integer, default 0; higher wins |
once | run (the default: at most once per run), user (at most once ever), or false (repeatable). On a project with a clock, also day (at most once per clock day) or slot (at most once per clock slot), and week (at most once per clock week, dsl 0.27.0 §5) when the clock declares a week:. On a project with a declared season, season:<name> (at most once per window of that season) |
also | optional true / false, default false (dsl 0.23.0). On a select: first occasion, an also beat is a side remark: it is presented after the winner instead of competing with it. See Side remarks with also |
share | optional; a project-wide key, written beside a spending once (dsl 0.25.0 §2). Every beat with the same key is spent together: see One event, several places |
spentBy | optional CEL condition that spends the beat instead of a presentation: once it has held the beat stays spent for its once period (run unless written): see Until it is solved: spentBy |
for | optional "kind:<kind>", on an untargeted select: sequence occasion, instead of target (dsl 0.27.0 §3). The scene is presented once per member whose when holds, reading the member as occasion.target: see Once per member |
The beat keys are scene-only and never come from project defaults:. A beat belongs to one scene.
when is an ordinary CEL slot, checked the way a quest start is: the profile’s CEL surface,
definite assignment, and the unset-sentinel rules all apply. The one extra rule is that a when
may not read the scene’s own scene.* state. That state does not exist until the scene starts.
after: keeps its meaning. It is the structural prerequisite over visited / completed /
active that connectivity analyzes. A beat is eligible only when
both after: and when hold, so put route order in after: and state conditions in when.
A scene without on: answers no occasion, so no occasion presents it: your engine starts it
itself, by its scene id, the way it started every scene before occasions existed. lute trace and
lute run walk such a scene on its own, and lute play never presents it. To chain scenes into a
story you can play with lute play, see Connect scenes into a story.
when, target, priority, once, also, share, spentBy, or for without on is an error:
---kind: sceneid: courtyard.rumorpriority: 5---
## Opening {#opening}
@narrator: A rumor drifts across the courtyard.Entry beats
Section titled “Entry beats”A lore entry answers an occasion with on=, priority=, and once= beside its existing target
and when:
---kind: loretitle: Achilles barksstate: user.runs: { type: int, default: 0 }---
<entry id="achillesBark1" on="talk" target="npc.achilles" category="bark"> @achilles: Keep your guard up.</entry>
<entry id="achillesBark3" on="talk" target="npc.achilles" category="bark" priority="10" when="user.runs >= 3"> @achilles: Back again, lad.</entry>
<entry id="achillesMorning" on="talk" target="npc.achilles" category="bark" priority="20" once="run"> @achilles: Up early again? Mind the stairs.</entry>
<entry id="achillesFirstMeeting" on="talk" target="npc.achilles" category="bark" priority="30" once="user"> @achilles: So you are the one who keeps coming back.</entry>Without once, an entry beat is repeatable. Being read again is normal for an entry: an NPC
repeats a bark, a codex page stays open. once (dsl 0.22.0) makes it spendable. run and user
spend an entry by its own read flags rather than by a presentation record:
once | Not eligible while | Eligible again |
|---|---|---|
| absent | never spent | — |
run | entry.<id>.read is set: it was read this run | at the next run, which resets read |
user | entry.<id>.everRead is set: it was read in any run | never |
day | it was presented earlier this clock day | on the next day |
week | it was presented earlier this clock week | when the next clock week starts |
slot | it was presented earlier in this clock slot | in the next slot |
season:<name> | it was presented since the season last opened | when the season opens again |
The engine sets both flags after an entry’s first read in a run, however it was presented, so an
entry the engine looked up by its target spends a run or user once too. See
Reading twice for the flags themselves. day and slot
(dsl 0.24.0 §1) need a declared clock. They count time rather than reads: the
engine remembers where on the clock the entry was last presented, as it does for a scene beat, so a
bark with once="slot" answers again in the next slot although entry.<id>.read is still set.
An entry’s ::set and ::retract apply on its first read in a run only (with for=, each
member’s first read); a later presentation in the same run shows the text and changes nothing. A
repeatable entry beat that writes is therefore a counter that stops at one, and so is one with a
once shorter than the run or a spentBy. lute check warns W-ENTRY-WRITE-REREAD at the
first such write, naming the remedies for the entry’s shape; an ::assert is exempt, since the
fact holds for the rest of the run either way. Give achillesBark1 a ::set{run.nagged += 1}:
./lore/barks.lute:9:3: warning [W-ENTRY-WRITE-REREAD] `<entry id="achillesBark1">` has no `once`, so every raise of `talk` can present it again in a run, but its `::set` applies on the first read in a run only — later reads skip it; to apply it on every presentation, put the body in a `<beat on="talk" target="npc.achilles" once="false">` — a beat's writes apply each time it is presented; for one application per run, write `once="run"` on the entry; to keep the write to the first read on purpose, guard it with `when="!entry.achillesBark1.read"`The defaults differ: an <entry> without once is repeatable, a <beat> without once is spent
for the run, so the beat that repeats a write says once="false". See
Reading twice for every shape the rule covers.
once takes run, user, day, week, slot, or season:<name>; once="false" is the same as
leaving the attribute out, a repeatable entry. Any other value is E-BEAT-ATTR, and so are day,
week, and slot in a project without a clock (week also needs the clock’s week:), and once=
or priority= without on=: a repetition policy belongs to a beat. A season that no schema
declares is E-SEASON-DECL. Before 0.22.0 the same effects were spelled as conditions,
when="!entry.<id>.read" for once per run and a user.* flag the entry set for once ever. Those
still work, but once says it directly. An entry that should answer until something is done
takes spentBy= (with once= saying how long it then stays spent), and an entry
on a select: sequence occasion may be read once per member with for=.
An entry never rides along another beat, so also on an <entry> is
E-BEAT-ATTR too. An entry beat takes share= beside its
once= (dsl 0.25.0 §2): reading it spends every beat of its key.
On an occasion declared without a target, an entry’s target= is metadata (dsl 0.24.0 §6). It says
what the entry is about, as it does on an entry the engine looks up, and the entry answers every
raise of the occasion:
---kind: loretitle: Shop window---
<entry id="compassNote" on="shopVisit" target="item.compass" category="item"> @narrator: A brass compass sits in the window, its needle trembling.</entry>lute play, lute calendar, lute beats, and the beat warnings treat such an entry as
untargeted. Before 0.24.0 this was E-BEAT-ATTR. A scene’s or bundle beat’s target on an
untargeted occasion still is, because for them the target restricts which raises they answer.
Beat bundles
Section titled “Beat bundles”An interview, a round of talk at the bar, a day’s worth of short NPC moments: many beats are a
few lines and a choice each, and a file per beat scatters them across the project. A lore document
can keep them together (dsl 0.23.0). A <beat> block is a scene beat written inside a lore
document, beside its entries, and its body is a scene body: lines, branches, hubs, <match>, and
directives.
---kind: loreid: interviewstitle: Station interviewsstate: run.porterTrust: { type: int, default: 0 }---
<entry id="porterNote" target="item.porter_note" category="note"> @narrator: A note in the porter's hand: "Lamps out at ten."</entry>
<beat id="porter" on="talk" target="npc.porter" title="The porter" priority="5"> @porter: You again. <branch id="night"> <choice id="ask" text="Ask about the night"> @porter: I saw nothing. Nothing I'll say twice. ::set{run.porterTrust += 1} </choice> <choice id="leave" text="Leave him be"> @porter: Good. </choice> </branch></beat>
<beat id="porterAgain" on="talk" target="npc.porter" title="The porter, again" after="visited('interviews.porter')" when="run.porterTrust >= 1"> @porter: Fine. The lamps went out before ten.</beat>A <beat> takes the scene beat keys as attributes:
| Attribute | Meaning |
|---|---|
id | required; a name (letters, digits, _ or -, not starting with -), unique in the document |
on | required; the occasion the beat answers |
target, when, priority, once, also, share, spentBy, for | as on a scene beat. once="false" makes the beat repeatable, and also may be written bare |
after | optional (dsl 0.25.0 §3); a scene after: formula, with the same meaning: an eligibility conjunct and an edge of the scenario graph |
title | optional; the label an engine shows for the beat in a select: all menu, localized like an entry title |
A bundle beat is a scene beat that lives in a different file:
- Its canonical id is
<document id>.<beat id>,interviews.porterabove, so the document must declare anid:. That id is the beat’s row inproject.index.jsonand its key in the presentation record. It shares the project-wide namespace of document ids, so a scene whoseid:is alsointerviews.porterisE-CONN-EPISODE-ID-DUP. oncedefaults torunand is spent by presentation, as a scene’s is. Read flags do not spend it, because it is not an entry. With a clock,once="day",once="week", andonce="slot"work as on a scene, and so doesonce="season:<name>"with a declared season.- Presenting it marks the canonical id visited, so
visited('interviews.porter')reads it in any condition. Since dsl 0.24.0 §2 it is a legalafter:predecessor: a scene’safter: "visited('interviews.porter')", or a quest’s, names it, and connectivity routes through it like a scene. (Before 0.24.0 that wasE-CONN-UNKNOWN-NODE.) - Since dsl 0.25.0 §3 it may declare an
after="…"of its own, asporterAgaindoes. The formula has a sceneafter:’s grammar and meaning: the beat is eligible only once it holds (lute playandlute calendarlist it— after: prerequisite not satisfied, andlute trace --beatnotesnot eligible: `after` prerequisite not satisfiedon the beat’s head without enforcing it), andlute scenariodraws its edges. A malformed formula isE-CONN-PROFILE, and a node no document declares isE-CONN-UNKNOWN-NODEat whole-projectlute check. Avisited()conjunct in a beat’swhenstill gates it, but draws no edge, solute scenariolists such a beat as unanchored and names theafter=to write. Put route order inafter=and state inwhen, as on a scene. - It is checked like a scene beat.
E-OCCASION-UNKNOWN,E-BEAT-UNREACHABLE, and the whole-projectlute checkwarnings below name it by its canonical id. - Its lines are addressed under the canonical id. The porter’s first line is
interviews.porter.porter_0010, and the title’stitleLineIdisinterviews.porter.title.
Shape faults are E-BEAT-ATTR: a missing, malformed, or duplicate id, a document with beats but
no id:, a missing on, or a bad priority, once, also, share, or target. Any other
attribute is E-UNKNOWN-ATTR, and a <beat> in a scene document is not admitted. Entries
and beats may interleave in any order. Lore entries
covers the entry side of the file.
Beat templates: use=
Section titled “Beat templates: use=”A roster of heroes, each with three bond stories, repeats one header shape dozens of times. A
beat template writes it once (dsl 0.27.0 §6). It is a component
whose frontmatter also declares a beat: header:
component: bondStoryparams: { who: { type: string }, need: { type: int, default: 0 }, prev: { type: string, default: "" } }beat: on: bond once: user when: "user.bond >= @need" after: "@prev" # left out when emptyA bundle beat names it with use= and passes the params as attributes:
<beat use="bondStory" id="first" who="aria"> @aria: Thank you for staying.</beat>
<beat use="bondStory" id="second" who="aria" need="2" prev="bonds.first"> @aria: You came back.</beat>- Every header key the
<beat>does not write itself comes from the template, with each@paramreplaced by the argument’s text (or the param’sdefault:). A key that comes out empty is left out, and anafter:that comes out a bare id meansvisited("<id>"). The header keys are the<beat>attributes:on,target,for,title,priority,once,share,after,when,spentBy, andalso(also: true, unquoted). A{{@who}}intitle:renders the argument, aspeaker’s cast name. - In
when:andspentBy:a member of aper:family is read the way a component body reads it:user.bond[@who]becomesuser.bond.ariaforwho="aria". The dot formuser.bond.@whoalso works in a header, with aW-TEMPLATE-DOT-PARAMhint toward the brackets. - An attribute written on the
<beat>wins:<beat use="bondStory" … once="run">. It replaces the template’s key whole, sowhen="@isNight"on a use drops the template’s ownwhen, andW-TEMPLATE-OVERRIDEsays so, naming the dropped condition and any argument only it read.lute beatsshows the use’s own guard with the note(replaces template `bondStory`'s `when: …`)(replacesTemplateWhenin--json). To keep both, write both in the use’swhen=. To let a use add a condition, give the template a param for it and conjoin it in the header:only: { type: string, default: "" }andwhen: "!holds('defeated', [@who]) && (@only)". A use writesonly="@isNight"; a use that leaves it empty (ortrue) derives just!holds('defeated', ['<who>']), with no&& (true)left behind. - A header value with no
@paramin it is the same for every use, so a fault in it (an unknown occasion, a badonce:) is reported once, at the header key in the component, and the uses derive nothing for that key. - The template’s own body plays first, then the beat’s. A
::bodyline at the top level of the template body places the beat’s body there instead, so a template can frame it. A template’s body needs no##heading: like a bundle beat’s, it is written straight after the frontmatter. - The other attributes are the template’s params, checked like
::usearguments (E-COMPONENT-ARG). A header key that reads a missing or refused argument is not derived, so one bad argument is one report. A param named like a header key (title,once,id, …) could never be passed, because the attribute sets the beat’s own key; it isE-TEMPLATEwhere it is declared. Rename it (title→heading). - A template with no body and a self-closing use make a one-line beat:
<beat use="trainer" id="r3Joey" who="joey"/>.
A template use is an ordinary beat to every tool: lute beats lists the derived header, and play,
trace and the compiled artifact present it as written out. Misuse is E-TEMPLATE: a use= that
names no imported component (with a did-you-mean), a component without beat:, a malformed header,
or ::body anywhere but the top level of a template.
Which beat wins
Section titled “Which beat wins”When the engine raises occasion O, optionally for target T:
- The candidates are the beats with
on: Owhosetargetis absent or equal toT, and (dsl 0.26.0) the kind beats whose kind hasT’s member. Scene, entry, and bundle beats on the same occasion compete in one list. - A candidate is eligible when its
after:(a bundle beat’safter=) andwhenhold and itsonceis not spent: a scene’s or bundle beat’s by its presentation record, an entry’s by its read flags (or, foronce="day"/"week"/"slot"/"season:<name>", by when it was last presented). A beat with asharekey is spent when any beat of its key is, and a beat withspentByonce its condition has held. - Eligible beats are ordered by priority, descending, then project order: document path,
then declaration order within the document.
project.index.jsonlists every beat in that order underbeats. At equal priority a kind beat comes after the other candidates, so the beat that namesTitself outranks it. - The occasion’s
selectdecides what is presented. Forselect: firstthe engine presents the first eligible beat that is notalso(the winner), then every eligiblealsobeat. Forselect: sequenceit presents every eligible beat, in order. Forselect: allit offers the whole ordered list and the player picks one, or closes the list without picking. Closing it presents nothing and spends nothing. An engine labels that menu with each beat’stitle(a scene’stitle:, an entry’s or bundle beat’stitle=), whichproject.index.jsoncarries on every beat row (dsl 0.23.0). - If no beat is eligible, the occasion passes with no story and the engine does its default.
Take the four barks above and a player on their fifth run. The first time they ever talk to
Achilles, the engine presents achillesFirstMeeting (priority 30), and once="user" spends it for
good. The next talk that run presents achillesMorning (priority 20). After that it presents
achillesBark3 (priority 10) for the rest of the run, and achillesBark1 answers only while
user.runs is below 3. Every later run opens with achillesMorning again, because a new run
resets its entry.achillesMorning.read.
One event, several places: share
Section titled “One event, several places: share”Sometimes one event can happen in different places. Sol is warm with you once a day: on the radio
in the morning, on the roof at night, whichever comes first. Two beats with once="day" each spend
only themselves, so the player would get both. Beats that stand for one event may share one spend
(dsl 0.25.0 §2):
<beat id="solRadio" on="talk" target="npc.sol" once="day" share="solWarm" when="run.slot == 'morning'"> @sol: The radio says clear skies. Stay a while.</beat>
<beat id="solRoof" on="talk" target="npc.sol" once="day" share="solWarm" when="run.slot == 'night'"> @sol: Up here you can see the whole bay.</beat>
<beat id="solNod" on="talk" target="npc.sol" once="false" priority="-1"> @sol: Hey.</beat>Beats with the same share key, anywhere in the project, are spent together: once one of them is
presented (an entry: read), every beat of the key is spent for the once period, exactly as if
each had been presented. Scene beats (share: in frontmatter), entry beats (share=), and bundle
beats (share=) may share one key. In the morning the radio beat plays, and the roof beat is spent
for the rest of the day. lute play says why:
── step 3 · talk → npc.sol ────────────── ✓ talks.solNod [beat, priority -1] ✗ talks.solRadio [beat, priority 0] — once: day — already presented today ✗ talks.solRoof [beat, priority 0] — once: day — `share: solWarm` already spent today by talks.solRadio → talks.solNodA key is a name (letters, digits, _ or -, not starting with -). It needs a spending once written beside it:
share with no once, or with once: false, is E-BEAT-ATTR, since a repeatable beat has
nothing to spend. Every beat of one key must declare the same once, because the key is spent for
one period. Add a beat solDock to a lore document harbor with once="user" share="solWarm",
and whole-project lute check reports the mismatch on the radio and roof beats:
./lore/talks.lute:7:66: error [E-BEAT-ATTR] beat `talks.solRadio` shares `solWarm` with beat `harbor.solDock`, but declares `once: day` where beat `harbor.solDock` declares `once: user`; the beats of one `share` key are spent together for one period, so every one of them declares the same `once`The key replaces the old workaround of stamping the day into state and guarding every beat on it
(::set{run.solWarmDay = clock.index}). lute beats shows the
key in its once column (day, share solWarm), lute calendar
spends the key together, and W-BEAT-PRIORITY-TIE and cast presence read the whole key’s spent
flags. The key reaches the IR as share on the beat’s record and on its project.index.json row,
absent when unauthored.
Until it is solved: spentBy
Section titled “Until it is solved: spentBy”A puzzle the player may fail and retry is not spent when it is shown. It is spent when it is solved. Before 0.27.0 that took a repeatable beat and a guard on the result:
<beat id="valves" on="townVisit" once="false" when="!holds('solved', ['valves'])"> @narrator: Three valves, one pressure gauge. The needle waits.</beat>spentBy says it directly. It is a CEL condition, checked like when, and it spends the beat
instead of a presentation: the beat stays eligible, presentation after presentation, until the
condition holds, and from then on it is spent:
<beat id="valves" on="townVisit" spentBy="holds('solved', ['valves'])"> @narrator: Three valves, one pressure gauge. The needle waits.</beat>A scene writes it in frontmatter (spentBy: "holds('solved', ['valves'])"), an entry and a bundle beat
as an attribute (spentBy="…"). The condition may read anything a when may. It is a latch:
once it has held — in the settled world after any settle of the playthrough (every presentation,
engine write, clock move and new run), or when the beat is judged — the beat is spent, and a
condition that turns false again does not bring it back. The latch looks only once a settle is
done, so a start="true" quest is already active when it first reads it, never the unset it
had a moment before. For a kind beat or a
for= beat the condition is judged per member, occasion.target bound,
and spends that member.
once beside spentBy sets how long the beat stays spent: run unless written (spendable again
in the next run), user (for good), day / slot / week (until the clock’s period moves on),
or season:<name> (until the season opens again). A weekly puzzle that resets writes
once="week". once="false" would keep nothing spent, so beside spentBy it is E-BEAT-ATTR:
a beat that should answer only while something is false writes when="!(…)". A
share key spends beats by presentation, so it cannot sit
beside spentBy either. A spentBy that already holds at the start of play (every state path
at its default, only the seed facts, each quest unset until its start holds) is
W-BEAT-SPENT-AT-START: the beat would be spent before it could play, and the message names
the when: "!(…)" rewrite.
A latch is not a condition judged afresh at each raise, and the two differ when the condition can
turn false again after it has held. Whole-project lute check warns W-SPENT-BY-REVERSIBLE on a
spentBy beat with no once written whose condition reads a fact some ::retract / ::assert
or a directive’s declared effect can undo, a season’s state, facts or quest (each reset when the
season opens again), or a quest a rearm returns to unset. The message names the rewrite:
once: false with when: "!(…)" for a beat that should come back whenever the condition is
false again, once: season:<name> for a condition over one season, or once: run to keep the
latch on purpose. Writing once states the period, so it silences the warning.
lute play, lute calendar, and lute test’s eligible: report a spent beat as ineligible with
the reason spentBy: `run.solved` holds while the condition holds and
spentBy: `run.solved` held — spent this run once it has held (a kind beat’s reason names the
member: … held for cod — spent this run). The condition reaches the IR as spentBy: {cel, expr, authored?} on
the scene meta.beat, the beat record, or the entry record, and as its raw text on the beat’s
project.index.json row; the beat’s once there is how long it stays spent (run when unwritten
on a scene or bundle beat, absent on an entry).
Composing an occasion
Section titled “Composing an occasion”select: first presents one beat per occasion, and that is usually right: a single main beat keeps
“what happens when I talk to Maud” easy to explain. Two additions in dsl 0.23.0 cover the moments
where one beat is not enough.
A routine, then the day’s event
Section titled “A routine, then the day’s event”An occasion declared select: sequence presents every eligible beat, one after another, in
selection order:
occasions: evening: { select: sequence }Take a supper scene on evening with priority: 5 and once: false that ends with
::set{ run.day = run.day + 1 }, and a letter scene on evening with when: 'run.day >= 2'. The
first evening presents supper, then the letter: once a beat of the raise has played, each later
beat is judged again just before its turn, so the letter sees the day supper moved to 2. The second
evening presents supper alone, since the letter’s default once: run is spent. A beat whose when
an earlier beat of the raise made false is skipped the same way. A sequence has nothing to pick, so a lute play
step that writes pick: on a sequence occasion is a usage error.
Side remarks with also
Section titled “Side remarks with also”On a select: first occasion, a scene or bundle beat with also: true does not compete for the
win. When it is eligible, it is presented after the winner, in addition to it. It is still
presented when no main beat is eligible at all.
---kind: sceneid: town.caton: townVisitalso: truewhen: 'run.day >= 2'once: falsestate: run.day: { type: int, default: 1 }---
## Opening {#opening}
@narrator: The stray cat is back on the fountain's rim, watching you.The winner is the first eligible beat that is not also, whatever the also beat’s priority.
Several eligible also beats follow the winner in selection order, and each spends its own
once. The cat above says once: false, so from day two it follows whichever beat wins every
hub visit.
also only means something where a single beat wins. On a select: all or select: sequence
occasion every eligible beat is already offered or presented, so also: true there is
E-BEAT-ATTR:
./scenes/supper-aside.lute:6:7: error [E-BEAT-ATTR] `also: true` applies only to a `select: first` occasion; `evening` is `select: sequence`, which already presents or offers every eligible beat — remove `also:`So is an also value other than true or false. W-BEAT-SHADOWED and W-BEAT-PRIORITY-TIE
ignore also beats: an also beat never shadows another beat, is never shadowed, and never ties.
An also beat is judged once, when the occasion is raised; a sequence beat is judged again at its
turn, as above. Each presented beat spends its own once, and quests settle after each one, so a
quest deadline is judged between two beats of one
occasion. The <on event> handlers the raise answers, a questComplete included, run after all of
its beats.
The project’s terminal: is judged when an occasion is raised, so the raise that makes it hold
plays out: the beat whose write ends the game finishes, and the rest of its sequence and its
handlers follow. That is where an epilogue goes, after the write or in the questComplete handler
of the quest terminal: reads. See The game is over for the
other things “end” means.
Occasions
Section titled “Occasions”Occasions are engine vocabulary. A plugin declares them with an occasions export:
occasions: townVisit: { select: first } talk: { select: first, target: { prefix: npc, entity: person } } examine: { select: first, target: true } inbox: { select: all } evening: { select: sequence }Until some resolved plugin declares occasions, occasion names are shape-only: any name
is accepted, so you can write beats before the engine’s plugin exists. Once any plugin declares
them, a beat naming an undeclared occasion is E-OCCASION-UNKNOWN, and a scene’s or bundle beat’s
target on an untargeted occasion (one declared with neither target: true nor a domain) is
E-BEAT-ATTR. An entry’s target there is metadata (dsl 0.24.0 §6). The
declaration format, select, and target are covered on Playing a story.
An occasion can also judge quest objectives: <objective on="runEnd" …> evaluates its done only
when runEnd is raised while its quest is active, and its occasion is checked against the same
vocabulary. Since dsl 0.23.0 an objective can also name a target. <objective on="talk" target="npc.maud" …> is judged only when talk is raised for npc.maud, and its target is checked
exactly like a beat’s (E-BEAT-ATTR): a dotted id, only with on, only on an occasion that takes a
target, and inside the occasion’s domain. Without target, an objective is judged whenever its
occasion is raised, whatever the target. See
Quests & scenes.
A raise answers quests after its beats. Once the occasion’s beats have been presented (or none
was eligible, or the player closed a select: all list without picking), the raise does two more
things, in this order:
- When a plugin also declares a world event of the same name under
events:, raising the occasion fires it: every active quest’s<on event>handler for that name runs, once. The event carries no target. - The occasion judges the
on=objectives of every active quest, so an objective can read what a handler just wrote. Then the quests settle.
An occasion that only objectives or same-named handlers reference is still raised: it presents
nothing and does just these two steps. lute play, lute run, and
lute trace answer a raise in the same order. So when a plugin declares
dayEnd as both an occasion and a world event, an active quest’s <on event="dayEnd"> handler
runs every time the engine raises dayEnd, with no separate event to fire.
Target domains
Section titled “Target domains”target: true keeps its 0.21.0 meaning: the occasion is raised for something, and a beat’s
target is any dotted id. So a typo such as npc.achilels names a target the engine never raises,
and the beat silently never plays.
A target domain { prefix, entity } (dsl 0.22.0) also says which things. Every target of the
occasion is <prefix>.<member>, where <member> is a member of the entity kind entity: the same
entities: vocabulary your facts use, read from the document’s schema (its uses:, or the
project’s defaults:). With the talk declaration above and
entities: person: { members: [maud, oskar] }target: npc.oskar answers talk, while target: npc.osker is E-BEAT-ATTR with a did-you-mean:
./scenes/oskar.lute:5:9: error [E-BEAT-ATTR] target `npc.osker` is outside occasion `talk`'s domain `npc.<person>` (`npc.maud`, `npc.oskar`) — did you mean `npc.oskar`?So is place.oskar, which has the wrong prefix. For an open: kind, whose members the engine
populates, any <prefix>.<id> passes. A domain naming a kind the document’s schema does not declare
is E-BEAT-ATTR on every target of that occasion. Scene, entry, and bundle beat targets are
checked alike, and so are objective targets. lute play refuses a step target
outside the domain with the same did-you-mean.
A domain can also narrow its kind with members:, when the engine raises the occasion for only
some members:
occasions: bossDefeated: { select: first, target: { prefix: boss, entity: foe, members: [gatekeeper, warden] } }With foe: { members: [gatekeeper, warden, cinderhound] } under entities:, the occasion’s
targets are boss.gatekeeper and boss.warden only. target: boss.cinderhound is E-BEAT-ATTR
although cinderhound is a foe, and the did-you-mean runs over the listed members:
./scenes/hound.lute:5:9: error [E-BEAT-ATTR] target `boss.cinderhound` is outside occasion `bossDefeated`'s member list (`boss.gatekeeper`, `boss.warden`), a subset of entity kind `foe`Every listed member must belong to the kind. One the kind does not declare makes every target of
the occasion E-BEAT-ATTR, well-spelled ones included, and the message names the stray member:
occasion `bossFled` lists `wardne` in its target `members:`, but `wardne` is not a member of entity kind `foe` — did you mean `warden`?. An open: kind’s members are not known to the
checker, so there the list is taken as written. An empty list, or a member listed twice, fails the
plugin load (see Manifests). The subset narrows every consumer of the
domain alike: beat, entry, and objective targets, a lute play step’s target:,
lute new scene --target, and lute calendar --target.
Kind targets
Section titled “Kind targets”A beat may answer every member of a kind with target="kind:<kind>" (dsl 0.26.0 §5). A
bug-catching contest scores a dozen species in a few tiers. Rather than one caught beat per
species, the project declares the tiers as sub-kinds of species, the entity kind of the
occasion’s { prefix: mon, entity: species } domain:
entities: species: { members: [inchlet, cocoonix, stingle, hornbeetle, bladebug] } bugRare: { subsetOf: species, members: [hornbeetle, bladebug] } bugCommon: { subsetOf: species, members: [inchlet, cocoonix, stingle] }and writes one beat per tier, in a lore document contest:
<beat id="catchRare" on="caught" target="kind:bugRare" once="false" when="run.contest == 'entered'"> ::set{run.contestScore = 3} @narrator: A {{occasion.target}}! The nets all around you go still.</beat>
<beat id="catchCommon" on="caught" target="kind:bugCommon" once="false" when="run.contest == 'entered'"> ::set{run.contestScore = 1 when="run.contestScore < 1"} @narrator: A {{occasion.target}}. It counts.</beat>
<beat id="firstInchlet" on="caught" target="mon.inchlet" once="user"> @narrator: Your very first inchlet. The judges smile.</beat>- The kind.
kind:<kind>names a closed kind (one withmembers:) inside the occasion’s domain: the domain’s own kind or one of its sub-kinds. The occasion must be declared by a plugin with a{ prefix, entity }domain. Anything else isE-BEAT-ATTR, with a did-you-mean for a misspelled kind. The beat is a candidate whenever the occasion is raised for<prefix>.<member>of one of its members. Scene, entry, and bundle beats take a kind target alike, and it counts as a read of the kind, so the kind draws noW-DOMAIN-UNREAD. occasion.target. In the beat’swhen, its guards, and its text,occasion.targetis the member the occasion was raised for (inchletformon.inchlet), typed by the kind. It is a value you compare (occasion.target == 'inchlet') or match on: a<match subject="occasion.target">checks its arms against the kind’s members, and needs nounsetarm, because the engine always assigns it. Since dsl 0.27.0 it also stands in for a member name in a fact query and aper:family: see Asking about the member. The value lasts for that presentation only. Readingoccasion.targetin a beat or entry that neither targets a kind nor is presented once per member isE-UNDECLARED.- Display.
{{occasion.target}}compiles to the placeholder{"kind": "occasionTarget", "entityKind": "bugCommon"}, so an engine can show the member’s display name.lute playrenders the kind’s label for the member, else its castname:when the member is a cast id, and the id otherwise (A inchlet. It counts.). - Ranking. At equal priority the beat that names the member outranks the kind beat, and the
two never draw
W-BEAT-PRIORITY-TIE.W-BEAT-SHADOWEDjudges a kind beat member by member.
lute play shows the member beat winning its first raise and the kind beat answering after it:
── step 1 · caught → mon.inchlet ────────────── ✓ contest.firstInchlet [beat, priority 0] ✓ contest.catchCommon [beat, priority 0] → contest.firstInchlet@narrator: Your very first inchlet. The judges smile.── step 2 · caught → mon.inchlet ────────────── ✓ contest.catchCommon [beat, priority 0] ✗ contest.firstInchlet [beat, priority 0] — once: user — already presented → contest.catchCommon set run.contestScore = 1@narrator: A inchlet. It counts.Each shape fault names what is wrong:
./lore/contest.lute:24:42: error [E-BEAT-ATTR] `target="kind:bugRar"`: `bugRar` is not a declared entity kind — did you mean `kind:bugRare`?./lore/contest.lute:28:40: error [E-BEAT-ATTR] `target="kind:bugRare"`: `hornbeetle`, `bladebug` are outside occasion `talk`'s domain `npc.<person>`; a kind target names `person` or one of its sub-kinds./lore/contest.lute:33:23: error [E-UNDECLARED] `occasion.target` is readable only in a beat or entry that targets a kind (`target="kind:<kind>"`) or runs once for each member of one (`for="kind:<kind>"`), where it is the member the beat answerslute play and lute calendar offer a kind beat for every member’s raise. lute beats lists it
in each member’s ladder that names a beat of its own, and in a kind:<kind> ladder for the rest
(caught @ kind:bugCommon); its --target accepts any member. A lute test of a
kind beat names the member in state: { occasion.target: inchlet }. The compiled beat carries
targetKind: { kind, prefix, members } on its scene meta.beat, its entry or beat record, and
its project.index.json row.
Asking about the member
Section titled “Asking about the member”occasion.target stands in for a member name where a member is expected (dsl 0.27.0 §3): as a
fact query’s argument, holds('logged', [occasion.target]), and as a per: family’s index,
run.caught[occasion.target]. The checker instantiates the condition for every member of the kind,
and the engine substitutes the member it raised:
<beat id="rareAgain" on="caught" target="kind:bugRare" once="false" priority="5" when="holds('logged', [occasion.target]) && run.caught[occasion.target] >= 2"> @narrator: Another {{occasion.target}} for the book.</beat>Because each member is checked, a fault names the member it is about: a member outside the
relation’s argument kind is E-FACT-DOMAIN, and a family declared per: a kind the member is not
in is E-UNDECLARED for that member’s path.
A lute test names the member and mocks what is asked about it:
file: ../lore/contest.lutebeat: rareAgainstate: { occasion.target: hornbeetle, run.caught.hornbeetle: 2 }facts: [logged(hornbeetle)]expect: transcriptContains: ["Another hornbeetle for the book."]Without the member, lute trace stops and says which mock decides it:
supply --state occasion.target=<hornbeetle|bladebug> as a mock.
When each member needs its own conditions throughout, a beat per member (target="mon.hornbeetle")
says it more plainly, and it outranks the kind beat at equal priority.
Writing to the member
Section titled “Writing to the member”The same beat can write what it asks about. Where a member is expected in a write,
occasion.target stands for the member the beat runs for:
- a
per:family index in a::set:::set{run.caught[occasion.target] += 1}; - a fact argument in an
::assertor::retract:::assert{logged(occasion.target)}; - a directive attribute typed by an entity kind or domain:
::haul{bug=occasion.target}(the directive’s declared effects then name the member too); - a component argument whose param is a
speakeror is typed by a kind:::use{component="reaction" who=occasion.target}. The member speaks the component’s@@who:lines, and a kind-typed param renders its label.
<beat id="rareAgain" on="caught" target="kind:bugRare" once="false" when="!holds('logged', [occasion.target])"> @narrator: A {{occasion.target}} for the book. ::assert{logged(occasion.target)} ::set{run.caught[occasion.target] += 1}</beat>Each write is checked once per member, as a condition is: a member the family is not declared
per:, or that is outside the relation’s or parameter’s kind, is reported naming that member.
lute play writes only the member the beat ran for (set run.caught.hornbeetle = 1). A write
through occasion.target in a beat or entry that neither targets a kind nor runs for each member of
one is E-UNDECLARED: there is no member to write.
In the compiled artifact the write keeps occasion.target (a set record’s path
run.caught[occasion.target], an assert argument, a plugin field); the engine substitutes the
member it bound, exactly as it does for a read. A component argument is expanded per member instead:
one match arm on occasion.target for each member.
Once per member: for=
Section titled “Once per member: for=”Some occasions are raised for no one in particular: a morning, a daily reset. A beat on such an
occasion may still be about each member of a kind in turn. With for="kind:<kind>" (dsl 0.27.0
§3), a beat on an untargeted select: sequence occasion is presented once per member whose
when holds, in the kind’s member order (its own
members, then its sub-kinds’ in declaration order), with occasion.target bound to that member:
occasions: morning: { select: sequence }<beat id="birthday" on="morning" for="kind:person" once="false" when="holds('birthdayToday', [occasion.target])"> @narrator: It is {{occasion.target}}'s birthday.</beat>On a morning where birthdayToday(maud) and birthdayToday(oskar) hold, the beat is presented
twice, for maud and then for oskar, among the occasion’s other eligible beats in the usual
order. Everything Kind targets says about occasion.target holds here too: a
fact query and a per: family take it, and {{occasion.target}} renders the member’s label.
-
Eligibility is decided per member.
onceis spent per member: aonce: runbeat that was presented formaudthis morning can still be presented foroskartomorrow, and not formaudagain this run. The same holds foruser,day,slot,week, andseason:<name>. -
A
spentBycondition is judged per member too, withoccasion.targetbound, so it spends one member at a time. A beat that greets each person until they are befriended:<beat id="hello" on="morning" for="kind:person" spentBy="holds('befriended', [occasion.target])">@narrator: {{occasion.target}} nods at you, warily.</beat>Once
befriended(maud)has held, the beat is spent formaudfor the run and still greets everyone else. -
A scene writes the same key in its frontmatter,
for: "kind:person". -
forneeds an occasion declaredselect: sequenceand raised without a target, and a closed kind (one withmembers:). It is not written besidetarget; an occasion raised for a member takestarget="kind:<kind>"instead. Each of these isE-BEAT-ATTR, with a did-you-mean for a misspelled kind.
lute play lists one candidate per member (✓ lore.birthday for maud), and its
--json candidates carry the member as for. A lute test of the beat names one member in
state: { occasion.target: maud }. The compiled beat carries forKind: { kind, members } on its
scene meta.beat, its entry or beat record, and its project.index.json row.
Occasion payloads
Section titled “Occasion payloads”An occasion may hand typed values to the beats that answer it (dsl 0.27.0 §3). A gacha pull is raised for the hero drawn, and says how many copies came with it:
occasions: summon: { select: first, target: { prefix: hero, entity: hero }, payload: { copies: int } }A beat on summon reads each field as occasion.payload.<field>, typed as declared, in its when,
its guards, its ::set values, and its text:
<beat id="pull" on="summon" target="kind:hero" once="false" when="occasion.payload.copies >= 1"> @narrator: {{occasion.payload.copies}} copies of {{occasion.target}}. ::set{run.shards += occasion.payload.copies}</beat>The engine binds the payload for one raise; it is gone once that raise’s beats have been
presented. Reading a field in a beat of an occasion that does not declare it is E-UNDECLARED,
and so is a read outside any beat. A lute play step gives the payload beside
the occasion (payload: { copies: 2 }), and a field the occasion does not declare is a usage
error. A step that leaves a declared field out is a usage error too, before anything plays:
step 1: occasion `summon` declares payload field `copies`, which is missing from this raise — add `payload: { copies: <int> }` to the step
lute trace and lute test mock it like any state, state: { occasion.payload.copies: 2 }, and
halt incomplete on a line that reads a field the mock leaves out, instead of printing the
placeholder. An occasion with a payload cannot be the clock’s raise: (E-CLOCK-DECL): the
clock has no values to give it.
What the checker proves
Section titled “What the checker proves”| Code | When |
|---|---|
E-BEAT-ATTR | a malformed beat key or attribute: on not a name, target not a dotted id or outside its occasion’s target domain, a kind: target that names no closed kind within the occasion’s domain, a for that is not kind:<kind> of a closed kind, sits beside target, or is on an occasion raised for a target or not select: sequence, priority not an integer, a scene’s or bundle beat’s once outside run / user / false or an entry’s once outside run / user / false (each also day / slot, but only in a project with a clock, and week only with its week:; dsl 0.27.0 §5), once="false" or a share key written beside spentBy, also not a bool, on an entry, or on a select: all / sequence occasion, a share that is not an identifier or has no spending once beside it, beats of one share key with different onces (whole-project lute check), beat keys without on, a scene’s or bundle beat’s target on an untargeted occasion, or a bundle beat shape fault (its id, a duplicate id, a lore document with beats but no id:) |
E-OCCASION-UNKNOWN | on names an occasion no resolved plugin declares (only once some plugin declares occasions) |
E-BEAT-UNREACHABLE | a scene or bundle beat’s when provably never holds; see How a when is decided. lute check decides what one file settles, and whole-project lute check also decides fact queries through the fact envelope. An entry beat’s dead when stays E-ENTRY-UNREACHABLE. |
W-BEAT-SHADOWED | whole-project lute check only: a select: first beat that can never win, because an earlier-ordered beat on the same occasion and target is always eligible (no after:, and a when that is absent or always true) and never spent (an entry without once, or a scene or bundle beat with once: false). On an occasion whose target domain is closed, an untargeted beat is also reported when, at every <prefix>.<member>, such a beat for that member wins; the message names the shadower per target. A kind beat is judged member by member the same way. also beats neither shadow nor are shadowed. |
W-BEAT-PRIORITY-TIE | whole-project lute check only: two beats on one select: first occasion, either untargeted or for the same target, with equal priority and whens that are not provably exclusive. When both are eligible, project order picks the winner, so renaming or moving a file changes it. Give one a different priority, or make the conditions exclusive: conditions that cannot hold together, such as run.slot == 'morning' against run.slot == 'night', run.day > 5 against run.day < 3, or holds(P) against !holds(P). A beat’s once counts as part of its condition: an entry’s once="user" as !entry.<id>.everRead, its once="run" as !entry.<id>.read, a scene’s or bundle beat’s once: user as !visited('<id>'). So a bark with once="user" and one whose when reads that bark’s everRead do not tie. A holds(…) of a derived atom whose rules are all ground, cel()-only schedules counts as those cel() guards, so beats on two different schedule slots do not tie either. Since dsl 0.26.0 §8 negations are normalized first (De Morgan, !(run.money < 100) as run.money >= 100, so it is exclusive with run.money < 100) and each condition’s alternatives are compared; the fact analysis’s must set at either beat’s when is read; a fact that only a beat’s own unplayed once: run / user presentation asserts counts as absent while that beat is eligible; and the message says why the two are not exclusive: a flag that outlives once: run, a fact asserted elsewhere or tier: user, or the paths each when reads (beat `tie.first` reads `run.metMira` and beat `tie.again` reads `run.money`: no path both constrain). A member beat and a kind beat never tie: the member beat ranks first. A shadowed beat reports W-BEAT-SHADOWED instead, and an also beat never ties. |
W-BEAT-ONCE-RUN-USER | whole-project lute check only: a scene or bundle beat whose once is left at its default run and whose when reads only user-tier state: user.*, entry.<id>.everRead, holds / count of a tier: user relation, or quest.<id>.* of a user-tier quest, and no run-tier path, run-tier fact, or visited(). Once that condition holds it holds in every run, so the beat plays again each run. Use once: user for a beat heard once ever, or gate it on run-tier state. If replaying every run is the point, write once: run: an authored once: run, like an entry’s once="run", says so and silences the warning. prev.run.* is run history, not user state, so a when reading it never warns. |
W-ENTRY-WRITE-REREAD | an entry that can be read again in a run (a lookup entry, an entry beat without once, a once shorter than the run, a spentBy entry, a for= entry without once="run" / "user") whose body has a ::set, a ::retract, or an effect-only directive that writes: its writes apply on its first read in a run only. ::assert is exempt, and so is a write guarded by !entry.<id>.read. See Reading twice. |
How a when is decided
Section titled “How a when is decided”The checker proves what it can about a condition without running the game, and dsl 0.23.0 made it
sharper. Inside one &&, it collects what each operand says about each path: equalities,
disequalities, and numeric ranges. A path is a state path, a component param, $ in a <match>,
or a relational call such as holds(P), visited(id), or count(P), compared by its text. When
the constraints on one path cannot all hold, the whole conjunction is false. @defs are expanded
first. Each of these beats is E-BEAT-UNREACHABLE in a plain lute check:
when | Why it never holds |
|---|---|
run.slot == 'morning' && run.slot == 'night' | one path, two values |
run.day > 5 && run.day < 3 | an empty range |
run.flag && !run.flag | a condition and its negation |
holds('met', ['maud']) && !holds('met', ['maud']) | the same query, both ways |
run.slot == 'afternon' | a value outside the path’s enum |
A condition hidden behind a def is found the same way:
---kind: sceneid: square.festivalon: townVisitwhen: "@festivalNight && run.day == 2"state: run.day: { type: int, default: 1 }defs: festivalNight: { type: bool, cel: "run.day == 5" }---
## Opening {#opening}
@narrator: Lanterns everywhere.An || works the other way: when its cases cover every value a path can take, it is always true.
run.day < 3 || run.day >= 3 holds on every day, so a repeatable beat with that when shadows
every later beat on its occasion (W-BEAT-SHADOWED).
unset counts as a value. Reading an ordering or a bare boolean on an unset path is an error, not
false, so a contradiction on a path with no default is decided only once the same conjunction
proves the path set: has(run.mood) && run.mood > 5 && run.mood < 3 is false. (Without the
has guard, a when reading that path is E-MAYBE-UNSET anyway.) The reasoning stays within && and
||. It does not look inside a Datalog rule’s cel(…) guard, so a when that contradicts the
scalar premise of a derived fact is not caught.
The same decider serves every condition slot. A dead <when test> or gated line is E-ARM-DEAD, a
dead entry when is E-ENTRY-UNREACHABLE, a dead objective done is
E-OBJECTIVE-UNSATISFIABLE, and W-BEAT-PRIORITY-TIE asks it whether two whens can hold
together. A project that checked clean on 0.22 may report a contradiction it used to miss. The
beat never played before either, but now the checker says so.
Work in progress
Section titled “Work in progress”A fact-gated beat is often dead only because the scene that asserts its fact is not written yet.
lute check --project <DIR> --wip (dsl 0.23.0) reports E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, and
E-OBJECTIVE-UNSATISFIABLE as warnings when the condition is dead only because some relation
has no producer at all: no seed, no ::assert anywhere, no rule, and not reserved:. The note on
the warning says why. A relation that has producers but can never match stays an error. So does
what follows downstream: a quest that is dead only because its content is unwritten still counts
for connectivity, so an E-CONN-UNREACHABLE it causes stays an error. See
whole-project lute check.
Tooling
Section titled “Tooling”-
lute play --project <DIR> --script <play.yaml>raises a scripted sequence of occasions through a whole project. It prints every candidate with its verdict, presents the winner and anyalsobeats (or every eligible beat of aselect: sequenceoccasion), and advances quest lifecycles after each one. A step on aselect: alloccasion names the player’s choice withpick:, or closes the list withpick: none, and a step’sexpect: { presented: [ids] }asserts the presented beats in order. Bundle beats play like scene beats. See Playing a story. Since dsl 0.26.0 §8lute playandlute testalso accept an entry by<document id>.<entry id>(talks.miraBark) wherever they take an entry id:pick:,entriesRead:, a step’swinner/offered/notOffered/presented, a test’sentry:/entries:andexpect.eligiblekeys, andlute trace --entry. -
lute beats --project <DIR>prints each occasion’s (and each target’s) beat ladder in selection order, with priority,once(and itssharekey),also,after:,when, title, and the whole-projectlute checkverdicts above, even for a project that does not check clean yet. Since dsl 0.26.0 §8 it also marks a fallback that an earlier, never-spent beat always beats, because the fallback’swhenimplies the earlier beat’s, ascovered by <id>(--json:coveredBy). It is informational, not a warning:townVisit — select: first# priority beat kind once verdict after when1 5 gate.open bundle no - - run.badges >= 12 0 gate.shutFallback bundle no covered by gate.open - run.badges >= 3lute calendar --project <DIR> --axis …evaluates eligibility over a grid of state values, such as every day and time slot, and shows what each cell presents: the winner, or a sequence’s whole list.lute scenario --project <DIR> knowledgetraces each fact-gated beat to the facts it queries and whatever produces them. See Overviews. -
lute trace <lore.lute> --beat <id>previews one bundle beat, andlute run <artifact> --beat <id>runs one from a compiled lore artifact. An id that names no bundle beat isE-TRACE-BEATintraceand a usage error inrun. See Tracing. -
Whole-project
lute compile(no document operand,-o <DIR>) writesbeatsintoproject.index.json: one row per scene, entry, or bundle beat (kindbundle), carrying the expandedwhenand thetitle(dsl 0.23.0). Each compiled scene beat carriesmeta.beat, each entry beat carrieson/priority/onceon itsentryrecord, and each bundle beat is abeatrecord in its lore artifact, followed by its body. -
lute new scene <name> --occasion <OCCASION> [--target <TARGET>]writes a scene beat, checking the occasion and target against the project first; a targeted occasion needs--target. -
lute contextlists the project’s declared occasions, with their target domains, beside its other vocabulary.
The engine contract (candidates, eligibility, spending, presentation) is in
docs/runtime/beats-and-occasions.md;
the normative specs are
0.21.0.md,
0.22.0.md
for entry once and target domains,
0.23.0.md
for select: sequence, also, beat bundles, the sharper decider, and --wip,
0.24.0.md
for once: day / once: slot, entry targets as metadata, and bundle beats as after:
predecessors,
0.25.0.md
for share and bundle beat after=,
0.26.0.md
for kind targets, W-ENTRY-WRITE-REREAD, the sharper W-BEAT-PRIORITY-TIE, and covered by, and
the draft
0.27.0.md
for once: week, once: season:<name>, and spentBy.
Evidence in 0.34.0
Section titled “Evidence in 0.34.0”lute beats adds evidence to each verdict in JSON. Proven checker
decisions remain proven; conservative ladder guidance is heuristic, and
clock-window findings are bounded with scope
declared clock windows; no path search. The bounded
W-OBJECTIVE-STRANDED and W-SLOT-CONTENTION messages describe only that
scope, not a universal impossibility.
Since 0.41.0 W-OBJECTIVE-STRANDED names that evidence level in its own
message and is always a warning. It skips a quest that re-arms
(tier="run", tier="season:<name>", or a rearm=), which retries the
objective, and an objective a beat of another document can complete.
JSON for beat tooling
Section titled “JSON for beat tooling”lute beats --json is a report, not an artifact stream. Consume its top-level
command, schemaVersion: "0.43.0.beats", and ok fields, then the beat verdicts and
their evidence (proven, heuristic, or bounded). Do not infer eligibility from
human formatting. The command identity and all report versions are discoverable with
LUTE_TELEMETRY=0 target/debug/lute schema beats.