Story overviews
Machine-readable output (0.43)
Section titled “Machine-readable output (0.43)”--json responses from beats, calendar, and scenario are envelope objects with command, schemaVersion, ok, and the command payload. Errors are represented by ok: false and a structured error (code, message, and optional location fields), never by a successful empty result. Check the exit code first.
Once a story is selected by occasions rather than read top to bottom, no single file answers “what plays at the inn on the evening of day two?”. The answer is spread over every beat’s on, target, priority, once, after: and when, over the project’s rules, and over whatever the save already holds. Since dsl 0.23.0 three commands put it on one screen:
lute beats— the ladder: every beat of each occasion and target in selection order, with whatcheck --projectconcludes about it. Static; the project need not check clean.lute calendar— the grid: for every combination of the state values, quest statuses and facts you name — from a save, or partway along a played route — which beatlute playwould present. It runs play’s own eligibility, so it is exact where the checker is conservative.lute scenario knowledge— the knowledge map: for every condition that reads a fact, which rules conclude it and who produces the facts they need, down to the ones nothing produces yet.
All three are read-only. The normative text is §1 of the 0.23.0 proposal.
Since dsl 0.26.0 the views follow a project several authors write at once: lute beats marks a fallback another beat always covers and lists a kind beat for every member it answers, lute scenario --facts draws which scene’s facts open which gate — progress an after: graph cannot see — and lute refs lists who gives what: every value of a directive attribute or reward target, with the documents that use it.
The example project
Section titled “The example project”The examples on this page run against a small harbour town on a day clock. Its plugin declares three occasions: the morning bell plays every eligible beat in turn (select: sequence, see Composing occasions), a visit to a place picks one beat, and the notice board offers a list:
occasions: dayStart: { select: sequence, description: The bell rings; every eligible beat plays } placeVisit: { select: first, target: { prefix: place, entity: place } } board: { select: all, description: Notices by the harbour master's door }The engine owns the clock. Two rules place people by the time of day, and a third says Ada trusts you once you have met her, unless a rumour says otherwise:
state: run.day: { type: int, default: 1, owner: engine } run.slot: { type: { enum: [morning, evening] }, default: morning, owner: engine } user.visits: { type: int, default: 0, owner: engine }entities: person: { members: [ada, bo] } place: { members: [inn, dock] }relations: works: { args: [person, place], tier: run } met: { args: [person], tier: run } rumor: { args: [person], tier: run } arrived: { args: [place], tier: run, reserved: true } present: { args: [person, place], derive: true } trusted: { args: [person], derive: true }facts: - "works(bo, dock)"rules: - "present(ada, inn) :- cel(\"run.slot == 'evening'\")" - "present(P, dock) :- works(P, dock), cel(\"run.slot == 'morning' && run.day != 3\")" - "trusted(P) :- met(P), not rumor(P)"The beats:
| Beat | Document | Answers | Conditions | once |
|---|---|---|---|---|
day.bell | scenes/day/bell.lute | dayStart | priority 10, title: The bell | false |
day.market | scenes/day/market.lute | dayStart | when: 'run.day == 3', title: Market day | run |
day.farewell | scenes/day/farewell.lute | dayStart | after: 'completed("ferry")' | run |
inn.ada | scenes/inn/ada.lute | placeVisit → place.inn | priority 20, when: 'holds('present', ['ada', 'inn'])'; asserts met(ada) | run |
inn.again | scenes/inn/again.lute | placeVisit → place.inn | priority 30, after: 'visited("inn.ada")', when: 'holds('trusted', ['ada'])' | run |
inn.regular | scenes/inn/regular.lute | placeVisit → place.inn | priority 10, when: 'user.visits >= 3' | run |
dock.storm | scenes/dock/storm.lute | placeVisit → place.dock | priority 50, when: "run.slot == 'evening' && run.day == 2" | run |
innQuiet, dockBo, dockGulls, dockEmpty (entries) | lore/places.lute | placeVisit | see below | — |
noteFerry, noteFair, noteDocked (entries) | lore/places.lute | board | see below | — |
The entries in lore/places.lute:
<entry id="innQuiet" on="placeVisit" target="place.inn" category="bark" when="!holds('present', ['ada', 'inn'])"> @narrator: The inn is quiet at this hour.</entry>
<entry id="dockBo" on="placeVisit" target="place.dock" category="bark" when="holds('present', ['bo', 'dock'])"> @bo: Mind the nets.</entry>
<entry id="dockGulls" on="placeVisit" target="place.dock" category="bark"> @narrator: Gulls fight over a fish head.</entry>
<entry id="dockEmpty" on="placeVisit" target="place.dock" category="bark" priority="-1"> @narrator: Nobody on the pier.</entry>
<entry id="noteFerry" on="board" category="note" title="Ferry times" when="run.day <= 2"> @narrator: "Ferry leaves on the third day."</entry>
<entry id="noteFair" on="board" category="note" title="The fair" priority="5"> @narrator: "Fair on the green, all week."</entry>
<entry id="noteDocked" on="board" category="note" title="The ferry is in" priority="10" when="holds('arrived', ['dock'])"> @narrator: "Ferry at the pier. Boarding now."</entry>And one quest, quests/ferry.lute, which starts once you have met Ada at the inn:
<quest id="ferry" title="Catch the ferry" start="visited('inn.ada')"> <objective id="word" title="Win Ada's trust" done="holds('trusted', ['ada'])"/> <objective id="board" title="Be at the dock on day three" done="run.day >= 3"/></quest>lute beats
Section titled “lute beats”$ lute beats --project <DIR> [--occasion <OCCASION>]… [--target <TARGET>]… [--expand] [--json]Print every beat of the project, grouped into one ladder per occasion — and, for a targeted occasion, one per target — in selection order: priority descending, then project order (the order of beats in project.index.json). Each row carries what decides the beat’s place and what check --project concludes about it:
$ lute beats --project .project root: .
board — select: all # priority beat kind once verdict after when 1 10 noteDocked "The ferry is in" entry no - - holds('arrived', ['dock']) 2 5 noteFair "The fair" entry no - - - 3 0 noteFerry "Ferry times" entry no - - run.day <= 2
dayStart — select: sequence # priority beat kind once verdict after when 1 10 day.bell "The bell" scene no - - - 2 0 day.farewell scene run - completed("ferry") - 3 0 day.market "Market day" scene run - - run.day == 3
placeVisit @ place.dock — select: first # priority beat kind once verdict after when 1 50 dock.storm scene run - - run.slot == 'evening' && run.day == 2 2 0 dockBo entry no - - holds('present', ['bo', 'dock']) 3 0 dockGulls entry no tied - - 4 -1 dockEmpty entry no shadowed - -
placeVisit @ place.inn — select: first # priority beat kind once verdict after when 1 30 inn.again scene run - visited("inn.ada") holds('trusted', ['ada']) 2 20 inn.ada "Ada at the inn" scene run - - holds('present', ['ada', 'inn']) 3 10 inn.regular scene run once-run-user - user.visits >= 3 4 0 innQuiet entry no - - !holds('present', ['ada', 'inn'])- beat — the id, followed by its
titlein quotes when it has one (a scene’stitle:, an entry’s or bundle beat’stitle=). - kind —
scene,entry, orbundle(a bundle beat, listed under its canonical<document id>.<beat id>). - once —
run,user,day,slot(with a declared clock, for a scene, an entry, or a bundle beat’sonce="day"/once="slot"), orno(a scene’sonce: false, an entry withoutonce);, alsofollows it for analsobeat, and, share <key>for a beat with a shared spend (dsl 0.25.0):day, share solWarm. - after / when — the conditions as the author wrote them,
@defreferences included (dsl 0.24.0;--expandprints eachwhenexpanded);-when absent. - verdict — the project-wide beat diagnostics
check --projectreports about this beat, by name:unreachable(E-BEAT-UNREACHABLE/E-ENTRY-UNREACHABLE),shadowed(W-BEAT-SHADOWED),tied(W-BEAT-PRIORITY-TIE), andonce-run-user(W-BEAT-ONCE-RUN-USER). They are the checker’s own diagnostics from the same run, not a second analysis; see Beats for what each means. Above,dockGullstiesdockBo(equal priority, conditions not provably exclusive),dockEmptycan never win because the always-eligibledockGullsoutranks it, andinn.regularis spent once per run while itswhenreads only user state.
A targeted occasion gets one ladder per target its beats name; a beat with no target answers every raise, so it appears in every target’s ladder. When no beat names a target, the occasion has a single ladder headed (any target) ("anyTarget": true in JSON). Shadowing and ties are select: first notions, so a select: all or sequence ladder never shows them.
Covered fallbacks (dsl 0.26.0 §8). A fallback that an earlier, never-spent beat whose when it implies always beats can never play, yet it is not shadowed — the beat above it is not always eligible. It is there on purpose (a lead’s gate line, kept until an area answers the gate), so the verdict column says what covers it instead of warning: covered by <id> (coveredBy in --json). An area’s solGate (priority 0) and the lead’s gateFallback (priority -10) share the when !holds('metWren', ['wren']), and entries without once are never spent. A kind beat (dsl 0.26.0 §5) is listed in the ladder of every member some beat names — Gus’s own beat outranks it there — and in a kind:<kind> ladder for the members no beat names on its own:
$ lute beats --project . --occasion talkproject root: .
talk @ npc.gus — select: first # priority beat kind once verdict after when 1 0 trainers.gusRematch "Gus wants a rematch" bundle no - - - 2 0 trainers.challenge "A trainer squares up" bundle no - - -
talk @ npc.sol — select: first # priority beat kind once verdict after when 1 0 solGate entry no - - !holds('metWren', ['wren']) 2 -10 gateFallback entry no covered by solGate - !holds('metWren', ['wren'])
talk @ npc.wren — select: first # priority beat kind once verdict after when 1 0 wren.talk "Wren at the pier" scene no - - -
talk @ kind:trainer — select: first # priority beat kind once verdict after when 1 0 trainers.challenge "A trainer squares up" bundle no - - ---target accepts any member of the occasion’s domain: lute beats --project . --occasion talk --target npc.r16Gus prints the one ladder talk @ npc.r16Gus, the kind beat alone. On Monster League the twelve covered by rows are the lead’s gate fallbacks, each covered by the area beat that answers its gate.
The project need not check clean. The ladder is most useful exactly when something is wrong. With the storm’s condition written as a contradiction, check --project fails with E-BEAT-UNREACHABLE, and the ladder still lists the beat, at the top of the dock, marked:
$ lute beats --project . --target place.dockproject root: .
placeVisit @ place.dock — select: first # priority beat kind once verdict after when 1 50 dock.storm scene run unreachable - run.slot == 'morning' && run.slot == 'evening' 2 0 dockBo entry no - - holds('present', ['bo', 'dock']) 3 0 dockGulls entry no tied - - 4 -1 dockEmpty entry no shadowed - ---occasion <OCCASION> (repeatable) keeps only those occasions’ ladders; --target <TARGET> (repeatable) keeps only the ladders raised for that target, so untargeted occasions drop out. An occasion the project does not know is a usage error that lists the known ones; a target no beat names prints (no beats):
$ lute beats --project . --occasion daystartlute beats: `--occasion daystart` is not an occasion of this project (known: board, dayStart, placeVisit)--json emits { "roots": [ { "root", "ladders": [ { "occasion", "select", "target"? | "anyTarget"?, "beats": [ … ] } ] } ] }. Each beat is { id, kind, document, priority, once, share?, target?, also?, after?, when?, whenAuthored?, title?, coveredBy?, neverFor?, verdicts }, where once is "run", "user", "day", "slot", or "none", share the beat’s shared-spend key (dsl 0.25.0), when carries the expansion and whenAuthored the author’s text (dsl 0.24.0), coveredBy the id of the beat that always beats it (dsl 0.26.0), neverFor the ladder’s targets a kind or for= beat’s when never holds for (dsl 0.28.0), and verdicts carries the full diagnostics, each { code, severity, message }; a kind ladder’s target is "kind:<kind>":
{ "document": "lore/places.lute", "id": "dockEmpty", "kind": "entry", "once": "none", "priority": -1, "target": "place.dock", "verdicts": [ { "code": "W-BEAT-SHADOWED", "message": "entry `dockEmpty` can never win occasion `placeVisit` for `place.dock`: entry `dockGulls` (priority 0) is ordered before it, is always eligible (no `after:`, and its `when` is absent or always true), and is never spent (an entry without `once`), so it wins every time", "severity": "warning" } ]}Defs as written (dsl 0.24.0). The when column shows a def reference the way the author wrote it; --expand prints the expansion instead. In the beats scaffold, town.morning is gated on !@firstDay:
$ lute beats --project . --occasion townVisitproject root: .
townVisit — select: first # priority beat kind once verdict after when 1 10 town.welcome "First arrival" scene user - - - 2 0 town.morning "Another morning" scene no - - !@firstDay$ lute beats --project . --occasion townVisit --expandproject root: .
townVisit — select: first # priority beat kind once verdict after when 1 10 town.welcome "First arrival" scene user - - - 2 0 town.morning "Another morning" scene no - - !(run.day == 1)Exit 0 on success, 2 on an I/O failure or an unknown --occasion.
lute calendar
Section titled “lute calendar”$ lute calendar --project <DIR> [--axis <path>=<lo>..<hi> | <path>=<a>,<b>,… | clock[=<d1>..<d2>]]… [--occasion <OCCASION>]… [--occasion-axes <OCCASION>=<axis>[=<value>],…]… [--target <TARGET>]… [--relation <relation>]… [--script <route.play.yaml> [--until <step>]] [--where <cel>] [--json | --format csv]Evaluate, for every cell of a grid of state values and every occasion column, which beats lute play would find eligible there — its own candidates, once, after: and when, with the project’s rules applied — and report the winner. Every cell starts from the same world — the declared defaults, or a play script’s save with its steps replayed — then gets its axis values written, and its quests settle. Nothing is presented within the grid: each cell is one question, “if the engine raised this occasion now, what would play?”, and no cell sees what another did.
--axis(optional, repeatable) names a declared state path and its values: an inclusive integer rangerun.day=1..7, or a listrun.slot=morning,evening. Each value is checked against the path’s declared type. The grid is the product of the axes, and the first axis varies slowest; with no--axisit is a single cell. More kinds of axis reach what a state write cannot — a quest’s status, a fact, the visited set, a declared clock — see Quest, fact, visited and clock axes.--occasion <OCCASION>(repeatable) — the occasions to evaluate; by default every occasion a beat answers.--occasion-axes <OCCASION>=<axis>,…varies only some axes for that occasion — see Per-occasion axes and who is where.--relation <relation>(repeatable, dsl 0.24.0) — print the relation’s facts in every cell, once it has settled.--target <TARGET>(repeatable) — the targets to raise a targeted occasion for; by default every target its beats name, not the rest of its declared target domain, where no beat answers and every cell would read as a hole. A kind beat (dsl 0.26.0) names every member of its kind, so each member gets a column, and a cell offers it at that member’s raise, after the member’s own beats of its priority. When none of its beats names a target, the occasion gets a single column headed(any)(<occasion>@(any)in the lists below the grid), which only its untargeted beats answer. Each (occasion, target) pair is one column.--script <file>— a play script every cell starts from: its save —state:,facts:,visited:,presented:,quests:,entriesRead:(Starting from a save) — and then itssteps:, replayed exactly aslute playplays them. A save needs no steps. Without it, every cell starts from the declared defaults and the seed facts.--until <step>— with--script, replay only the steps before this one, named by its 1-based number or itslabel:; the step itself is not played. See Along a route.--where <cel>— keep only the cells where this condition holds; see Dropping cells no run reaches.--json/--format csv— machine-readable output instead of the grid.
Over the town’s first three days:
$ lute calendar --project . --axis run.day=1..3 --axis run.slot=morning,eveningcalendar: . — 6 cell(s) × 4 column(s), from declared defaults
run.day run.slot board (all) dayStart (sequence) placeVisit placeVisit place.dock place.inn1 morning noteFair, noteFerry day.bell dockBo +2 innQuiet1 evening noteFair, noteFerry day.bell dockGulls +1 inn.ada2 morning noteFair, noteFerry day.bell dockBo +2 innQuiet2 evening noteFair, noteFerry day.bell dock.storm +2 inn.ada3 morning noteFair day.bell, day.market dockGulls +1 innQuiet3 evening noteFair day.bell, day.market dockGulls +1 inn.ada
shadowed (eligible, not presented): run.day=1 run.slot=morning placeVisit@place.dock: dockBo over dockGulls, dockEmpty run.day=1 run.slot=evening placeVisit@place.dock: dockGulls over dockEmpty run.day=2 run.slot=morning placeVisit@place.dock: dockBo over dockGulls, dockEmpty run.day=2 run.slot=evening placeVisit@place.dock: dock.storm over dockGulls, dockEmpty run.day=3 run.slot=morning placeVisit@place.dock: dockGulls over dockEmpty run.day=3 run.slot=evening placeVisit@place.dock: dockGulls over dockEmpty
never eligible in any cell: 4 noteDocked [entry, lore/places.lute] board — when: false day.farewell [scene, scenes/day/farewell.lute] dayStart — after: prerequisite not satisfied inn.again [scene, scenes/inn/again.lute] placeVisit@place.inn — after: prerequisite not satisfied inn.regular [scene, scenes/inn/regular.lute] placeVisit@place.inn — when: false
eligible but never presented in any cell: 1 dockEmpty [entry, lore/places.lute] placeVisit@place.dock — lost to dock.storm; dockBo; dockGullsReading a cell:
- A
select: firstcolumn shows the winner, followed by+NwhenNmore beats were eligible but outranked; theshadowedblock below the grid names them, winner first. A-is an empty cell: nothing is eligible, so the occasion would pass with no story — exactly the holes a calendar exists to find. - A
select: allcolumn shows the offered list, and aselect: sequencecolumn the sequence that would play, each in selection order. ?marks an undecided cell — awhenthe reference runtime cannot decide (avalidAt(…), say) decides the outcome, solute playwould halt there. The reasons are listed underundecided.
Here the dock is never empty — dockGulls covers every cell Bo is not on the pier — while the storm takes over the second evening. The rules move Ada: she is at the inn only in the evening. And the calendar lists the beats never eligible in any cell, with the reason: day.farewell and inn.again wait on history (after:) that a fresh start does not have, inn.regular needs three visits, and noteDocked a fact only the engine asserts. Since dsl 0.24.0 it also lists the beats eligible but never presented — eligible somewhere, beaten everywhere — with what beat them: dockEmpty is always outranked (--json: neverPresented, each with beatenBy; --format csv: a second table).
From a save
Section titled “From a save”History is what the axes cannot vary, so --script supplies it. A save in which the player met Ada on an earlier evening, three visits in, needs no steps:
# A save: Ada met on an earlier evening, three visits in all. No steps.visited: [inn.ada]facts: ["met(ada)"]state: { user.visits: 3 }$ lute calendar --project . --axis run.day=1..3 --axis run.slot=morning,evening \ --script plays/regular.play.yaml --occasion dayStart --occasion placeVisit --target place.inncalendar: . — 6 cell(s) × 2 column(s), from the save in plays/regular.play.yaml
run.day run.slot dayStart (sequence) placeVisit place.inn1 morning day.bell inn.again +21 evening day.bell inn.again +22 morning day.bell inn.again +22 evening day.bell inn.again +23 morning day.bell, day.farewell, day.market inn.again +23 evening day.bell, day.farewell, day.market inn.again +2
shadowed (eligible, not presented): run.day=1 run.slot=morning placeVisit@place.inn: inn.again over inn.regular, innQuiet run.day=1 run.slot=evening placeVisit@place.inn: inn.again over inn.ada, inn.regular run.day=2 run.slot=morning placeVisit@place.inn: inn.again over inn.regular, innQuiet run.day=2 run.slot=evening placeVisit@place.inn: inn.again over inn.ada, inn.regular run.day=3 run.slot=morning placeVisit@place.inn: inn.again over inn.regular, innQuiet run.day=3 run.slot=evening placeVisit@place.inn: inn.again over inn.ada, inn.regular
never eligible in any cell: none
eligible but never presented in any cell: 3 innQuiet [entry, lore/places.lute] placeVisit@place.inn — lost to inn.again inn.ada [scene, scenes/inn/ada.lute] placeVisit@place.inn — lost to inn.again inn.regular [scene, scenes/inn/regular.lute] placeVisit@place.inn — lost to inn.againThe visit opens inn.again’s after:, and met(ada) makes trusted(ada) derive, so Ada’s second scene wins the inn in every cell. The quests settle in every cell, after the cell’s values are written: the visit starts ferry, and from day 3 its objectives both hold, so the quest is complete there and day.farewell (after: completed("ferry")) joins the morning sequence. A cell whose quest settle halts — an objective the reference runtime cannot decide — carries a note, listed under notes: after the grid, and so does a cell where the settle moves an axis value away from what the cell wrote: a handler’s write to that path, or a seeded quest status the lifecycle moves on (see below).
Along a route
Section titled “Along a route”A save is history written down by hand. A route is history played: when the script has steps:, every cell starts from the world those steps leave, replayed exactly as lute play plays them — presentations, once spent, facts asserted, quests advanced. --until <step> stops the replay before a step, named by its number or its label:, so the grid shows what that step would find. This route spends an evening at the inn and comes back:
state: { run.slot: evening }steps: - occasion: placeVisit target: place.inn - label: back at the inn occasion: placeVisit target: place.innStopping before the way back, and asking whether a rumour about Ada would change it:
$ lute calendar --project . --script plays/evening.play.yaml --until "back at the inn" \ --axis 'holds('rumor', ['ada'])=false,true' --occasion placeVisit --target place.inncalendar: . — 2 cell(s) × 1 column(s), from the save in plays/evening.play.yaml, then its step 1 replayed (stopping before step 2, back at the inn)
holds('rumor', ['ada']) placeVisit place.innfalse inn.againtrue -
never eligible in any cell: 3 innQuiet [entry, lore/places.lute] placeVisit@place.inn — when: false inn.ada [scene, scenes/inn/ada.lute] placeVisit@place.inn — once: run — already presented this run inn.regular [scene, scenes/inn/regular.lute] placeVisit@place.inn — when: false
eligible but never presented in any cell: noneThe replayed step presents inn.ada, which asserts met(ada) and starts ferry, and spends it for the run. Without a rumour, Ada trusts you and inn.again wins the way back; with one, nothing at the inn is eligible — a hole the rumour opens. The header names where the cells start; --json carries the same text as from.
--until needs --script. A step number the script does not have, or a label it does not declare, is a usage error — a label gets a did-you-mean — and so is a replay that halts (an unscripted choice, say): the calendar cannot say what a route reaches if it does not play.
Quest and fact axes
Section titled “Quest and fact axes”An axis over a declared state path writes it as an engine: step would. Some things need more than a write, and each has an axis of its own:
quest.<id>.state=unset,active,complete,failedseeds the quest’s status, as a save’squests:does; a plain write would be overwritten by the lifecycle the cell settles.unsetandactivealso clear the objective progress a replayed route made, since the axis names a status, not the route’s objectives.quest.<id>.objectives.<oid>.done=false,trueis taken as written, as a save’s objective progress. Any otherquest.*path —activatedAt, say — is the lifecycle’s own bookkeeping and a usage error, and so is an id no quest declares.holds(<fact>)=true,falseasserts (true) or retracts (false) a base fact before the rules derive, so derived facts follow it. The fact must be ground, of a declared relation, with members of its domains; a derived relation is refused, since the rules decide it. Quote the axis in the shell:--axis 'holds('rumor', ['ada'])=false,true'.visited('<id>')=true,false(dsl 0.24.0) puts a scene or bundle beat in or out of the cell’s visited set, so a beat behindafter: visited(…)can be read both ways without a route.clock=<d1>..<d2>(dsl 0.24.0 §1) walks a declared clock: every slot of each day, in clock order —clock=1..2overslots: [morning, afternoon, night]is six cells,1 Mon morningthrough2 Tue night(the weekday label when the clock’sweek:has labels). Each cell writes the clock’sdayandslotpaths, soclock.indexandclock.weekdayread that position. Bare--axis clockis one week from day 1 (day 1 alone without aweek:). The clock axis cannot sit beside an axis over its owndayorslotpath, and a project without a clock refuses it.<family>.*=<values>and<family>[<axis>]=<values>(since 0.27.0) vary a wholeper:family — see Aper:family as one axis.
Anything else is a usage error that lists the axis kinds (with a did-you-mean for a mistyped state path):
$ lute calendar --project . --axis run.dya=1..3lute calendar: `--axis run.dya`: `run.dya` is not a declared state path in this project — did you mean `run.day`?; an axis is one of: a declared state path (`run.day=1..7`), every member of a `per:` family (`run.aff.*=6,7`) or the member another axis names (`run.aff[run.route]=6,7`), `quest.<id>.state=<status>,…`, `quest.<id>.objectives.<oid>.done=true,false`, `holds(<fact>)=true,false`, `visited('<scene or bundle-beat id>')=true,false`, `clock[=<d1>..<d2>]` (every slot of those days, in order)$ lute calendar --project . --axis 'holds('trusted', ['ada'])=true,false'lute calendar: `--axis holds('trusted', ['ada'])`: `trusted(ada)` is derived by rules and cannot be assertedWith the visit and the meeting as axes, inn.again wins only where both hold:
$ lute calendar --project . --axis "visited('inn.ada')=false,true" --axis 'holds('met', ['ada'])=false,true' \ --occasion placeVisit --target place.inncalendar: . — 4 cell(s) × 1 column(s), from declared defaults
visited('inn.ada') holds('met', ['ada']) placeVisit place.innfalse false innQuietfalse true innQuiettrue false innQuiettrue true inn.again +1
shadowed (eligible, not presented): visited('inn.ada')=true holds('met', ['ada'])=true placeVisit@place.inn: inn.again over innQuiet
never eligible in any cell: 2 inn.ada [scene, scenes/inn/ada.lute] placeVisit@place.inn — when: false inn.regular [scene, scenes/inn/regular.lute] placeVisit@place.inn — when: false
eligible but never presented in any cell: noneThe status is a seed, and the settle still runs. From the save above — Ada met, inn.ada visited — a quest seeded unset does not stay there:
$ lute calendar --project . --script plays/regular.play.yaml --axis quest.ferry.state=unset,active --occasion dayStartcalendar: . — 2 cell(s) × 1 column(s), from the save in plays/regular.play.yaml
quest.ferry.state dayStart (sequence)unset day.bellactive day.bell
notes: quest.ferry.state=unset: quest.ferry.state settled to active
never eligible in any cell: 2 day.farewell [scene, scenes/day/farewell.lute] dayStart — after: prerequisite not satisfied day.market [scene, scenes/day/market.lute] dayStart — when: false
eligible but never presented in any cell: noneferry starts on visited('inn.ada'), which the save holds, so the settle activates it and the note says so.
A per: family as one axis
Section titled “A per: family as one axis”A per: family — run.aff: { type: int, default: 0, per: suitor } — is one path per member (run.aff.ren, run.aff.kai, …), and an axis per member multiplies the grid by every one of them. Since 0.27.0 one axis covers the family:
--axis 'run.aff.*=6,7'gives every member the cell’s value.--axis 'run.aff[run.route]=6,7'gives it only to the member the value of--axis run.routenames in that cell; the other members keep their seed or default. The indexing axis must be an--axisof the same calendar naming at least one member of the family’s kind; at a value that names no member (a route outside the family,hotaru), the tied axis sets nothing: that one cell stands for all its values, itsrun.aff[run.route]column reads(none), and a note says so.
Tied to the route, an ending matrix is route × the route’s own affection, with every other suitor left at its default:
$ lute calendar --project . --script plays/confessed.play.yaml --axis run.route=ren,kai \ --axis 'run.aff[run.route]=6,7' --occasion termEndcalendar: . — 4 cell(s) × 1 column(s), from the save in plays/confessed.play.yaml
run.route run.aff[run.route] termEndren 6 end.ren.ember +1ren 7 end.ren.lantern +2kai 6 end.kai.ember +1kai 7 end.kai.lantern +2The axis is named as written in the row labels, the --json at keys and the --format csv header. The family itself is no value, so a bare --axis run.aff=… names the three forms instead of guessing; an axis over a member the family axis already sets (--axis 'run.aff.*=6' --axis run.aff.ren=7) is refused too:
$ lute calendar --project . --axis run.route=ren,kai --axis run.aff=6,7 --occasion termEndlute calendar: `--axis run.aff`: `run.aff` is a `per: suitor` family — name a member (`run.aff.ren`), all of them (`run.aff.*`), or tie it to an axis (`run.aff[run.route]`)Per-occasion axes and who is where
Section titled “Per-occasion axes and who is where”Some occasions happen once a day, but a grid over day × slot evaluates every occasion in every row. --occasion-axes <OCCASION>=<axis>,… (dsl 0.24.0) varies only the named axes for O: it is evaluated where every other axis is at its first value and left blank elsewhere — --occasion-axes dayStart=run.day reads the bell once per day. --occasion-axes dayStart=run.day,run.slot=evening holds run.slot at evening instead of its first value. --relation <relation> (repeatable) adds a table of the relation’s facts in every settled cell — a row per first argument, a column per cell — so the rules that move people become visible:
$ lute calendar --project . --axis run.day=1..3 --axis run.slot=morning,evening \ --occasion-axes dayStart=run.day --occasion placeVisit --target place.dock --relation presentThe grid then reads (the facts table and the lists follow it):
calendar: . — 6 cell(s) × 2 column(s), from declared defaults dayStart: varies over run.day only, at run.slot=morning; blank elsewhere
run.day run.slot dayStart (sequence) placeVisit place.dock1 morning day.bell dockBo +21 evening dockGulls +12 morning day.bell dockBo +22 evening dock.storm +23 morning day.bell, day.market dockGulls +13 evening dockGulls +1and --relation present, over the same six cells with every occasion evaluated:
facts present(person, place):person 1/morning 1/evening 2/morning 2/evening 3/morning 3/eveningada - inn - inn - innbo dock - dock - - -In --json a per-occasion column carries varies and heldAt, a cell outside it has no result for that column, and each cell carries a facts map ({ "present": ["present(bo, dock)"] }); --format csv adds a facts:present column.
With a declared clock, the grid is --axis clock=<d1>..<d2>, and a per-occasion column names the clock’s axes: --occasion-axes dayStart=clock.day — or the clock’s own paths, --occasion-axes dayStart=run.day — with ,clock.slot=<slot> (or ,run.slot=<slot>) to hold another slot than the day’s first. Give the harbour a clock over the paths it already has:
clock: day: run.day slot: run.slot slots: [morning, evening]$ lute calendar --project . --axis clock=1..3 --occasion-axes dayStart=clock.day,clock.slot=evening \ --occasion placeVisit --target place.dockcalendar: . — 6 cell(s) × 2 column(s), from declared defaults dayStart: varies over clock.day only, at clock.slot=evening; blank elsewhere
clock dayStart (sequence) placeVisit place.dock1 morning dockBo +21 evening day.bell dockGulls +12 morning dockBo +22 evening day.bell dock.storm +23 morning dockGulls +13 evening day.bell, day.market dockGulls +1Dropping cells no run reaches
Section titled “Dropping cells no run reaches”Axes are independent, so their product holds combinations no run can reach. --where <cel> keeps only the cells where a condition holds — evaluated after the cell’s values are written and its quests settle — and the header counts the rest. ferry completes only from day 3, so a completed ferry before then is not a state to plan for:
$ lute calendar --project . --axis run.day=1..3 --axis quest.ferry.state=unset,active,complete \ --where "quest.ferry.state != 'complete' || run.day >= 3" --occasion dayStartcalendar: . — 7 cell(s) (2 dropped by --where) × 1 column(s), from declared defaults
run.day quest.ferry.state dayStart (sequence)1 unset day.bell1 active day.bell2 unset day.bell2 active day.bell3 unset day.bell, day.market3 active day.bell, day.market3 complete day.bell, day.farewell, day.market
never eligible in any cell: none
eligible but never presented in any cell: none--where is plain CEL: an @def call is not expanded there. A condition that does not parse is a usage error before anything is evaluated, and one that evaluates unknown at some cell — it reads a path that cell leaves undecided — is a usage error naming the cell, since a cell is never dropped, or kept, on a guess. --json counts the dropped cells as pruned.
Undecided cells
Section titled “Undecided cells”A cell is ? when the reference runtime cannot decide a when that would decide it. Add a scene at the inn, priority 40, whose when is validAt('met', ['ada'], quest.ferry.activatedAt) — a historical query lute play has no clock for — and the inn column cannot be answered:
$ lute calendar --project . --axis run.slot=morning,evening --occasion placeVisit --target place.inncalendar: . — 2 cell(s) × 1 column(s), from declared defaults
run.slot placeVisit place.innmorning ? +1evening ? +1and the undecided block says why, per cell:
undecided (an unknown `when` decides the cell; play halts there): run.slot=morning placeVisit@place.inn: inn.oldFriend — `validAt('met', ['ada'], quest.ferry.activatedAt)` evaluates unknown: now()/validAt(...) has no reference-runtime resolution run.slot=evening placeVisit@place.inn: inn.oldFriend — `validAt('met', ['ada'], quest.ferry.activatedAt)` evaluates unknown: now()/validAt(...) has no reference-runtime resolutionAn unknown when below the winner does not make a cell undecided: the winner is presented whatever it evaluates to.
Machine-readable output
Section titled “Machine-readable output”--json emits where the cells start (from, the header’s text), how many cells --where dropped (pruned), the axes, the columns, one record per cell, the never-eligible list, and the never-presented list (neverPresented, each with beatenBy). A column of a targeted occasion carries target, or "anyTarget": true for the (any) column. A cell’s results hold one entry per column: winner (null on a select: all or sequence column, or when nothing is eligible), presented (the winner, or the whole offered or sequence list), shadowed, and, when a when evaluated unknown, unknown ([{ id, reason }]), with "undecided": true when that unknown decides the cell. A cell carries notes — a list — when its quest settle halted or moved an axis value:
{ "axes": [ { "path": "run.day", "values": [3] }, { "path": "run.slot", "values": ["morning"] } ], "cells": [ { "at": { "run.day": 3, "run.slot": "morning" }, "results": [ { "occasion": "board", "select": "all", "winner": null, "presented": ["noteFair"], "shadowed": [] }, { "occasion": "dayStart", "select": "sequence", "winner": null, "presented": ["day.bell", "day.market"], "shadowed": [] }, { "occasion": "placeVisit", "target": "place.dock", "select": "first", "winner": "dockGulls", "presented": ["dockGulls"], "shadowed": ["dockEmpty"] }, { "occasion": "placeVisit", "target": "place.inn", "select": "first", "winner": "innQuiet", "presented": ["innQuiet"], "shadowed": [] } ] } ], "columns": [ { "occasion": "board", "select": "all" }, … ], "from": "declared defaults", "neverEligible": [ { "id": "dockBo", "kind": "entry", "document": "lore/places.lute", "on": "placeVisit", "target": "place.dock", "reasons": ["when: false"] }, … ], "neverPresented": [ { "id": "dockEmpty", "kind": "entry", "document": "lore/places.lute", "on": "placeVisit", "target": "place.dock", "beatenBy": ["dockGulls"] } ], "pruned": 0}--format csv writes one row per cell and column — the axis values, then occasion,target,select,winner,presented,shadowed,unknown,notes, lists joined with ; (an (any) column’s target reads (any)) — for a spreadsheet, then, after a blank line, a second table of the beats eligible but never presented, each with what beat it:
$ lute calendar --project . --axis run.day=2..3 --axis run.slot=evening --occasion placeVisit --format csvrun.day,run.slot,occasion,target,select,winner,presented,shadowed,unknown,notes2,evening,placeVisit,place.dock,first,dock.storm,dock.storm,dockGulls;dockEmpty,,2,evening,placeVisit,place.inn,first,inn.ada,inn.ada,,,3,evening,placeVisit,place.dock,first,dockGulls,dockGulls,dockEmpty,,3,evening,placeVisit,place.inn,first,inn.ada,inn.ada,,,
neverPresented,kind,document,occasion,target,beatenBydockEmpty,entry,lore/places.lute,placeVisit,place.dock,dock.storm;dockGullsLimits and exit codes
Section titled “Limits and exit codes”The calendar compiles the whole project the way lute play does, so, unlike lute beats, it needs a project that compiles. With the contradictory storm from above:
$ lute calendar --project . --axis run.day=1..3./scenes/dock/storm.lute:7:8: error [E-BEAT-UNREACHABLE] beat `dock.storm` is never eligible: its `when` `run.slot == 'morning' && run.slot == 'evening'` is provably falseStandard error adds lute calendar: 1 of 9 document(s) failed to compile; refusing to evaluate the calendar, and the exit is 1.
Every axis is validated before a cell is evaluated. An undeclared path, a value outside the path’s type, an empty or non-integer range, an axis given twice, a quest path other than a status or an objective’s done, an undeclared quest, a holds(…) over a derived or undeclared relation (or with a value other than true/false), a --target that none of the listed occasions takes, and a grid of more than 10,000 cells are usage errors, as are an --until that names no step, a replay that halts, and a --where that does not parse or is unknown at some cell:
$ lute calendar --project . --axis run.slot=noonlute calendar: `--axis run.slot`: `run.slot` does not take `noon`: the enum's members are morning, evening$ lute calendar --project . --axis quest.ferry.activatedAt=1lute calendar: `--axis quest.ferry.activatedAt`: `quest.ferry.activatedAt` is the quest lifecycle's own bookkeeping and cannot be set per cell — an axis over a quest is `quest.<id>.state` (its status) or `quest.<id>.objectives.<oid>.done`$ lute calendar --project . --script plays/evening.play.yaml --until "back at the ink"lute calendar: plays/evening.play.yaml: `--until back at the ink` names no step number or `label:` of the script — did you mean `back at the inn`?$ lute calendar --project . --axis run.day=1..200 --axis user.visits=0..60lute calendar: the axes' product exceeds 10000 cellsExit 0 on success, 1 when the project does not compile, 2 on an I/O or usage failure.
lute scenario knowledge
Section titled “lute scenario knowledge”$ lute scenario --project <DIR> [--json] knowledge [--node <NODE>]List every condition that queries a fact — holds(…), count(…), countDistinct(…), or validAt(…) — in every guard slot (dsl 0.24.0): beat, entry and objective guards, line when=, <choice when>, <when> arm tests, ::jump/::set when, <on when>, reward when, quest start/fail, and objective until. Conditions are grouped by document, each with its source line, and each queried atom is traced back to what can produce it. A derived atom is followed through the rules, with the rule head’s variables bound to the atom’s constants, so each premise is shown as the ground atom it needs; a base atom ends at its producers:
- asserted by — the scenes (by scene key), quests, entries and bundle beats (by canonical id
<document id>.<beat id>) whose::assertcan produce it, with their documents; - seed facts — the schema’s
facts:that match it; - reserved — a
reserved: truerelation: the engine asserts it; - NO PRODUCER — nothing asserts it, no seed fact, not reserved, no rule. The condition is waiting on content no one has written.
$ lute scenario --project . knowledgeproject root: . knowledge (every fact-guarded condition, by document -> relations read -> producers):
lore/places.lute entry `innQuiet` when: !holds('present', ['ada', 'inn']) not present(ada, inn) — holds unless defeated defeated when present(ada, inn) is derived ⇐ cel("run.slot == 'evening'") rule: present(ada, inn) :- cel("run.slot == 'evening'") cel("run.slot == 'evening'") — state condition on run.slot, decided at run time
entry `dockBo` when: holds('present', ['bo', 'dock']) present(bo, dock) — derived by 1 rule rule: present(P, dock) :- works(P, dock), cel("run.slot == 'morning' && run.day != 3") works(bo, dock) — seed facts works(bo, dock) cel("run.slot == 'morning' && run.day != 3") — state condition on run.day, run.slot, decided at run time
entry `noteDocked` when: holds('arrived', ['dock']) arrived(dock) — reserved — the engine asserts it
quests/ferry.lute objective `ferry.word` done: holds('trusted', ['ada']) trusted(ada) — derived by 1 rule rule: trusted(P) :- met(P), not rumor(P) met(ada) — asserted by scene `inn.ada` (scenes/inn/ada.lute) not rumor(ada) — always holds (nothing produces rumor(ada): no assert, seed fact, engine write or rule) — cannot be defeated
scenes/inn/ada.lute scene `inn.ada` when: holds('present', ['ada', 'inn']) present(ada, inn) — derived by 1 rule — traced above under entry `innQuiet` in lore/places.lute
scenes/inn/again.lute scene `inn.again` when: holds('trusted', ['ada']) trusted(ada) — derived by 1 rule — traced above under objective `ferry.word` in quests/ferry.luteOnly the rules that can conclude the queried atom are followed: dockBo asks for present(bo, dock), so the rule that places Ada at the inn is not listed under it. A derived atom’s rules print once per report; every later mention says where they were traced. A negation reads “holds unless defeated”, followed by one line per fact that could defeat it — defeated when <fact> is asserted by …, … is derived ⇐ <premises>, defeated when the engine asserts <fact> (reserved), or defeated from the start: <fact> is a seed fact. not rumor(ada) has no producer, so it “always holds … — cannot be defeated”: the rumour that would make Ada distrust you is a relation declared and never written. (0.23 printed this as NO PRODUCER, which read as a warning; a positive read with no producer still says NO PRODUCER.) Under check --project . --wip, a guard that is dead only because of such a relation is a warning, not an error.
Every premise of a rule is traced beneath it. A cel(…) premise is a state condition the rules cannot decide ahead of time, so it names the paths it reads — cel("run.slot == 'evening'") — state condition on run.slot, decided at run time — and an entity-kind atom is a membership test, not a relation that lacks a producer. With the trust rule written trusted(P) :- person(P), met(P), not rumor(P):
$ lute scenario --project . knowledge --node ferry.wordproject root: . knowledge (every fact-guarded condition, by document -> relations read -> producers):
quests/ferry.lute objective `ferry.word` done: holds('trusted', ['ada']) trusted(ada) — derived by 1 rule rule: trusted(P) :- person(P), met(P), not rumor(P) person(ada) — entity kind `person`; ada is a member met(ada) — asserted by scene `inn.ada` (scenes/inn/ada.lute) not rumor(ada) — always holds (nothing produces rumor(ada): no assert, seed fact, engine write or rule) — cannot be defeatedSince dsl 0.26.0 §6 a rule body may count — canPass(pier) :- count(hasItem(_)) >= 2 — and the count is traced like any premise: the facts it counts, with their producers beneath it:
$ lute scenario --project . knowledge --node pier.boatproject root: . knowledge (every fact-guarded condition, by document -> relations read -> producers):
scenes/boat.lute scene `pier.boat` when: holds('canPass', ['pier']) canPass(pier) — derived by 1 rule rule: canPass(pier) :- count(hasItem(_)) >= 2 count('hasItem', ['_']) >= 2 — counts: hasItem(_) — asserted by scene `wren.talk` (scenes/wren.lute)A negated premise the project can make false is followed by what would do it. The rule body is instantiated against every fact that may hold in some run (the may set check --project decides guards with, here counting every assert site as live), and constants the positive premises force are carried into the negation (not liar(hollis), not not liar(_)). Once a bundle beat writes the rumour:
not rumor(ada) — holds unless defeated defeated when rumor(ada) is asserted by beat `town.gossip.whisper` (lore/gossip.lute)A derived defeater names the facts that join to produce it — here an entry guarded on !holds('present', ['bo', 'dock']):
not present(bo, dock) — holds unless defeated defeated when present(bo, dock) is derived ⇐ works(bo, dock) [seed], cel("run.slot == 'morning' && run.day != 3")A fact with several derivation routes lists every one of them (dsl 0.25.0 §9), separated by /, so the report shows each way the negation can fail, not only the first. Add a rule present(P, L) :- hired(P, L) and a scene dock.hire that asserts hired(bo, dock):
not present(bo, dock) — holds unless defeated defeated when present(bo, dock) is derived ⇐ works(bo, dock) [seed], cel("run.slot == 'morning' && run.day != 3") / ⇐ hired(bo, dock) [scene `dock.hire` (scenes/dock/hire.lute)]At most three defeating facts are printed per premise, then … and N more defeating facts. A negated relation that may hold any tuple (an open argument domain) says so after its defeat line (— any `rumor` tuple may hold).
--node <NODE> selects: a scene (every guard in it — inn.again), a bundle beat, an entry id (dockBo), a quest objective as <quest>.<objective> (ferry.word), quest:<id> for every objective of a quest, or <scene>#<branch>.<choice> for one choice’s guard. With a scene inn.ask whose line and choice are fact-guarded:
$ lute scenario --project . knowledge --node inn.askproject root: . knowledge (every fact-guarded condition, by document -> relations read -> producers):
scenes/inn/ask.lute line `@ada` (line 9) when: holds('present', ['ada', 'inn']) present(ada, inn) — derived by 1 rule rule: present(ada, inn) :- cel("run.slot == 'evening'") cel("run.slot == 'evening'") — state condition on run.slot, decided at run time
choice `ask.ferry` (line 12) when: holds('trusted', ['ada']) trusted(ada) — derived by 1 rule rule: trusted(P) :- met(P), not rumor(P) met(ada) — asserted by scene `inn.ada` (scenes/inn/ada.lute) not rumor(ada) — always holds (nothing produces rumor(ada): no assert, seed fact, engine write or rule) — cannot be defeated--node 'inn.ask#ask.ferry' keeps only the choice. A node that names no fact-guarded condition is a usage error (exit 2), with a did-you-mean when one is close:
$ lute scenario --project . knowledge --node ferry.wrodlute scenario knowledge: `--node ferry.wrod` names no fact-guarded condition (a scene, bundle beat, entry, `quest:<id>`, `<quest>.<objective>` or `<scene>#<branch>.<choice>`) — did you mean `ferry.word`?--json (given before the subcommand, like every scenario option) emits { "roots": [ { "root", "elements", "relations" } ] }. Each element is { node, document, line?, for, conditions, reads } — for lists the --node handles that select it (["inn.ask", "inn.ask#ask.ferry"]), conditions maps the slot (when, done, …) to its expanded text, and reads lists the atoms it queries, a negated read as !present(ada, inn). relations maps each relation involved to { declared, derived, reserved, seedFacts, assertedBy, rules }, each rule { rule, premises }, where a premise is { relation, negated? }, { entityKind } for a kind atom, or { cel } for a cel(…) premise (its condition text). --format dot is refused: the knowledge map is not a graph.
lute scenario envelope words its Facts rows the same way (works/2 (producible) — seed facts works(bo, dock), arrived/1 (producible) — reserved — the engine asserts it, trusted/1 (producible) — derived by 1 rule), and lute lore ends with a Derived section built from the same traces: each conclusion the rules can reach, the rule instance and evidence behind it, and the conditions it gates.
Exit 0 on success, 2 on an I/O failure or an unmatched --node.
What the scenario graph leaves out
Section titled “What the scenario graph leaves out”The scenario graph draws only prerequisites that connectivity analyzes. A quest’s edges come from its follows=, its subquest tree, the anchoring conjuncts of its start, and the ::accepts that take it up (dsl 0.24.0 §2, 0.25.0 §4); see Quests & scenes. The bare lute scenario view says what it therefore did not draw. Take a lighthouse: a quest relight with start="entry.keeperLog.everRead && visited('arrival')" and two subquests, oil and wick; a salvage quest the engine accepts from a board (accept="external"); a lostDog quest only a test accepts; two keeper beats, greeting with after="visited('arrival')" and warning gated by when="visited('keeper.greeting')"; and a scene farewell with after: completed("salvage"):
$ lute scenario --project .project root: . topological layers: layer 0: scene(arrival), scene(farewell), beat(keeper.warning), entry(keeperLog) layer 1: quest(relight), beat(keeper.greeting) layer 2: quest(oil), quest(wick) edges (prerequisite -> dependent) [atom kind(s)]: scene(arrival) -> quest(relight) [start] scene(arrival) -> beat(keeper.greeting) [visited] quest(relight) -> quest(oil) [subquest] quest(relight) -> quest(wick) [subquest] entry(keeperLog) -> quest(relight) [start] unanchored (no `after` / `follows` — available from the start of play; no prerequisites in this graph): quest(lostDog) quest(salvage) beat(keeper.warning) — its `when` reads visited('keeper.greeting'), which gates it but draws no edge; write `after="visited('keeper.greeting')"` to anchor it note: 6 `visited()`/`completed()`/`active()` reference(s) not drawn — a quest's edges come from its `follows`, its subquest tree, its `start` conjuncts and its `::accept`s: scene(farewell) -> completed("salvage") — quest(salvage) is on no edge (no `follows`, tree, `start` anchor or `::accept`) quest(relight) reads visited('keeper.warning') in its objective climb done — a condition read, not an anchor quest(oil) reads visited('arrival') in its objective fetch done — a condition read, not an anchor quest(wick) reads visited('arrival') in its objective trim done — a condition read, not an anchor quest(salvage) reads visited('arrival') in its objective dive done — a condition read, not an anchor quest(lostDog) reads visited('arrival') in its objective find done — a condition read, not an anchorrelight is anchored by its start conjuncts, so it needs no follows=, and oil and wick hang off it by [subquest] edges. keeper.greeting is ordered by its own after=; keeper.warning reads the same kind of fact in its when, which gates it but orders nothing, so it is listed with the after= to write. salvage and lostDog are on no edge, so the completed("salvage") in farewell’s after: is not drawn either, and an objective’s visited() read is a condition, never an anchor.
--json carries the unanchored nodes as unanchored on the root, the beat hints as unanchoredHints (node → hint), and the undrawn references as omitted, each { from, kind, quest } for a completed/active reference or { from, kind: "visited", scene, slot } for a quest’s visited() read. lute scenario --project . reach --node quest:relight lists the quest’s anchors (anchors, each { kind, from }, in JSON):
$ lute scenario --project . reach --node quest:relightproject root: .reach quest(relight): verdict: Reachable — a satisfiable route exists under your declared routes. follows: (none declared) — anchored; each anchor holds before it activates, through any one of its sources: [start] entry(keeperLog) [start] scene(arrival) referenced node(s) (see the anchors above — this is NOT a flat requirement list): - scene(arrival): Reachable — a satisfiable route exists under your declared routes. - entry(keeperLog): Reachable — a satisfiable route exists under your declared routes.Lore entries join the graph only as the source of a quest’s start anchor, entry(<id>). Bundle beats are always nodes: a beat without after= is an entry node, and one with after= is the dependent of the edges it draws. lute scenario --project . reach --node keeper.greeting (or reach --node beat:keeper.greeting) prints the file that declares it with its on, target and when; see The scene graph.
Fact edges (lute scenario --facts)
Section titled “Fact edges (lute scenario --facts)”$ lute scenario --project <DIR> [--format text|dot | --json] --factsProgress in a collecting game is gated by facts, not by visits: a gym opens on a badge, a tower on a lens. Those gates draw no after: edge, so the bare graph puts every gym in layer 0. --facts (dsl 0.26.0 §8) adds a fact edge producer -> reader [fact] wherever a scene’s, beat’s or quest’s gate — its when: or start= — reads holds(F) and the producer asserts F; when F is derived, the edge runs from whatever asserts a fact the deriving rule needs and names the rule’s conclusion, [hasItem(goodRod), via canPass(pier)]. The layers are then drawn over the fact edges too, wherever one closes no cycle, so what a fact unlocks sits below what gives it. Wren’s scene gives a rod (::assert{hasItem(goodRod)}); the lake reads holds('hasItem', ['goodRod']), and the boat reads holds('canPass', ['pier']), which the rule canPass(pier) :- hasItem(goodRod) derives:
$ lute scenario --project .project root: . topological layers: layer 0: scene(pier.boat), scene(pier.lake), scene(wren.talk) edges (prerequisite -> dependent) [atom kind(s)]: (none)$ lute scenario --project . --factsproject root: . topological layers (after: and fact edges): layer 0: scene(wren.talk) layer 1: scene(pier.boat), scene(pier.lake) edges (prerequisite -> dependent) [atom kind(s)]: (none) fact edges (producer -> reader) [asserted fact]: scene(wren.talk) -> scene(pier.boat) [hasItem(goodRod), via canPass(pier)] scene(wren.talk) -> scene(pier.lake) [hasItem(goodRod)]Across a whole game the edges show one area’s gate hanging on another’s scene — on Monster League, scene(mid.gameCorner) -> scene(east.ashTowerLens) [hasItem(spectralLens)]. --json adds factEdges to the root, each { from, to, fact, via?, layered } (via when the gate reads a derived fact; layered whether the edge took part in the layering); --format dot draws each one dotted and purple, labelled with the fact. --facts is an option of this graph view only: reach, envelope, and check --project’s connectivity passes still read the declared after: structure.
Beat rows in project.index.json
Section titled “Beat rows in project.index.json”The overviews read the same beat table an engine does. Since 0.23.0, a beat row in project.index.json carries the beat’s when — with every @def expanded, so an engine or a tool can show it without the schema — and its title, the label a select: all menu shows. Both are omitted when the beat has none, so a project that uses neither compiles byte-identically. A bundle beat’s row has kind bundle. Since dsl 0.26.0 a kind beat’s row has target: "kind:<kind>" and targetKind: { kind, prefix, members } — {"kind": "trainer", "prefix": "npc", "members": ["gus", "r16Gus"]} — so an engine can offer it for every <prefix>.<member> raise.
{"id": "noteFerry", "kind": "entry", "document": "lore/places.lute", "on": "board", "priority": 0, "when": "run.day <= 2", "title": "Ferry times"}{"id": "day.market", "kind": "scene", "document": "scenes/day/market.lute", "on": "dayStart", "priority": 0, "once": "run", "when": "run.day == 3", "title": "Market day"}{"id": "dock.storm", "kind": "scene", "document": "scenes/dock/storm.lute", "on": "placeVisit", "target": "place.dock", "priority": 50, "once": "run", "when": "run.slot == 'evening' && run.day == 2"}Evidence in 0.34.0
Section titled “Evidence in 0.34.0”calendar cell results and aggregate rows carry evidence; bounded
aggregates also carry their finite scope (axes, product, seed, and any
--where filter). A calendar result is therefore bounded to the evaluated
grid, not a claim about all runtime states. scenario, beats, play, and
test expose the same evidence vocabulary in JSON.