콘텐츠로 이동

치트시트

모든 구문을 한 페이지에 복사해 쓸 수 있는 스니펫으로 모았습니다. 아래의 lute 블록은 모두 CI에서 실제 툴체인으로 컴파일 검사를 거치며, 각 절 끝의 링크는 전체 설명 페이지로 이어집니다.

이야기를 쓰고 플레이하는 데 필요한 것을 한 화면에 모았습니다. 나머지 내용은 프로젝트 구성부터 모든 구문을 자세히 다룹니다.

---
kind: scene
id: cafe.counter
pov: fixer
on: chapter
after: 'visited("cafe.arrival")'
priority: 10
when: "run.tips == 0"
state:
run.tips: { type: int, default: 0 }
run.mood: { type: { enum: [calm, tense] }, default: calm }
---
## Counter {#counter}
@narrator: The espresso machine hisses.
@mira: The usual?
@fixer{mono}: She remembered.
@mira{os}: One moment!
<branch id="order" prompt="What will you have?">
<choice id="tea" text="Tea, and a tip">
::set{run.tips += 5}
@mira: Thank you!
</choice>
<choice id="nothing" text="Nothing tonight">
::set{run.mood = 'tense'}
@mira: Suit yourself.
</choice>
</branch>
<match subject="run.mood">
<when is="calm">
@mira: Stay as long as you like.
</when>
<when is="tense">
@mira: We close at ten.
</when>
</match>
  • @speaker: text는 대사 한 줄이고, @narrator는 내레이션입니다. {mono}는 속마음(시점 인물 pov:나 monoSpeakers:에 적은 화자만), {os}는 화면 밖, {vo}는 보이스오버입니다. 콘텐츠는 ## Heading 섹션 아래에 둡니다. → 대사, 캐스트, 연출
  • <branch>는 <choice text>로 이루어진 선택 메뉴입니다. → 선택지, 허브, 점프, 엔딩
  • <match subject>는 들어맞는 첫 <when is> 갈래를 고릅니다. → match와 when
  • ::set{…}은 state:에 선언한 상태를 씁니다. → 상태 쓰기와 팩트
  • on:은 씬이 계기에 응답하게 합니다. 여기서는 chapter이며, 플러그인이 계기를 선언하기 전까지는 어떤 이름이든 됩니다. after:는 다른 씬 뒤로 순서를 정하고, priority:는 같은 자리에서 이기며, when:은 상태로 자격을 거릅니다. → 비트 · 씬을 이야기로 잇기

테스트는 씬 하나를 걷고, 플레이는 스텝마다 계기를 한 번씩 발생시키며 프로젝트 전체를 진행합니다:

tests/counter.test.yaml
file: ../scenes/counter.lute # relative to this test file
visited: [cafe.arrival] # the after: route this walk assumes
choose: { order: tea }
expect:
state: { run.tips: 5 }
transcriptContains: ["@mira: Stay as long as you like."]
end: complete
# plays/story.play.yaml: cafe.arrival answers `chapter` first, then cafe.counter
choose: { order: nothing }
steps:
- occasion: chapter
expect: { winner: cafe.arrival }
- occasion: chapter
expect: { winner: cafe.counter }
expect:
transcriptContains: ["@mira: We close at ten."]
Terminal window
$ lute check --project . # every document, and the project as a whole
$ lute test --project . # every *.test.yaml, and every play with an expect:
$ lute play --project . --script plays/story.play.yaml # the transcript of one playthrough

→ 테스트와 플레이 · 스토리 플레이

my-game/
├── lute.project.yaml profiles, plugins, identity, defaults, chapters
├── world.schema.yaml run/user/app state, enums, defs, facts, rules
├── plugins/game.occasions/ optional: plugin.yaml + occasions/*.yaml + events/*.yaml
├── scenes/*.lute kind: scene
├── quests/*.lute kind: quest
├── lore/*.lute kind: lore
├── components/*.component.lute component: <name>
├── tests/*.test.yaml lute test
└── plays/*.play.yaml lute play; lute test runs those with an expect:

lute init --template beats <DIR>는 이 구성을 그대로 만들어 줍니다: 계기 플러그인, id:가 있는 비트, 퀘스트, 엔트리 비트, engine: 스텝과 expect:가 담긴 플레이 스크립트, 시나리오 테스트까지 들어 있으며, 만든 그대로 프로젝트 전체 check, test, play를 통과합니다.

lute.project.yaml:

pluginsDir: plugins/
defaultProfile: game
profiles:
game:
plugins: { game.occasions: true } # true = active with defaults
identity: # both values below are the defaults
lineId: "{prefix}.{speaker}_{code}"
voiceKey: "{prefix}.{speaker}-{code}" # the 0.21 default was {speaker}-{code}: pin it to keep old keys
defaults: # frontmatter every document inherits
pov: fixer # the effective point of view: who may speak {mono}
monoSpeakers: [mira] # further speakers allowed {mono}
luteVersion: "0.30.0"
uses: # resolved against THIS file's directory
- world.schema.yaml
- schema/areas/*.schema.yaml # 0.26.0: a glob, expanded in path order; matching nothing is fine
questTier: run # 0.26.0: the tier= of every <quest> that writes none
chapters: # 0.28.0: chains of scenes by id, in play order
- on: chapter # one chain per occasion: every listed scene answers it
scenes: [prologue, counter, accusation]

chapters:(0.28.0, 0.27.0의 단일 사슬을 대신함)는 계기마다 하나씩 있는 사슬의 목록입니다. 사슬은 나열한 장면마다 on:, after: 'visited("<앞 장면 id>")', 그리고 앞에서부터 줄어드는 priority:(30, 20, 10, …)를 써 넣습니다. 장면이 직접 쓴 키가 이깁니다. 어떤 장면도 선언하지 않은 id, 모르는 on:, 두 번 적은 장면, 한 계기의 두 사슬, 모양이 틀린 사슬은 E-CHAPTERS입니다(그 사슬만 적용되지 않고 나머지는 적용됩니다). 계기의 select: sequence(한 번의 발생에서 자격 있는 비트를 모두 선택 순서대로 재생)와는 별개입니다. 그런 계기에서는 사슬이 on:과 priority:만 만들므로, 나열된 장면이 한 번의 발생 안에서 목록 순서대로 재생됩니다. 대상에게 발생하는 계기라면 나열된 모든 장면이 자기 target:을 선언합니다. 장면 연결하기를 보세요.

defaults:에는 kind, character, season, episode, pov, monoSpeakers, luteVersion, contentLang, uses, extends, components, extra, 그리고 (0.26.0) questTier: run | user만 쓸 수 있습니다(그 밖의 키는 E-DEFAULTS-KEY). 문서가 어떤 키를 직접 쓰면 그 키의 기본값은 병합 없이 통째로 대체됩니다(uses: []는 “가져오기 없음”). 문서의 kind에서 허용되지 않는 기본값은 그 문서에는 적용되지 않습니다. 0.26.0부터 uses 항목은 글롭(*, **)일 수 있습니다: 아무것도 맞지 않는 글롭(비었거나 아직 없는 디렉터리)은 아무것도 가져오지 않을 뿐이고, 글롭이 아닌 경로는 반드시 있어야 합니다.

문서의 components:는 그 문서의 디렉터리를 기준으로, defaults: components:는 defaults: uses:처럼 매니페스트의 디렉터리를 기준으로 해석합니다. 그래서 같은 파일을 scenes/의 씬은 components: [../components/greet.component.lute]로, 매니페스트는 defaults: { components: [components/greet.component.lute] }로 씁니다.

0.22.0부터 기본 voiceKey에 {prefix}가 들어가므로 보이스 키는 프로젝트 전체에서 유일합니다. 내레이션과 속마음을 포함한 모든 줄이 voiceKey를 가집니다. 이것은 녹음 파일이 있다는 약속이 아니라 조인 키이며, 녹음할 역할을 고르는 일은 소비하는 쪽이 합니다. 0.21 키로 음성을 녹음해 둔 프로젝트는 identity: { voiceKey: "{speaker}-{code}" }로 고정하면 되고, 그러면 수식어를 뗀 텍스트가 다른 줄들이 한 키에 모이는 곳마다 E-DUP-VOICEKEY가 납니다.

world.schema.yaml은 --- 구분선이 없는 일반 YAML입니다. 씬은 uses:로 이 파일을 가져옵니다:

state: # scalar only: int | double | bool | string | enum
run.pressure: { type: int, default: 0 }
run.day: { type: int, default: 1, owner: engine } # content reads it; ::set is E-ENGINE-OWNED-WRITE
run.slot: { type: { enum: [morning, afternoon, night] }, default: morning, owner: engine }
run.mood: { type: { enum: [calm, tense] }, default: calm }
run.rival: { type: { enum: [kai, lee] } } # no default: maybe-unset until set
run.trust: { type: int, default: { _: 0, vesna: 2 }, per: crew } # 0.24.0: run.trust.vesna (2), run.trust.toma (0)
run.today: { type: { domain: weekday }, default: mon } # {{run.today}} renders "Monday"
user.runs: { type: int, default: 0 }
app.rating: { type: { enum: [teen, adult] }, default: teen }
enums: # content vocabulary: you declare every member
emotion: [neutral, happy, worried]
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
costume: [apron, coat]
framing: [closeUp, wide]
cameraMove: [pushIn, pan]
transition: [cut, crossfade]
cgLayout: [fullscreen, inset]
musicPlayback: [start, fadeOut]
sequence: [doorOpen] # project-declared sequences ::sequence{name} refers to
textStyle: [whisper, shout] # inline :whisper[…] spans
weekday: { members: [mon, tue], labels: { mon: Monday, tue: Tuesday } } # labels: 0.24.0
entities:
crew: { members: [vesna, toma] }
watch: { subsetOf: crew, members: [vesna] } # 0.24.0 sub-kind: legal wherever a kind is
topic: { members: [manifest, heading] }
relations:
awake: { args: [crew], tier: run }
knows: { args: [crew, topic], tier: run }
can_halt: { args: [crew], derive: true }
onWatch: { args: [crew], derive: true }
asleep: { args: [crew], tier: run, excludes: [awake] } # 0.25.0: never both on the same args (symmetric)
hurt: { args: [crew], tier: run, reserved: true, changedOn: [dusk] } # 0.25.0: the engine writes it only on `dusk`
facts:
- "awake(vesna)"
rules:
- "can_halt(C) :- awake(C), knows(C, manifest)"
- "onWatch(C) :- awake(C), count(awake(_)) >= 2" # 0.26.0: count / countDistinct in a rule body
defs:
calm: "run.pressure < 2" # shorthand: the body alone, type inferred (bool)
veteran: "user.runs >= 10"
vesnaKnows: "holds('knows', ['vesna', 'manifest'])"
pace: "run.pressure > 2 ? 0.4 : 0.8" # inferred double
holdTime: "0.5" # a constant: the only kind of def an attribute takes
atLeast: { type: bool, params: { n: int }, cel: "user.runs >= n" } # params need type:
clock: # optional (0.24.0), one per project: see Clock below
day: run.day # owner: engine
slot: run.slot # optional, with slots: (owner: engine); omit both for a clock of whole days
slots: [morning, afternoon, night] # exactly the slot enum's members, in order
raise: { slot: slotStart, dayEnd: dusk } # optional: one occasion (= slot:), or any of slot / dayStart / dayEnd
raiseAtStart: true # optional (0.28.0): the engine raises slot / dayStart where the run starts
week: { length: 7, first: 0, labels: [Mon, Tue, Wed, Thu, Fri, Sat, Sun] } # optional
last: { day: 1, slot: night } # optional (0.27.0): where the clock ends; or days: 1
cast: # optional (0.23.0): once declared, any other speaker is E-CAST-UNKNOWN
vesna: { name: Vesna, present: "holds('awake', ['vesna'])", emotions: [neutral, worried] } # 0.24.0 keys
toma: { name: Toma }
mira: { name: Mira }
guard1: { name: Night Guard, sharedName: true } # 0.26.0: a role name several speakers share
guard2: { name: Night Guard, sharedName: true }

def 타입 추론: 비교, && || !, holds, has는 bool이고, count와 산술은 피연산자에 따라 int 또는 double, 정수·소수 리터럴은 int/double이며, 경로를 그대로 읽으면 그 경로의 타입입니다. 검사기가 타입을 알 수 없는 본문(예: "@other")은 긴 형태 { type: …, cel: … }로 써야 하며, 그렇지 않으면 E-DEF-DECL입니다. 연출 어휘는 프로젝트가 선언하는 닫힌 도메인입니다. 코어는 도메인 이름만 정하고 멤버는 하나도 싣지 않으며, 멤버는 문서나 스키마의 enums:, 또는 플러그인이 선언합니다:

도메인쓰는 곳비고
anchor::actor{anchor}default:를 반드시 적습니다. anchor를 생략하면 그 멤버를 씁니다.
action::actor{action}, 줄의 action=exits:를 반드시 적습니다. 그 멤버가 퇴장이며, 이름으로 추측하지 않습니다.
emotion줄과 ::actor의 emotion캐스트 항목의 emotions:가 화자별로 더 좁힙니다.
costume::actor{costume}
framing · cameraMove · transition::camera{framing} · {move} · {transition}
cgLayout::cg{layout}
musicPlayback · mood · volume::music{playback} · {mood} · {volume}
vfxType::vfx{type}
sequence::sequence{name}프로젝트가 가진 시퀀스를 가리키는 이름입니다.
textStyle인라인 :<멤버>[…]멤버가 곧 스팬 이름이며 속성은 받지 않습니다.

선언되지 않은 도메인을 쓰면 E-DOMAIN-UNKNOWN, 멤버가 아닌 값은 E-BAD-ENUM입니다.

cast:(0.23.0)는 화자를 선언합니다. 스키마 문서에 쓰거나, 플러그인의 cast 내보내기(cast/*.yaml, 같은 cast: 맵)로 선언합니다. 캐스트가 하나라도 선언되면 그 밖의 화자(@narrator 제외)는 씬, 퀘스트, 엔트리, 번들 비트 어디서나 비슷한 이름을 제안하는 E-CAST-UNKNOWN이며, 0.24.0부터는 캐스트 밖의 ::actor{character}와 ::camera{focus}도 마찬가지입니다. 캐스트를 선언하지 않으면 어떤 화자 id든 받아들여집니다. 씬 프론트매터에는 cast:를 쓸 수 없습니다(E-META-UNKNOWN-KEY). lute context가 캐스트를 보여 줍니다. 캐스트 항목에는 present:(조건)와 emotions:도 둘 수 있습니다(0.24.0). 그 화자의 줄을 감싼 가드가 present를 함의하지 않으면 W-CAST-ABSENT이고, 화자의 emotions: 밖의 emotion=은 E-BAD-ENUM입니다. {vo} 줄은 제외됩니다(화자가 씬의 시간 밖에 있을 수 있음). {os} 줄은 검사합니다. {os}는 씬 안에 있지만 화면 밖이라는 뜻이기 때문입니다. 항목에 assume: true를 두면 present: 안의 엔진 reserved: 관계에 대한 부정 holds를 참으로 읽으므로, present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"에는 inParty 가드만 있으면 됩니다. 0.25.0부터 그 관계가 changedOn: [battleEnd]를 선언하면, battleEnd에 제시된 단위와 시나리오 그래프에서 그 뒤에 오는 모든 단위(after: / after= / [start] 간선)에서는 assume이 더 이상 그 관계를 덮지 않습니다. 그런 줄은 가드를 달 때까지 다시 경고합니다.

역시 0.24.0: subsetOf:는 하위 종류를 선언합니다. watch 종류의 인자는 crew이기도 합니다. 0.26.0부터는 하위 종류의 멤버가 부모에 다시 적지 않아도 부모의 멤버이고(다시 적어도 됩니다), 한 종류나 enum에 같은 멤버를 두 번 적으면 E-ENTITY-KIND-SHAPE이며, 스키마는 다른 import가 선언한 종류에 add:로 멤버를 더할 수 있습니다(crew: { add: [ilsabet] }. 종류를 선언하는 import는 정확히 하나여야 하고, 두 파일이 같은 멤버를 더하면 두 파일을 모두 밝히는 E-ENTITY-KIND-SHAPE). 규칙 본문은 머리가 먹이지 않는 관계에 대해 count(R(…))이나 countDistinct(R(…), V…)를 >=, >, <=, <, ==, !=로 비교할 수 있습니다(그렇지 않으면 E-RULE-AGGREGATE-CYCLE). 여러 문서의 프론트매터 state:가 같은 경로를 선언하면 type, default, per, owner가 모두 같아야 합니다(E-STATE-DECL-CONFLICT). 경로를 함께 쓰려면 두 문서가 모두 가져오는 스키마에 한 번만 선언하세요. 같은 name:을 가진 캐스트 항목 둘은 둘 다 sharedName: true가 아니면 W-DISPLAY-NAME-DUP입니다. 여러 작가, 한 프로젝트를 보세요. per: <kind>는 닫힌 종류의 멤버마다 경로를 하나씩 선언합니다(run.trust.vesna). 열린 종류나 모르는 종류는 E-STATE-DECL입니다. 맵 형태 default:는 멤버마다 값을 주고 나머지는 _로 채웁니다. 멤버가 아닌 키, 값도 _도 없는 멤버, per: 없는 맵 기본값은 E-STATE-DECL입니다. 긴 형태 enum의 labels:는 { domain: … } 타입 경로를 {{path}}로 렌더링할 때 나오는 표시 이름이며, 멤버가 아닌 키의 라벨은 E-ENUM-LABEL-NOT-MEMBER입니다. entity kind도 같은 방식으로 labels:를 받습니다(0.27.0, room: { members: [chapel], labels: { chapel: the chapel } }). 멤버가 아닌 라벨은 E-ENTITY-KIND-SHAPE입니다. 텍스트({{occasion.target}}, 타입이 있는 경로, 타입이 있는 컴포넌트 인자)에서는 kind 라벨이 캐스트 name:보다 우선하고, 대사의 화자 이름은 캐스트 이름 그대로입니다. clock: 블록은 시계에서 설명합니다. seasons:(0.27.0)는 되풀이되는 기간을 이름 붙여 선언합니다(harvest: { live: "@harvestLive" }). 시즌이 열릴 때마다(live가 거짓→참) season.<name>.*를 prev.season.<name>.*로 옮기고 기본값으로 되돌리며, once: season:<name> 소진을 지우고 tier="season:<name>" 퀘스트를 초기화합니다. 잘못되었거나 선언되지 않은 시즌은 E-SEASON-DECL입니다.

→ 상태 스키마 · 가져오기 · 콘텐츠 어휘 · 팩트와 Datalog · 시계

kind필수그 kind에서만 쓰는 키
kind: sceneid:, 또는 레거시 character + season + episodeid, character, season, episode, episodeId, after, 비트 키 on / target / when / priority / once / also / share(0.25.0) / spentBy / for(0.27.0)
kind: quest본문에 <quest> 하나 이상id (선택, 묶음 이름)
kind: lore본문에 <entry>나 <beat> 하나 이상id (<beat>가 있으면 필수), series
컴포넌트 (kind: 없음)component: <name>component, params, effects(0.24.0), beat(0.27.0: 비트 템플릿의 머리. <beat use="<name>" id="…" param="…">로 씁니다)

모든 루트 kind는 title, pov, monoSpeakers, luteVersion, contentLang, profile, plugins, uses, extends, components, state, defs, enums, entities, relations, facts, rules, codesLocked, mode, extra(자유로운 설명 데이터)도 받습니다. 이 목록 밖의 키는 E-META-UNKNOWN-KEY이고, _가 들어간 키는 lowerCamelCase 이름을 알려 주는 E-AUTHOR-CASE입니다(속성, 태그 속성, 프로젝트 키도 같습니다). 문서 제목은 프론트매터 title: 하나뿐이며, 본문의 # 제목은 E-INERT-TITLE입니다. 퀘스트의 그래프 간선은 <quest>의 follows= 속성이며, 프론트매터 키가 아닙니다.

플러그인 프론트매터 키 중 타입이 entity, domain, enumFromOption, providerRef, assetKind인 키는 해당 타입의 속성과 같은 타입별 검증으로 검사됩니다(0.49.0). providerRef는 E-UNKNOWN-ID(경우에 따라 W-CATALOG-STALE), 에셋 종류는 E-ASSET-*가 되고, 엔티티·enum 오타는 비슷한 이름 제안과 함께 E-BAD-ENUM입니다. 예를 들면 (`mirra` is not a member of entity kind `person` (attribute `lead` of `frontmatter`) — did you mean `mira`?). 0.53부터는 같은 검사가 record 필드, list 항목, map의 키나 값 안에 중첩된 그런 값에도 깊이와 상관없이 적용되며, 메시지가 필드 경로를 적습니다(field `partner` of `talk` of `frontmatter`). 표현할 수 없는 값, 지원하지 않는 map 키 타입, 모양이 맞지 않는 값은 계속 E-FRONTMATTER-SCHEMA입니다.

→ 프론트매터와 프로필

---
kind: scene
id: diner.night
pov: fixer
monoSpeakers: [mira]
enums:
emotion: [neutral, happy]
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
costume: [apron]
mood: [peaceful]
volume: [quiet, normal]
musicPlayback: [start, fadeOut]
vfxType: [whiteOut]
framing: [closeUp, wide]
cameraMove: [pushIn]
transition: [crossfade]
cgLayout: [fullscreen]
sequence: [doorOpen]
textStyle: [whisper]
state:
run.affection: { type: int, default: 0 }
---
## Counter {#counter}
::bg{location="diner" time="night"}
::music{playback="start" mood="peaceful" volume="quiet" assetId="bgm.diner"}
::actor{character="mira" anchor="center" action="fadeInUp" emotion="happy" costume="apron"}
::camera{focus="mira" framing="closeUp" move="pushIn" duration="0.5" wait="true"}
@narrator: The diner hums.
@mira{code="0010" emotion="happy"}: You're back, {{userName}}! Warmth: {{run.affection}}.
@fixer: I am.
@fixer{mono}: She remembered.
@fixer{mono when="run.affection > 2"}: She remembered my order, too.
@mira{mono}: He came back.
// a `//` line comment: the whole line is ignored
@mira{os}: Hold on!:pause{s=0.5} :whisper[Just a second.]
@mira{as="???"}: ...:speed[who's there]{rate=0.8}?
::sfx{sound="doorBell" assetId="sfx.bell"}
::vfx{type="whiteOut"}
::sequence{name="doorOpen"}
::cg{assetId="cg.diner.door" layout="fullscreen"}
::cg{assetId="cg.diner.door" display="hide"}
::video{assetId="mv.rain"}
::camera{framing="wide" transition="crossfade"}
::clear
::music{playback="fadeOut"}
::end{reason="closing"}
요소규칙
@speaker{attrs}: text@narrator는 내레이션(narration)이고, 그 밖의 화자는 모두 대사(dialogue)입니다. pov: 화자도 마찬가지입니다. : 뒤의 텍스트는 줄 끝까지 그대로입니다. cast:가 선언되어 있으면 화자는 그 안에 있어야 합니다(E-CAST-UNKNOWN). 줄의 가드가 캐스트의 present:를 함의하지 않으면 W-CAST-ABSENT이고, 화자의 emotions: 밖의 emotion=은 E-BAD-ENUM입니다(0.24.0).
줄 속성code, emotion, variant, action, dialogMotion, as(이름표 덮어쓰기), when(가드). 줄을 점프 대상으로 만들려면 바로 앞에 ::label{name="…"}을 둡니다. 따옴표로 감싼 값은 &quot; &apos; &amp; &lt; &gt; &#NN; &#xHH;를 해석합니다(0.24.0). 그 밖의 &는 그대로이며 \"도 여전히 됩니다.
전달 플래그{mono} 속마음(역할 mono), {os} 화면 밖(os), {vo} 보이스오버(vo). 한 줄에 하나까지이며 @narrator에는 쓸 수 없습니다. 플래그는 속성과 함께 쓸 수 있습니다: {mono when="…"}.
{mono} 자격화자가 유효 시점 인물(문서의 pov:, 없으면 프로젝트 defaults.pov)이거나 유효 monoSpeakers: 목록(프론트매터, 없으면 defaults.monoSpeakers)에 있어야 합니다. 아니면 E-MONO-POV, 시점 인물이 아예 정해지지 않았으면 E-MONO-NO-POV입니다. 컴포넌트 줄은 ::use마다 호출하는 쪽의 시점과 목록으로 검사하고, 오류는 그 ::use에 붙습니다.
{{…}}{{userName}}, 선언된 상태 경로, 또는 {{@def}}(산출물에 def 본문이 실리고 lute run / lute play가 그 값을 계산합니다). 값이 없을 수 있는 경로를 읽으면 E-MAYBE-UNSET입니다. 줄의 텍스트 전체가 @name이면 그 글자가 그대로 출하됩니다(W-TEXT-LOOKS-LIKE-REF). {{@name}}으로 쓰세요. 0.24.0: {{run.visits:ordinal}}은 1st, 2nd, …로 렌더링되며(숫자 전용), labels:가 있는 enum 타입 경로는 라벨로 렌더링됩니다. 0.25.0: {{run.day:ordinalWord}}는 first … twentieth로 렌더링됩니다(엔진이 현지화하며, lute play는 스물을 넘으면 21st 같은 숫자로 돌아갑니다). 0.27.0: {{run.lamps:plural(lamp|lamps)}}는 수가 1이면 첫 형태, 아니면 둘째 형태로 렌더링되고(형태 안의 #은 그 수: plural(# lamp|# lamps)), labels:가 있는 entity kind의 값({{occasion.target}}, { domain: <kind> } 경로)은 라벨로 렌더링됩니다. cast name:이 우선합니다. 0.28.0: :cardinalWord는 one … twenty로 씁니다. plural 형태 안의 #word / #Word는 그 수를 단어로 씁니다(plural(One wagon|#Word wagons) → Eleven wagons). plural 형태는 |로 나눈 따옴표 없는 텍스트입니다(따옴표나 , 형태는 E-PLURAL-FORM). :capitalize, :start, :indefinite는 텍스트를 다듬고, kind 라벨은 이를 위해 { text, start, indefinite } 형태를 선언할 수 있습니다(없으면 첫 글자 대문자, a/an + 텍스트). 그 밖의 힌트는 E-CEL-PROFILE입니다.
인라인 수식어줄 텍스트 안에서만 씁니다. :pause{s=0.5}는 멈춤(초, 0 이상), :speed[text]{rate=1.25}는 속도(양수)이며, 그 밖의 :name[text]는 textStyle 도메인 멤버여야 하고 속성을 받지 않습니다. 스팬은 겹칠 수 있고(속도는 가장 안쪽 값, 스타일은 바깥부터), pause{0.5}처럼 이름 없는 값은 오류입니다. \: \[ \] \{ \} \\는 글자 그대로입니다. 잘못된 형태나 닫히지 않은 스팬은 E-TEXT-MODIFIER, 모르는 이스케이프는 E-TEXT-ESCAPE입니다. {{…}} 보간은 스팬 안에서도 그대로 동작합니다.
섹션모든 콘텐츠는 ## 제목 섹션 아래에 둡니다. ## 제목 {#stable-id}의 꼬리표는 안정적인 섹션 id이며(영문자로 시작하는 ASCII 토큰, 문서 안에서 유일, 중복은 E-SECTION-DUP), 점프 대상은 아닙니다. 본문의 # 제목은 E-INERT-TITLE입니다.
디렉티브아래 표. 모든 연출 디렉티브는 타이밍 키 duration, delay(초), wait="true"(대기)를 받습니다. 캐스트가 선언되어 있으면 ::actor{character}와 ::camera{focus}도 그 안에 있어야 합니다(E-CAST-UNKNOWN). 필수 속성이 빠지면 E-MISSING-ATTR입니다.
주석// …는 줄 끝까지이며, 그 줄에 홀로 있거나 디렉티브 뒤에 올 수 있습니다(::set{run.n += 1} // why). 그리고 /* … */. <tag> 뒤에 쓰면 E-TAG-INLINE-BODY이고, 대사 텍스트 안의 //는 글자 그대로입니다.
줄의 wait0.49.0. @mira{wait="false"}: …는 허용되며(bool) 줄의 timing.wait로 컴파일됩니다. 줄의 delay와 duration은 계속 E-UNKNOWN-ATTR입니다. 줄은 항상 자기 진행을 기다리고, wait는 그 줄 뒤 다음 줄 전까지의 연출 큐가 줄을 기다리는지를 정합니다. true나 생략이면 기다리고 false이면 줄이 표시되는 동안 시작됩니다. 다음 줄은 여전히 기다리며, 대화창을 비우는 presentation 큐도 마찬가지입니다.
::jump, ::label, ::clear0.49.0. 스탬프를 싣지 않습니다. 이 디렉티브에 플러그인 스탬프 속성이나 코어 타이밍 키를 쓰면 E-UNKNOWN-ATTR입니다(`::jump` does not carry `cue`: it compiles to a `jump` record that has no stamp). 속성은 점프 앞, 라벨 뒤의 줄이나 디렉티브, 또는 직접 쓰는 ::actor{exit}에 두세요 — ::clear는 주입하는 퇴장에 스탬프를 넘기지 않습니다.
디렉티브속성산출물 kind
::bglocation, time, assetId. 장면 전환이며 무대의 모두를 내립니다.bg
::musicplayback(musicPlayback), mood, volume, assetIdmusic
::sfxsound, assetIdsfx
::vfxtype(vfxType), label, transitionvfx
::actorcharacter(필수), anchor, action, emotion, costume. 등장, 포즈, 퇴장을 맡습니다: action이 exits: 멤버면 퇴장이고, anchor를 생략하면 도메인의 default입니다. emotion은 줄의 emotion처럼 무대의 감정을 바꿉니다.actor
::clear없음. 0.24.0: 무대의 모두가 퇴장하고 배경과 음악은 유지됩니다.캐릭터마다 퇴장하는 actor
::camerafocus(캐스트 id), framing, move(cameraMove), transition 중 하나 이상. 하나도 없으면 E-CAMERA-EMPTY입니다. 값은 모두 프로젝트 도메인 멤버이며, 카메라 상태는 저장되지 않습니다.camera
::cgassetId(필수), display="show|hide"(기본값 show), layout(cgLayout)cg
::videoassetId(필수), display="show|hide"(기본값 show). wait의 기본값은 true입니다.video
::sequencename(필수, sequence 도메인). 인라인 연출이 아니라 프로젝트가 가진 시퀀스를 가리키는 참조이며, wait의 기본값은 true입니다.sequence
::endreasonend

→ 대사와 캐스트 · 코어 디렉티브

---
kind: scene
id: cafe.counter
state:
scene.warmth: { type: int, default: 0 }
run.metMira: { type: bool, default: false }
run.tip: { type: int, default: 0 }
defs:
warm: "scene.warmth >= 2"
---
## Counter
<branch id="greet" prompt="Mira looks up.">
<choice id="wave" text="Wave" into="run.metMira">
@mira: Hi!
::set{scene.warmth += 2}
</choice>
<choice id="tip" text="Leave a tip" into="run.tip" value="5">
@mira: Thanks!
</choice>
<choice id="flirt" text="Flirt" when="@warm">
@mira: Oh, stop.
</choice>
</branch>
<hub id="chat" prompt="Anything else?">
<choice id="coffee" text="Ask about the coffee" once>
@mira: House blend.
</choice>
<choice id="cup" text="Ask about the missing cup">
::accept{quest="lostCup"}
@mira: Find it and your next one is free.
</choice>
<choice id="leave" text="Leave" exit>
@mira: Bye.
</choice>
</hub>
::jump{to="outro" when="run.tip > 0"}
@mira: No tip, huh.
::label{name="outro"}
@narrator: The door closes behind you.
::end{reason="leftCafe"}
구문규칙
<branch id>메뉴입니다. 고른 값은 scene.choices.<id>에 기록되고(산출물의 selectionKey), 씬이 끝나면 지워집니다. 가드 없는 선택지가 하나 이상 있어야 합니다(E-BRANCH-ALL-GUARDED). prompt=와 timeout="N"은 선택입니다.
<choice id text>text=는 보여 줄 선택지 텍스트입니다. when= 가드. into="run.x"는 true를 쓰고, number나 enum 경로에는 value=를 함께 써서 이후 씬이 읽을 수 있게 합니다.
<hub id>exit를 고를 때까지 자격 있는 선택지를 다시 보여 줍니다. once는 한 번 고른 선택지를 없앱니다. 가드 없는 exit가 있거나 모든 선택지가 once여야 합니다(E-HUB-NO-EXIT). 고를 때마다 scene.visited.<hub>.<choice>가 설정됩니다. 선택 속성 prompt=(0.23.0)는 선택지와 함께 보여 줄 질문이며, 비어 있으면 E-BRANCH-PROMPT입니다.
::label{name}점프 대상 이름입니다. 문서 전체가 이름공간 하나를 쓰고, 레코드를 내지 않습니다. 줄을 대상으로 삼으려면 그 줄 바로 앞에 둡니다. 섹션의 {#id}는 점프 대상이 아닙니다.
::jump{to when max}같은 문서의 ::label로 가는 점프입니다. 기본은 앞쪽으로 갑니다. 0.51부터 max="N"(1~99의 정수 리터럴)이 있으면 앞선 label로 최대 N번까지 되돌아갈 수 있고, 그다음에는 다음 노드로 넘어갑니다(한도 있는 점프). 0.53부터 그 label은 앞선 최상위 ## 섹션에 있어도 됩니다. max 없이 뒤로 가는 점프는 E-JUMP-BACKWARD, 앞으로 가는 점프의 max는 E-JUMP-MAX입니다. when도 max도 없으면 그 뒤의 콘텐츠는 죽은 코드입니다(W-CODE-AFTER-JUMP). 0.26.0부터 lute trace와 lute test도 lute play처럼 실행된 점프를 따라 label로 갑니다(트랜스크립트에 <jump -> outro>).
::end{reason}씬을 끝냅니다. lute play에서는 자신이 실행된 제시(또는 퀘스트 핸들러)만 끝내고, 플레이는 계속됩니다. 같은 본문에서 그 뒤의 콘텐츠는 W-CODE-AFTER-END입니다.
::accept{quest}수락형 퀘스트(start가 없는 퀘스트, 또는 activate="accept"인 자식)를 받아들입니다. at="nextRun"(0.24.0)은 다음 newRun 초기화 직후로 수락을 미루며, 그 밖의 at은 E-ACCEPT-TARGET입니다. 퀘스트를 보세요. when=을 받습니다(0.26.0).

→ 선택지와 허브 · branch, match, when

---
kind: scene
id: cafe.moods
state:
run.tips: { type: int, default: 0 }
run.mood: { type: { enum: [calm, tense, joyful] }, default: calm }
run.rival: { type: { enum: [kai, lee] } }
---
## Moods
<match subject="run.mood">
<when is="calm">
@mira: Quiet day.
</when>
<when is="tense|joyful">
@mira: Busy day.
</when>
</match>
<match subject="run.tips">
<when is="10..">
@mira: My best customer.
</when>
<when is="1..9">
@mira: Thanks, as always.
</when>
<otherwise>
@mira: Hmm.
</otherwise>
</match>
<match subject="run.rival">
<when is="kai">
@mira: Kai was here earlier.
</when>
<when test="$ == 'lee' && run.tips > 3">
@mira: Lee asked about you.
</when>
<when is="unset">
@mira: Nobody asked about you.
</when>
<otherwise>
@mira: Lee was here.
</otherwise>
</match>
@mira{when="run.mood != 'calm'"}: Take a breath.
  • is=에는 enum 멤버, true/false, 숫자, 양끝을 포함하는 범위(3.., ..0, 1..9), unset, | 선택이 들어갑니다. test=는 주제 값이 $에 묶인 CEL 가드입니다. 둘 다 쓰면 패턴 AND 가드입니다.
  • 갈래는 위에서 아래로 평가되며 처음 맞는 것이 이깁니다. match는 모든 경우를 다뤄야 합니다: 모든 멤버를 다룬 enum이나 bool에는 <otherwise>가 필요 없습니다. 숫자는 실수이므로 1..9와 10.. 사이에는 9.5라는 틈이 남습니다. 값이 없을 수 있는 주제에는 is="unset"이나 <otherwise>가 필요합니다. 갈래는 주제를 좁혀 줍니다: <when is="x"> 안에서는 주제에 값이 있고, test 없는 is="unset" 갈래 뒤의 갈래와 <otherwise>는 값이 있는 것으로 읽습니다(E-MAYBE-UNSET 없음).
  • 비트의 when:은 본문 전체에서 주제를 좁힙니다(0.24.0): when: "run.verdict != 'undecided'" 아래의 <match subject="run.verdict">에는 undecided 갈래가 필요 없고, 써 두면 E-ARM-DEAD입니다. 본문이 주제에 쓰기를 하면 전체 도메인이 유지됩니다.
  • test="$ == 'x'"는 W-WHEN-TEST-LITERAL이며, lute fix가 is="x"로 바꿔 줍니다.
  • @who{when="G"}: …는 갈래가 하나인 match의 축약입니다. 팩트 질의(holds(…))에는 이 줄 형태만 쓸 수 있습니다. <match subject="holds(…)">는 E-MATCH-RELATION-SUBJECT이기 때문입니다.
  • subject가 없는 <match>에는 주제가 없으므로 모든 갈래가 test=입니다. 거기서 is= 갈래는 E-MATCH-NO-SUBJECT입니다. subject="…"를 쓰거나, 조건을 test=로 쓰세요(<when is="run.lamps >= 3">는 <when test="run.lamps >= 3">입니다).
  • 모든 태그는 한 물리적 줄에 혼자 놓입니다. 한 줄짜리 <when …>text</when>는 E-TAG-INLINE-BODY이고, 여러 줄로 나눈 태그는 E-TAG-NOT-ONE-LINE입니다.

→ branch, match, when

---
kind: scene
id: ship.archive
state:
run.trust: { type: int, default: 0 }
run.seen: { type: bool }
entities:
crew: { members: [vesna, toma] }
topic: { members: [manifest, heading] }
relations:
knows: { args: [crew, topic], tier: run }
---
## Archive
::set{run.seen = true} /* the first write of a no-default path must be `=` */
::set{run.trust += 1} /* also -= and *= */
@vesna{when="has(prev.run.trust) && prev.run.trust >= 3"}: You trusted me last time.
@vesna{when="run.seen"}: You found the archive.
::assert{knows(vesna, manifest)}
@vesna{when="holds('knows', ['vesna', 'manifest'])"}: I read the manifest.
@vesna{when="count('knows', ['_', 'manifest']) >= 2"}: So we both know.
::retract{knows(vesna, _)}

_는 질의와 ::retract에서 와일드카드입니다. app.*는 읽기 전용이며(E-APP-READONLY), owner: engine으로 선언한 경로도 마찬가지입니다(E-ENGINE-OWNED-WRITE). 그 경로는 엔진이 쓰고, lute play에서는 engine: 스텝으로 씁니다. derive: true나 reserved: 관계는 콘텐츠에서 assert할 수 없습니다. 관계의 key: [0]은 첫 번째 인자를 함수적으로 만들어, 새로 assert하면 이전 팩트를 대체합니다.

prev.run.<path>(0.23.0)는 선언된 모든 run.* 경로에 대해, 이전 런이 끝났을 때 run.<path>가 가졌던 값을 같은 타입으로 읽습니다. 읽기 전용이고(E-QUEST-RESERVED-WRITE) 첫 런이 끝나기 전에는 값이 없으므로, 읽을 때마다 has(prev.run.x)나 unset 갈래가 필요합니다(E-MAYBE-UNSET). prev.* 경로를 직접 선언하면 E-STATE-NAMESPACE입니다. lute play는 newRun에서 스냅숏을 뜨고, 플레이 스크립트나 목의 state:로 시드할 수 있습니다.

파티(0.24.0): 하위 종류, 엔티티별 상태, 더 풍부해진 규칙.

---
kind: scene
id: camp.fire
state:
run.day: { type: int, default: 1 }
run.approval: { type: int, default: 0, per: companion } # run.approval.isolde, run.approval.corvin
entities:
person: { members: [isolde, corvin, hollis] }
companion: { subsetOf: person, members: [isolde, corvin] } # every member is a person
place: { members: [camp, ridge] }
relations:
inParty: { args: [companion], tier: run }
sawAt: { args: [person, place], tier: run }
loyal: { args: [companion], derive: true }
witness: { args: [person], derive: true }
unseen: { args: [companion], derive: true }
defs:
evenDay: "run.day % 2 == 0"
rules:
- "loyal(P) :- inParty(P), cel(\"run.approval[P] >= 3\")" # [P]: rule guards only
- "witness(W) :- sawAt(W, _), cel(\"@evenDay\")" # `_` in a body; a @def in a guard
- "unseen(P) :- companion(P), not sawAt(P, _)" # not: no such fact holds
---
## Camp
::assert{inParty(isolde)}
::set{run.approval.isolde += 2}
::set{run.approval.corvin += 1 when="run.day > 3"}
@isolde{when="holds('loyal', ['isolde'])"}: I'm with you.
@hollis{when="countDistinct('sawAt', ['_', '_'], 0) >= 2"}: Two of us saw it.
@hollis{when="holds('witness', ['hollis'])"}: I was there.
@hollis{when="holds('unseen', ['corvin'])"}: Nobody saw Corvin.
  • per: companion은 멤버마다 run.approval.<member>를 선언합니다. 콘텐츠는 멤버 이름으로 씁니다. 이 묶음을 이름 대신 색인하는 곳은 두 군데입니다: 규칙의 cel() 가드는 양의 원자가 묶은 변수로 색인하고(run.approval[P], 그런 규칙은 멤버마다 인스턴스 하나로 컴파일됩니다), 컴포넌트의 ::set은 파라미터로 색인합니다(run.approval[@who], ::use마다 검사됩니다. 컴포넌트 참고).
  • 규칙 본문의 _는 새 익명 변수입니다(not 아래에서는 “그런 튜플이 아예 없음”). 규칙 머리 (E-DATALOG-PARSE)와 비교식에서는 여전히 오류입니다.
  • 규칙 본문의 not r(…)은 그런 팩트가 없을 때 성립합니다(unseen(P): sawAt(P, _)가 아예 없음). 부정은 아무것도 묶지 않으므로, 그 변수는 모두 같은 본문의 양의 원자가 묶어야 합니다. 아니면 E-DATALOG-UNSAFE입니다.
  • countDistinct('sawAt', ['_', '_'], 0)는 한 위치의 서로 다른 값 개수를 세고, count(…)는 튜플 개수를 셉니다. count처럼 규칙 가드에는 쓸 수 없습니다.
  • 규칙 가드는 @def를 부를 수 있습니다. 없는 def, 잘못된 인자 개수, $는 E-RULE-GUARD-DEF입니다. 규칙 본문의 종류 원자(companion(P))는 멤버십 검사입니다.
  • ::set{… when="…"}은 조건이 성립할 때만 씁니다(lute play는 skip set … — when: false로 출력). 0.26.0부터 ::use, ::accept, ::assert, ::retract, 플러그인 패스스루와 브리지 디렉티브도 같은 when=을 받습니다(::assert{inParty(corvin) when="run.day > 3"}. 가드 달린 ::use는 펼친 내용 전체를 실행하거나 전혀 실행하지 않으며, play는 skip ::give{item="potion"} — when: false로 출력). 검사기는 가드 달린 디렉티브를 확정된 쓰기, 팩트, 수락으로 세지 않습니다. 내장 레코드로 로워링되는 디렉티브(::bg, ::sfx, ::actor, ::clear 같은 모든 코어 연출 디렉티브와 ::end, ::label, 플러그인 lower: 레코드)는 when=을 거부하며(E-UNKNOWN-ATTR, <match>에 넣으세요), <track> 클립도 마찬가지입니다(E-TIMELINE-CONTENT). CEL 호출처럼 이름 붙인 관계(has, holds, count, now, …)는 E-RESERVED-NAME입니다(예약된 이름 전체: 예약된 이름).

배타 관계(0.25.0): excludes:는 같은 인자에서 결코 함께 성립하지 않는 관계를 적습니다.

---
kind: scene
id: tower.gallery
entities:
person: { members: [elias, maren] }
relations:
seen: { args: [person], tier: run }
seenAfter: { args: [person], derive: true, excludes: [fell] } # symmetric: fell excludes seenAfter
fell: { args: [person], tier: run }
rules:
- "seenAfter(P) :- seen(P)"
---
## Gallery
::assert{seen(elias)}
@maren{when="holds('seenAfter', ['elias'])"}: He was on the stairs after the storm.
  • 짝은 선언된 관계여야 하고, 인자 종류가 같아야 하며, 자기 자신일 수 없습니다(E-RELATION-DECL). IR의 RelationEntry.excludes에는 대칭 폐포가 실립니다.
  • 프로젝트 전체 check: holds('seenAfter', ['x']) && holds('fell', ['x'])는 죽은 가드이고(E-ARM-DEAD / E-BEAT-UNREACHABLE), holds('seenAfter', ['x']) 아래의 !holds('fell', ['x'])는 W-FACT-GUARANTEED이며, 여기서 ::assert{fell(elias)}는 E-FACT-EXCLUSIVE(다른 쪽이 모든 경로에서 성립), 짝을 깨뜨릴 수밖에 없는 fell(P) :- seenAfter(P) 같은 규칙은 E-RULE-EXCLUSIVE입니다.
  • 둘 다 가능하기만 한 곳에서는 lute play가 쓰기 지점에서 ✗ exclusive: fell(elias) and seenAfter(elias) both hold로 멈추고(종료 코드 1), lute trace / lute test는 그 지점에서 거부합니다(E-FACT-EXCLUSIVE).
  • reserved: 관계는 엔진이 그 관계를 바꾸는 계기를 changedOn: [<occasion>…]으로 선언할 수 있습니다. 이것이 캐스트의 assume: true를 좁힙니다(위의 스키마 참고). reserved가 아닌 관계의 changedOn이나 선언되지 않은 계기를 가리키는 changedOn은 E-RELATION-DECL입니다.

→ 상태 모델 · 팩트와 Datalog

네임스페이스초기화 시점콘텐츠가 쓸 수 있는가
scene.*씬이 끝날 때예
run.*새 런에서예
user.*프로필 초기화 시예
app.*앱 삭제 시아니요
quest.<id>.state(항상 값이 있음: 퀘스트가 활성화되기 전에는 unset, 그 뒤로 active complete failed), quest.<id>.activatedAt, quest.<id>.objectives.<o>.done엔진. tier="run" 퀘스트는 새 런에서 unset으로 돌아감아니요
quest.<id>.failedBy(unset fail by until subquest cascade superseded), quest.<id>.objectives.<o>.failed(0.24.0)엔진. run 등급 퀘스트와 함께 지워짐아니요(E-QUEST-RESERVED-WRITE)
clock.index, clock.weekday, clock.weekdayLabel(0.24.0, 시계를 선언했을 때. weekday*는 week:가 있을 때)엔진: 시계의 날과 슬롯에서 계산아니요(E-QUEST-RESERVED-WRITE)
entry.<id>.read엔진, run 등급아니요
entry.<id>.everRead엔진, user 등급: 처음 읽을 때 설정되고 새 런에서도 초기화되지 않음아니요
owner: engine으로 선언한 경로그 네임스페이스를 따름아니요(E-ENGINE-OWNED-WRITE)
prev.run.<path>(0.23.0)엔진: 런이 끝날 때의 run.<path> 스냅숏. 첫 런이 끝나기 전에는 값이 없음아니요(E-QUEST-RESERVED-WRITE)
season.<name>.<field> / prev.season.<name>.<field>(0.27.0)시즌이 열릴 때: 기본값으로 초기화 / 끝난 기간의 스냅숏예 / 아니요(E-QUEST-RESERVED-WRITE)
scene.choices.<branch>, scene.visited.<hub>.<choice>엔진아니요
visited('<scene id>'), visited('<doc>.<beat>')초기화되지 않음: 세이브 전체아니요
연산자함수와 참조
== != < <= > >= · && || ! · + - * / · %(0.24.0: 정수 전용) · c ? a : b · x in ['a', 'b'] · 정수·소수 리터럴has(p) / 'k' in a.b(값이 있는가) · holds('rel', ['a', '_']) · count('rel', ['_']) >= n · countDistinct('rel', ['_', '_'], 0)(0.24.0) · validAt('rel', ['a'], quest.q.activatedAt) · visited('scene.id') · @def / @def(args) · $(<match> 안에서만)
쓸 수 없는 것: size, matches, map/filter/exists/all(E-CEL-PROFILE). 가드에서도 def
본문에서도 마찬가지입니다. %는 int 피연산자 두 개를 받으며, int가 아닌 피연산자나 소수 리터럴은 E-CEL-TYPE입니다.
값이 없음은 문자열 'unset'이 아닙니다(E-UNSET-LITERAL). !has(p)나
is="unset"으로 확인하세요. 예외는 quest.<id>.state로, 여기서는 unset이 실제 멤버입니다.
quest.q.state == 'unset'으로 쓰세요. has(quest.q.state)는 항상 참입니다(W-QUEST-STATE-HAS).
0.26.0부터 가드가 enum 경로, occasion.target, 퀘스트의 state / failedBy와 비교하는 문자열(==, !=,
in [...]의 원소)은 그 멤버여야 합니다. run.rank == 'silvr'는 is="silvr"처럼
E-WHEN-LITERAL-DOMAIN이며, 비슷한 이름을 제안합니다.

0.49.0 per: 멤버의 존재 확인: 'look-around' in user.stepRank는 user.stepRank.look-around의 존재 가드이며 식별자 경로의 has()와 같습니다(부정은 !('look-around' in user.stepRank)). 식별자가 아닌 멤버에 쓰세요. has(user.stepRank["look-around"])는 계속 E-CEL-PARSE이며 '<m>' in <family>로 쓰라는 힌트가 붙습니다. 존재 테스트를 언급하는 모든 메시지는 식별자 경로에는 has(p) / !has(p), 그런 멤버에는 '<m>' in <family> / !('<m>' in <family>)로 표기하며, 식별자가 아닌 다른 세그먼트가 있는 경로는 after: / default: 해결책만 알려 줍니다.

0.30.0: 작가가 쓰는 모든 이름은 이름입니다. 글자, 숫자, _, -로 이루어지고 -로 시작하지 않으며, 점으로 이은 id는 이름을 .으로 잇습니다. 씬·비트·엔트리·퀘스트·objective·branch·허브·choice id, ::label 이름, 문서 id의 각 부분, share 키, 시즌, relation, enum과 엔티티 종류와 그 멤버, 플러그인이 선언하는 계기와 이벤트, 대상과 카테고리가 모두 그렇습니다. lamp-out, zero-coke-001, 001은 모두 이름입니다. 이름에 ., 공백, 따옴표가 있으면 그 자리의 코드로 오류가 나고, 쓸 수 있는 문자를 알려 줍니다. JavaScript 변수처럼 그대로 읽히는 이름 — def, def의 파라미터, 컴포넌트와 템플릿 파라미터(@lampLit) — 는 식별자(글자나 _로 시작하고 글자, 숫자, _로 이어지는 이름)이며, 여기에 쓴 lamp-lit은 lampLit을 알려 주는 오류입니다.

식별자인 이름은 조건식에 그대로 쓸 수 있고, 어떤 이름이든 JavaScript가 키에 접근하듯 따옴표로 쓸 수 있습니다: quest["zero-coke-001"].state, run.visits["lab-b2"], holds('at', ["lab-b2"]). 두 표기는 같은 이름입니다. quest.zero-coke-001.state는 뺄셈으로 읽히므로 E-PATH-IDENT이며, 대괄호 표기를 알려 줍니다.

CEL이 들어가는 곳: <match subject>, <when test>, 줄이나 선택지의 when=, ::set의 우변과 when=, 그것을 받는 다른 디렉티브의 when=(0.26.0), ::jump when, 비트 when:, 엔트리 when=, 퀘스트 start / fail, 목표 done / by / until / visibleWhen, <on when>, <reward when>, 캐스트 항목의 present:. 디렉티브 속성에서 def 참조는 따옴표 없이 씁니다: duration="@holdTime"이 아니라 ::camera{framing="wide" duration=@holdTime}입니다. 속성에는 def가 접히는 상수가 들어가므로, 위의 pace처럼 상태를 읽는 def는 E-ATTR-DEF-DYNAMIC입니다. <match>로 나누고 갈래마다 리터럴을 쓰세요.

→ CEL 표현식 · 정의와 파라미터

비트: 계기에 응답하는 씬과 엔트리

섹션 제목: “비트: 계기에 응답하는 씬과 엔트리”

비트는 계기(occasion)가 발생했을 때 엔진이 고를 수 있는 씬, 로어 엔트리, 번들 비트입니다.

---
kind: scene
id: vesna.gift
on: talk
target: npc.vesna
when: 'user.runs >= 10 && !run.giftRefused'
priority: 50
once: user
after: 'visited("cafe.counter")'
state:
user.runs: { type: int, default: 0 }
run.giftRefused: { type: bool, default: false }
---
## Gift
@vesna: You have been at this a while. Take this.
---
kind: lore
id: vesna.barks
state:
user.runs: { type: int, default: 0 }
---
<entry id="vesnaBark" on="talk" target="npc.vesna" category="bark">
@vesna: Keep your head down.
</entry>
<entry id="vesnaBack" on="talk" target="npc.vesna" category="bark" priority="10" when="user.runs >= 3">
@vesna: Back again?
</entry>
<entry id="vesnaFirst" on="talk" target="npc.vesna" category="bark" priority="20" once="user">
@vesna: So you are the new one.
</entry>

로어 문서에는 엔트리 옆에 <beat> 블록, 곧 번들 비트도 둘 수 있습니다(0.23.0):

---
kind: lore
id: cafe.talks
state:
run.tips: { type: int, default: 0 }
---
<beat id="miraOrder" on="talk" target="npc.mira" title="Order" priority="10" when="run.tips >= 3">
@mira: The usual?
<hub id="order" prompt="What will it be?">
<choice id="usual" text="The usual" exit>
@mira: Coming up.
</choice>
</hub>
</beat>
<beat id="miraHum" on="talk" target="npc.mira" once="false" also>
@narrator: Mira hums while she works.
</beat>
<beat id="thanksCounter" on="talk" target="npc.mira" after="visited('cafe.talks.miraOrder')" once="user" share="miraThanks">
@mira: Thanks for the tips, by the way.
</beat>
<beat id="thanksDoor" on="leave" once="user" share="miraThanks">
@mira: Thanks for the tips! See you.
</beat>

번들 비트는 id, on, target, title, when, priority, once, also, 그리고 0.25.0부터 after와 share를 받고, 본문은 씬 본문(대사, branch, 허브, match, 디렉티브)입니다. 문서에는 id:가 있어야 하고, 비트 id는 이름이며, 비트의 정식 id는 <문서 id>.<비트 id>(cafe.talks.miraOrder)입니다. lute play, presented:, visited('cafe.talks.miraOrder'), lute trace --beat가 이 id를 씁니다. 씬 비트처럼 동작합니다: once의 기본값은 run이고, 제시되면 소진됩니다. 0.24.0부터는 씬이나 퀘스트가 번들 비트를 선행 조건으로 쓸 수 있고(after: visited('cafe.talks.miraOrder')), 0.25.0부터는 비트 자신의 after="…"가 씬의 after:와 같습니다: 자격 조건이자 시나리오 간선입니다. 비트 when의 visited()는 막기만 하고 간선을 그리지 않으므로, lute scenario는 그런 비트를 써야 할 after=와 함께 unanchored로 나열합니다. title은 select: all 메뉴에서 비트의 이름표가 됩니다. 정식 id가 씬 id와 같으면 E-CONN-EPISODE-ID-DUP입니다.

키의미
on응답하는 계기. 이 키가 씬을 비트로 만듭니다. on 없이 다른 비트 키를 쓰면 E-BEAT-ATTR입니다.
target선택, 점으로 구분한 id(npc.vesna). 계기가 그 대상에 대해 발생했을 때만 후보가 됩니다. 계기가 대상 도메인을 선언했다면 대상은 그 도메인의 <prefix>.<member>여야 합니다. target:이 없는 계기에서 엔트리의 target=은 메타데이터입니다(0.24.0): 엔트리는 모든 발생에 응답합니다. 씬이나 번들 비트의 target은 여전히 E-BEAT-ATTR입니다. 0.26.0: target="kind:<kind>"는 계기 도메인 안의 닫힌 종류의 모든 멤버에 응답합니다(아래).
whenrun / user / app, quest.*, entry.*.read / entry.*.everRead, 팩트, visited()에 대한 CEL. scene.*는 읽을 수 없습니다. 문자열을 비교할 때는 YAML 값을 큰따옴표로 감싸 CEL이 작은따옴표를 쓸 수 있게 하세요: when: "run.slot == 'night' && user.runs >= 3". 두 층 모두 작은따옴표를 쓰면 E-META-PARSE입니다.
priority정수, 기본값 0. 높은 쪽이 이깁니다.
once씬: run(기본값), user(평생 한 번), false(반복 가능). 엔트리: once="run"(새 런이 entry.<id>.read를 초기화할 때까지) 또는 once="user"(entry.<id>.everRead가 설정되면 소진). 엔트리에 once가 없으면 반복됩니다. 시계를 선언했다면(0.24.0) once: day / once: slot(엔트리는 once="day" / "slot")은 날이나 슬롯이 바뀔 때까지 소진 상태로 둡니다. 시계 없이 쓰면 E-BEAT-ATTR입니다. 엔트리의 쓰기는 한 런에서 처음 읽을 때만 적용되므로, once 없는 엔트리의 본문에 ::set / ::retract가 있으면 W-ENTRY-WRITE-REREAD입니다(0.26.0, ::assert는 제외). 반복되어야 하는 쓰기는 <beat once="false">에 두세요.
advances0.31.0. 씬 프론트매터, <entry>, <beat>에 slot, day, 또는 1 이상의 정수를 선언합니다. 비트가 제시되면 엔진이 그만큼 시계를 이동하고 퀘스트를 정산한 뒤 raise: 계기를 발생시킵니다. 프로젝트에 clock:이 없으면 오류입니다.
also0.23.0. select: first 계기의 씬(also: true)과 번들 비트(also): 승자 뒤에, 또는 주 비트가 하나도 자격이 없을 때는 혼자 제시되며, 승자를 대신하지 않습니다. 엔트리에 쓰거나 select: all / sequence 계기에 쓰면 E-BEAT-ATTR입니다. W-BEAT-SHADOWED와 W-BEAT-PRIORITY-TIE는 also 비트를 무시합니다.
share0.25.0. 씬(share:), 엔트리와 번들 비트(share=): 여러 곳에서 이야기되는 한 사건을 위한 프로젝트 전체의 키입니다. 키의 어느 비트든 제시되면(엔트리는 읽히면) 그 키의 모든 비트가 once 기간 동안 소진됩니다(lute play: once: user — `share: miraThanks` already spent … by cafe.talks.thanksCounter). false가 아닌 once를 함께 써야 하고, 한 키의 모든 비트는 같은 once를 선언해야 합니다. 그렇지 않으면 E-BEAT-ATTR입니다. lute beats는 user, share miraThanks로 보여 줍니다.
spentBy0.27.0. 씬(spentBy:), 엔트리와 번들 비트(spentBy=): 제시 대신 비트를 소진하는 조건입니다(spentBy: "holds('solved', ['valves'])"). 0.28.0부터 래치입니다. 한 번 성립하면 조건이 다시 거짓이 되어도 비트는 once 기간(쓰지 않으면 run, 주마다 초기화하려면 once: week) 동안 소진된 채로 남고, lute play는 spentBy: `run.solved` held — spent this run으로 알립니다. once: false나 share와 함께 쓰면 E-BEAT-ATTR, 시작부터 성립하면 W-BEAT-SPENT-AT-START입니다. once를 쓰지 않았는데 조건이 다시 거짓이 될 수 있으면(철회되는 팩트, 시즌 상태, rearm되는 퀘스트) W-SPENT-BY-REVERSIBLE이며, once: false + when: "!(…)", once: season:<name>, 또는 래치를 유지하는 once: run을 알려 줍니다.
use0.27.0. 번들 비트(use="bondStory"): 비트 템플릿입니다. 컴포넌트의 beat: 머리가 <beat>가 직접 쓰지 않은 키를 모두 채웁니다(@param은 인자로 바뀌고, 빈 값은 빠지며, id 하나뿐인 after는 visited("<id>")입니다). 템플릿 본문이 먼저 실행되고(최상위 ::body가 있으면 비트 자신의 본문이 그 자리에 들어갑니다), 나머지 속성은 파라미터입니다(E-COMPONENT-ARG). <beat use="trainer" id="r3Joey" who="joey"/>는 한 줄 비트입니다. 잘못 쓰면 E-TEMPLATE입니다.
for0.27.0. 대상 없는 select: sequence 계기의 씬(for: "kind:crew"), 엔트리와 번들 비트(for="kind:crew"): when이 성립하는 멤버마다 한 번씩, 멤버 순서대로 제시되며 그 멤버를 occasion.target으로 읽습니다(lute play: ✓ cafe.birthday for toma). once(와 spentBy)는 0.28.0부터 멤버마다 따로 소진됩니다. target과 함께 쓰거나, 대상이 있는 계기나 sequence가 아닌 계기에 쓰면 E-BEAT-ATTR입니다.

선택: 후보는 on이 일치하고 target이 없거나 발생한 대상과 같은 비트입니다. 후보는 after:와 when이 성립하고 once가 소진되지 않았을 때 자격이 있습니다. 자격 있는 비트는 priority, 문서 경로, 선언 순서로 정렬됩니다. select: first는 also가 아닌 맨 위의 비트를 제시한 뒤 자격 있는 also 비트를 제시하고, select: sequence(0.23.0)는 자격 있는 비트를 그 순서대로 모두 제시하며(루틴 다음에 그날의 이벤트), select: all은 플레이어가 고르게 합니다. 자격은 계기가 발생할 때 한 번 정해지고, 퀘스트는 제시할 때마다 정산됩니다. 대상이 같거나 둘 다 없고 priority도 같은 select: first 비트 둘의 when이 서로 배타적임을 증명할 수 없으면 승자는 파일 순서로 정해집니다(프로젝트 전체 check의 W-BEAT-PRIORITY-TIE. 0.26.0부터는 부정을 정규화하고 두 when이 왜 배타적이지 않은지 말해 줍니다). 앞선, 결코 소진되지 않는 비트가 그 when을 함의해 늘 이기는 폴백은 lute beats에 covered by <id>로 나옵니다(0.26.0).

종류 대상(0.26.0)은 비트 하나로 한 종류의 모든 멤버에 응답합니다. 발생한 멤버는 종류로 타입이 매겨진 occasion.target으로 when, 가드, 텍스트에서 읽습니다. priority가 같으면 그 멤버를 직접 지목한 비트가 이기고(동점 아님), 종류 대상(또는 for)이 없는 비트에서 occasion.target을 읽으면 E-UNDECLARED입니다. 0.27.0부터는 사실 인자와 per: 인덱스로도 쓸 수 있고, 멤버마다 검사됩니다: holds('regular', [occasion.target]), user.bond[occasion.target] >= 2. lute play는 {{occasion.target}}을 종류의 라벨로(없으면 캐스트 name:으로, 그것도 없으면 id로) 렌더링하고, 테스트는 state: { occasion.target: toma }로 멤버를 정합니다:

<beat id="anyRegular" on="talk" target="kind:crew" once="false">
@narrator: {{occasion.target}} waves from the counter.
</beat>
<entry id="regularNod" on="talk" target="kind:crew" when="occasion.target != 'toma'">
@narrator: A nod from the regulars' table.
</entry>

종류는 닫힌 종류이면서 계기의 { prefix, entity } 도메인 안에 있어야 하고, 계기는 플러그인이 선언해야 합니다. 아니면 E-BEAT-ATTR입니다(비슷한 이름 제안과 함께). 계기와 월드 이벤트, 그리고 선택적으로 보상 종류와 캐스트는 플러그인이 선언합니다:

plugins/game.occasions/plugin.yaml
id: game.occasions
version: 0.1.0
kind: capability
depends: [ { id: lute.core, range: "^0.0.1" } ]
exports:
occasions: occasions/
events: events/
rewardKinds: rewardkinds/ # rewardKinds: see Quests
cast: cast/ # cast: { <id>: { name, present?, emotions? } }, like a schema's cast:
plugins/game.occasions/occasions/game.yaml
occasions:
townVisit: { select: first }
talk: { select: first, target: { prefix: npc, entity: crew } } # or `target: true`: any dotted id
greet: { select: first, target: { prefix: npc, entity: crew, members: [mira, vesna] } } # only these members (0.23.1)
runEnd: { select: first, judge: before } # 0.24.0: judge on="runEnd" objectives before the beats
evening: { select: sequence } # every eligible beat, in selection order (0.23.0)
inbox: { select: all, description: Letters waiting at the fountain }
gift: { select: first, target: { prefix: npc, entity: crew }, payload: { hearts: int } } # 0.27.0: beats read occasion.payload.hearts; a play step gives payload: { hearts: 2 }
# plugins/game.occasions/events/game.yaml: for <on event> and a play's `event:` step
events:
- name: combatEnd
- name: talk # same name as an occasion: <on event="talk" target="npc.mira">

계기를 선언하는 플러그인이 없으면 어떤 이름이든 받아들여집니다. 플러그인이 계기를 선언한 뒤에는 모르는 계기가 E-OCCASION-UNKNOWN이 되고, target을 선언하지 않은 계기에 target을 쓰면 E-BEAT-ATTR입니다. 대상 도메인 { prefix, entity }는 그 entities: 종류의 멤버마다 <prefix>.<member>를 허용하므로(open: 종류라면 어떤 멤버든), target: npc.vesan은 비슷한 이름을 제안하는 E-BEAT-ATTR입니다.

플러그인 매니페스트(0.24.0):

  • 모든 내보내기 파일은 모르는 키를 거부합니다. { selct: all }은 조용히 select: first가 되지 않고, 비슷한 이름을 제안하는 E-PLUGIN-PARSE입니다. 쉼표가 든 flow-map 값은 따옴표로 감싸세요.
  • 디렉티브의 lower:는 선택입니다. 없으면 일반 kind: "plugin" 패스스루로 컴파일됩니다. { kind: builtin, name }은 코어 훅(actorStage, cameraTransform, clearStage, end, jump, label, sequence)을 가리켜야 하며, 그렇지 않으면 E-PLUGIN-PARSE입니다.
  • judge: before는 비트를 고르기 전에 그 계기의 on= 목표를 판정하고 퀘스트를 정산하므로, 그 계기의 에필로그가 퀘스트가 어떻게 끝났는지 읽을 수 있습니다. 옮겨지는 것은 판정뿐이며, 그 발생이 응답하는 <on> 핸들러 본문은 여전히 비트 뒤에 실행됩니다. 기본값은 after입니다.
  • cast 내보내기 항목도 스키마처럼 present:, emotions:, assume:을 받고, (0.26.0) 여러 화자가 함께 쓰는 역할 이름에는 sharedName: true를 받습니다(W-DISPLAY-NAME-DUP 없음).
  • 0.26.0: 디렉티브 속성은 프로젝트 종류로 타입을 정할 수 있습니다({ name: item, type: { entity: bagItem } }). 멤버가 아닌 값은 비슷한 이름 제안과 함께 E-BAD-ENUM이며, 컴포넌트 파라미터를 거쳐 들어온 값도 그렇습니다. when은 모든 패스스루와 브리지 디렉티브에서 예약된 이름이므로(when= 가드) 플러그인 속성 이름으로 쓸 수 없습니다.

→ 비트 · 스토리 플레이 · 플러그인 매니페스트

스키마의 clock:(0.24.0, 위의 world.schema.yaml 참고)은 엔진 소유의 날과 슬롯에 순서를 줍니다. 아래 비트는 시계를 읽으므로 그 스키마가 있는 프로젝트 안에서만 검사를 통과합니다:

---
kind: scene
id: cafe.open
on: slotStart
once: day
when: "clock.weekday < 5"
---
## Open
@mira: {{clock.weekdayLabel}} again. That's slot {{clock.index}} of the story.
  • day(int)는 owner: engine 경로여야 합니다. slot(enum, 역시 owner: engine)과 slots(그 멤버를 순서대로)는 선택이지만 둘을 함께 써야 하며, 둘 다 없으면 날 단위로만 세는 시계입니다. 잘못된 시계나 두 번째 시계, 콘텐츠 소유 경로, slot 없는 slots, 모르는 raise 계기(프로젝트의 플러그인이 계기를 선언할 때부터 검사), payload:가 있는 raise 계기는 E-CLOCK-DECL입니다.
  • 조건과 보간 어디서나 읽을 수 있는 읽기 전용 경로: clock.index((day-1) * len(slots) + slotIndex, 단조 증가, 날 시계에서는 day-1), clock.weekday(정수 0..length-1이므로 is="0..5" + is="6"은 빠짐없고 is="7"은 E-WHEN-LITERAL-DOMAIN), clock.weekdayLabel(week.labels의 enum. 둘 다 week: 필요). 여기에 ::set하면 E-QUEST-RESERVED-WRITE입니다.
  • 씬과 번들 비트는 once: day / once: slot, 엔트리는 once="day" / "slot".
  • once: week(0.27.0, 시계의 week: 필요. 엔트리는 once="week")는 다음 시계 주가 시작될 때, 즉 clock.weekday가 week.first로 돌아올 때까지 소진 상태로 둡니다.
  • lute play는 advance: slot | day | <n> 스텝으로 시계를 옮기고(뒤로는 못 가며, advance: day는 다음 날 첫 슬롯에 도착) 퀘스트를 정산합니다. raise:는 계기 하나이거나 맵입니다: dayEnd는 자정을 넘을 때마다 (advance: <n>은 먼저 그날의 마지막 슬롯까지 감), dayStart는 다음 날 첫 슬롯에서, slot(단일 형태와 같음)은 시계가 멈춘 곳에서 한 번 발생하며, 각각 자기 머리글로 출력됩니다 (── step 1 · day 2 (Tue) morning · dawn). lute calendar --axis clock=1..3은 그 날들의 모든 슬롯을 훑고, --occasion dusk@clock.day는 dusk를 하루에 한 번 평가합니다.
  • 0.26.0: advance: { to: night } / advance: { to: { weekday: Fri, slot: morning } }는 현재 위치 다음의 그런 위치로 시계를 옮깁니다(앞으로만, 0걸음은 없음. 한 번의 전진이라 슬롯 계기는 멈춘 곳에서 한 번 발생하고, 지나는 자정마다 dayEnd / dayStart가 발생). 스텝의 expect: { clock: { weekday, slot, day } }는 시계가 선 곳을 판정하며, include:된 스텝 파일은 이것으로 자신이 기대하는 시간을 밝힙니다.
  • 0.27.0: last: { day: 1, slot: h05 }(또는 days: N)는 시계를 끝냅니다: 그 너머로 가는 전진은 마지막 위치까지만 가서 마지막 dayEnd를 한 번 발생시키고(slot 발생 없음) 멈춥니다. 그 뒤의 advance:는 newRun 전까지 E-CLOCK-END(종료 코드 1)입니다. 더 늦은 위치가 필요한 when(run.night == 2)은 도달할 수 없고, lute calendar --axis clock은 끝에서 멈춥니다.
  • 0.28.0: clock.day와 clock.slot은 날과 슬롯 경로를 이름으로 읽고, last: / days:가 있는 시계에는 clock.ended가 있습니다. 시계를 끝내는 전진의 정산부터 newRun 전까지 참입니다. by="clock.ended"는 그 순간 목표를 실패시키고(마지막 위치를 가리키는 by="clock.index >= N"은 한 슬롯 먼저 실패시킴), terminal: "clock.ended"는 거기서 게임을 끝내며, 플레이 스텝은 expect: { clock: { ended: true } }로 확인합니다. 슬롯 계기를 발생시키지 않고 슬롯을 지나친 advance:는 그 슬롯을 메모로 남기고(note: passed day 1 (Mon) afternoon, night without raising …), lute calendar --axis clock은 시계가 계기를 발생시키지 않는 칸을 not raised로 읽습니다. 실행이 시작되는 위치에서는 어떤 전진도 멈추지 않으므로 시계는 거기서 slot 계기도 dayStart도 발생시키지 않습니다. raiseAtStart: true는 엔진이 그것을 발생시킨다고 밝히며, 검사기와 캘린더는 그 위치를 발생한 것으로 셉니다. 이 키가 없으면 그 위치에서만 받는 비트는 W-BEAT-UNRAISED이고, 경고가 이 키를 알려 줍니다. 0.31.0부터 advances: slot, advances: day, advances: <n>을 제시되는 씬 프론트매터나 로어의 <entry>/<beat>에 선언할 수 있습니다. 이 선언은 메타데이터일 뿐이며 제시 순간 엔진이 advance:와 같은 방식으로 시계를 옮기고 퀘스트를 정산하며 raise: 계기를 발생시킵니다. 따라서 별도의 advance:를 바로 이어 쓰면 시간이 두 번 이동합니다. 플레이는 이를 메모로 표시하므로 중복 스텝을 삭제하세요. 이 키는 선언된 프로젝트 시계가 필요하고, 이전 플러그인 설정의 spendsSlot: true는 advances: slot으로, spendsSlots: n은 advances: n으로 옮깁니다.
  • 0.27.0: 계기는 raisedWhen: "holds('canEnter', [occasion.target])"를 선언할 수 있습니다(플러그인 occasions/*.yaml): 그 계기의 비트는 관문 아래에서 판정되고(E-BEAT-UNREACHABLE이 관문을 댐), lute beats는 관문이 결코 성립하지 않는 대상을 표시하며, lute play는 관문이 거짓일 때 그 계기를 발생시키는 스텝을 거부하고(E-OCCASION-GATE, 종료 코드 1), 관문이 막은 시계 발생은 메모와 함께 건너뜁니다. 스키마(만)는 terminal: "run.fate == 'taken'"을 선언할 수 있습니다: 모든 비트는 !terminal 아래에서 판정되고, 그것이 성립하면 플레이는 ── end: terminal — …로 끝나며(--json "end": "terminal"), 그 뒤의 occasion: / advance: 스텝은 newRun 전까지 E-OCCASION-GATE입니다. occasion: 스텝은 발생 전에 적용되는 engine: 쓰기를 가질 수 있습니다. 0.41: advance: 스텝의 engine: 쓰기는 시계가 움직이기 전에 적용되므로 그 발생들이 읽습니다. 런타임에서 거부된 발생은 상태를 바꾸지 않는 거부 E-OCCASION-GATE이며, gate(occasion, target?)로 미리 물을 수 있습니다.
  • 0.29.0: 의도적으로 런을 넘어 남는 결말(로그라이크의 영구 결말)은 긴 형식 terminal: { when: "user.crowned", persists: true }로 씁니다: W-TERMINAL-PERSISTENT는 나오지 않고, lute play는 newRun을 권하는 대신 게임이 영영 끝났다고 말하며, IR은 terminalPersists: true를 싣습니다. 런 상태만 읽는 조건에 쓴 persists: true는 E-META-VALUE입니다.

함께 나온 기능으로, 시계가 없어도 쓸 수 있습니다: enum 라벨, 정수 %, 가드 달린 ::set, :ordinal 힌트.

---
kind: scene
id: diner.payday
state:
run.day: { type: int, default: 1 }
run.visits: { type: int, default: 1 }
run.tab: { type: int, default: 0 }
run.today: { type: { domain: weekday }, default: fri }
enums:
weekday: { members: [mon, fri], labels: { mon: Monday, fri: Friday } }
---
## Payday
@mira: Happy {{run.today}}! Your {{run.visits:ordinal}} visit.
::set{run.tab = 0 when="run.day % 7 == 0"}
@mira{when="run.day % 7 == 0"}: Tab's cleared.

lute trace --state run.visits=3은 Happy Friday! Your 3rd visit.을 출력합니다.

→ 시계 · 스토리 플레이

---
kind: quest
id: cafe.quests
state:
run.tips: { type: int, default: 0 }
run.fired: { type: bool, default: false }
run.found: { type: bool, default: false }
run.day: { type: int, default: 1 }
user.xp: { type: int, default: 0 }
---
<quest id="regular" title="Become a regular" start="true" fail="run.fired" follows="visited('cafe.counter')">
<reward id="badge" kind="BADGE" amount="1"/>
<objective id="tip" title="Tip three times" done="run.tips >= 3">
@narrator: Mira starts your order when you walk in.
</objective>
<objective id="calm" title="End the night calm" on="runEnd" done="run.tips > 0"/>
<objective id="chat" title="Chat with Mira" done="visited('cafe.counter')" optional/>
<objective id="help" title="Help out back" quest="sideJob"/>
<on event="questComplete">
::set{user.xp += 50}
@narrator: You are a regular now.
</on>
<on event="questFailed">
@narrator: You are no longer welcome.
</on>
</quest>
<quest id="sideJob" title="Help out back">
<reward kind="GOLD" amount="10..20"/>
<objective id="dishes" title="Do the dishes" done="run.tips >= 1"/>
</quest>
<quest id="lostCup" title="Find the lost cup" tier="run">
<objective id="find" title="Find it by day 3" done="run.found" by="run.day > 3"/>
<objective id="tell" title="Tell Mira" on="talk" target="npc.mira" done="run.found"/>
</quest>
요소규칙
start=성립하면 퀘스트를 활성화합니다(unset → active). start가 없으면 수락형입니다: 씬이 ::accept{quest="…"}를 실행하거나 목(mock)이 수락할 때까지 unset으로 남습니다. 플레이 스크립트는 quests:로 세이브의 상태를 시드합니다. 어떤 ::accept도 이름을 부르지 않는 수락형 퀘스트는 W-QUEST-NEVER-ACCEPTED입니다(0.24.0, 프로젝트 전체 check). 0.25.0부터 accepts: 목은 더 이상 치지 않습니다. 0.26.0부터 테스트의 accepts:는 퀘스트를 프로젝트 전체에서 찾고, 플레이는 engine: { accept: [id] } 스텝으로 도중에 수락합니다.
accept="external"0.25.0. 엔진이 문서 밖(퀘스트 게시판, 메뉴)에서 퀘스트를 수락합니다. W-QUEST-NEVER-ACCEPTED를 잠재웁니다. start와 함께 쓰면 E-ATTR-TYPE, 부모와 함께 활성화되는 자식에 쓰면 E-ACCEPT-TARGET입니다(activate="accept"를 더하세요). IR: QuestCmd.accept. 0.53부터 조건이 있는 호스트 부여 퀘스트는 accept="external" follows="…"로 씁니다. 조건은 호스트가 적용하고 Lute는 적용하지 않습니다(스크립트 안 accept는 follows와 상관없이 활성화합니다).
fail=active → failed. 완료 조건과 동시에 성립하면 실패가 이깁니다.
follows=씬 그래프를 위한 그래프 메타데이터: && / ||로 묶은 visited / completed / active. 활성화를 결코 막지 않으며, 활성화는 start가 정합니다. 씬은 활성화를 막는 선행 조건을 프론트매터에 after:로 씁니다. 0.28.0 전에는 after=로 썼습니다(이제 E-UNKNOWN-ATTR). 이것이 없으면 퀘스트는 자신의 ::accept, 부모([subquest], 0.25.0), 그리고 visited(…) / entry.X.everRead / quest.Y.state == …를 읽는 start 연언항([start], 0.25.0)에 고정됩니다.
tier="run"새 런에서 퀘스트가 unset으로 돌아가고 목표도 모두 미완료가 됩니다. 기본값 tier="user"는 런이 바뀌어도 상태를 유지합니다. 0.26.0부터 매니페스트의 defaults: questTier: run은 tier=를 쓰지 않은 모든 퀘스트의 기본값을 바꿉니다. 하위 퀘스트의 등급은 부모와 같아야 합니다(E-QUEST-TIER-MIX).
rearm=0.27.0. 조건이 거짓에서 참으로 바뀔 때마다 퀘스트가 unset으로 돌아가고(목표, failedBy, 기한 초기화) 다시 시작하거나 수락할 수 있습니다: <quest id="harvestMissions" start="@harvestLive" rearm="@harvestLive">. tier="season:<name>"은 대신 그 시즌이 열릴 때마다 초기화합니다.
<objective done>done은 필수입니다(E-OBJECTIVE-MISSING-DONE). optional이 아닌 목표가 모두 완료되면 퀘스트가 완료됩니다. 완료는 되돌려지지 않으며 본문은 한 번만 재생됩니다.
on="runEnd"퀘스트가 활성인 동안 그 계기가 발생했을 때만 done을 판정합니다. 계기를 발생시키면 먼저 같은 이름으로 선언된 월드 이벤트의 핸들러(<on event="runEnd">)가 실행되고, 그다음 목표를 판정합니다.
by=기한이며, 0.24.0부터는 시점입니다: on=이 있든 없든 매 정산마다 판정합니다. 목표가 완료되지 않은 동안 처음으로 성립하면 목표는 영구히 실패하고, 필수 목표가 실패하면 퀘스트도 실패합니다(failed 보상, questFailed). 같은 정산에서는 done이 이기고, on= 목표의 계기를 발생시키는 스텝에서는 done을 먼저 판정합니다. done이 by를 함의하는 on= 목표는 그 스텝에서 둘이 함께 성립하지 않는 한 실패하며, W-DEADLINE-BEFORE-DONE이 until=을 제안합니다.
until=(0.24.0)장소에 묶인 기한(0.23.1에서 on= 목표의 by)입니다. 목표의 계기(와 target)가 발생할 때만, done 다음에 판정합니다. on= 없이 쓰면 E-BEAT-ATTR입니다.
on="talk" target="npc.mira"(0.23.0)계기가 그 대상에 대해 발생했을 때만 판정하며, 비트의 대상처럼 검사합니다(E-BEAT-ATTR). 도구에서는 talk@npc.mira로 발생시키고, lute play에서는 target:이 있는 스텝이 판정합니다.
목표의 visibleWhen=표시 여부만 정하며 done을 막지 않습니다(완료를 조건에 묶으려면 done=에 쓰세요). 0.28.0 전에는 when=으로 썼습니다.
quest="child"하위 퀘스트: 자식이 완료되면 완료되고, 필수 자식이 실패하면 부모도 실패합니다. done=과 함께 쓸 수 없습니다(E-OBJECTIVE-QUEST-DONE). start가 없는 자식은 부모와 함께 활성화되지만, activate="accept"(0.24.0)를 선언하면 부모가 활성인 동안 ::accept를 기다립니다. 부모와 함께 활성화되는 자식을 ::accept하면 E-ACCEPT-TARGET입니다.
complete="any"(0.24.0)필수 목표 중 하나만 완료되어도 퀘스트가 완료되고, 아직 활성인 나머지 자식은 failedBy superseded로 실패합니다. 대안 하나가 실패해도 퀘스트는 열려 있습니다. 기본값은 complete="all"입니다.
quest.<id>.failedBy(0.24.0)실패한 이유: 실패 전에는 unset, 그 뒤로 fail, by, until, subquest(필수 하위 퀘스트가 실패함), cascade, superseded. quest.<id>.objectives.<o>.failed는 by/until이 그 목표를 실패시키면 true입니다. lute play는 quest X -> failed (by)로 출력합니다.
<reward id kind amount target when outcome/>엔진이 지급하는 데이터입니다. 콘텐츠는 보상을 읽을 수 없으므로 같은 재화를 <on> 핸들러에서 ::set으로 또 올리지 마세요. 두 번 지급됩니다. amount는 정수나 범위 N..M입니다. outcome="failed"는 실패 시에 지급합니다(0.28.0 전: on=). 선택 속성 id="badge"는 영문자로 시작하는 ASCII 토큰으로, 퀘스트 안(목표의 보상 포함)에서 유일해야 하며(E-REWARD-DUP) 소스 맵과 산출물 RewardEntry.id의 안정 키가 됩니다. 없으면 선언 순서로 대신합니다. lute run, lute play, lute trace, lute test는 grant를 출력하고, 보상 종류가 credits:(아래)를 선언했다면 스칼라 금액을 그 경로에 더합니다. 그 퀘스트의 <on>이나 목표 본문에서 같은 경로를 ::set하면 W-REWARD-DOUBLE-CREDIT입니다.
<on event>questActive, questComplete, questFailed, 또는 플러그인의 월드 이벤트. when=으로 가드할 수 있습니다. target=(0.24.0)을 쓰면 같은 이름의 계기가 그 대상에 대해 발생할 때만 실행됩니다(일반 event: 스텝에서는 실행되지 않음). 결코 실패할 수 없는 퀘스트(fail도, by= 기한이 있는 필수 목표도, 실패할 수 있는 필수 하위 퀘스트 목표도, 부모 퀘스트도 없음)의 questFailed 핸들러는 W-QUEST-HANDLER-DEAD입니다. 핸들러 안의 ::accept는 lute trace / lute test에서도 lute play처럼 적용됩니다(0.26.0).

퀘스트 문서에는 ## 섹션, <hub>, <timeline>이 없고, 0.52부터 <quest> 바로 안에는 <on>, <objective>, <reward>만 올 수 있습니다(E-GRAMMAR-NOT-ADMITTED). <objective title>은 평문이며 그 안의 {{는 E-OBJECTIVE-TITLE-INTERP입니다. 다른 문서는 <match subject="quest.regular.state">나 when="quest.regular.state == 'complete'"로 퀘스트를 읽습니다. 상태는 항상 값이 있으므로 quest.regular.state == 'unset'은 “아직 받지 않음”을 뜻합니다.

퀘스트 구조(0.24.0): 대안, 대화에서 받는 하위 퀘스트, 두 종류의 기한.

---
kind: quest
id: keep.quests
state:
run.day: { type: int, default: 1 }
run.gold: { type: int, default: 0 }
run.freed: { type: bool, default: false }
run.lamp: { type: bool, default: false }
---
<quest id="rescue" title="Free the prisoner" start="true" complete="any">
<objective id="force" title="Storm the keep" quest="storm"/>
<objective id="guile" title="Bribe the guard" quest="bribe"/>
<on event="questComplete">
@narrator{when="quest.storm.failedBy == 'superseded'"}: No blood was spilled.
</on>
</quest>
<quest id="storm" title="Storm the keep" activate="accept">
<objective id="breach" title="Breach the gate" done="run.freed" by="run.day > 3"/>
</quest>
<quest id="bribe" title="Bribe the guard" activate="accept">
<objective id="pay" title="Pay him at his post" on="talk" target="npc.guard" done="run.gold >= 10" until="run.day > 2"/>
</quest>
<quest id="lamp" title="Light the lamp">
<objective id="light" title="Light it" done="run.lamp" by="run.day > 2"/>
<on event="questFailed">
@narrator{when="quest.lamp.objectives.light.failed"}: The night came first.
</on>
</quest>

씬의 ::accept{quest="storm"}은 자식을 받아들입니다. ::accept{quest="lamp" at="nextRun"}은 다음 newRun 초기화 직후로 수락을 미루므로, 런 사이 허브에서 받은 run 등급 퀘스트가 초기화를 견딥니다. follows=가 없는 수락형 퀘스트는 그것을 ::accept하는 모든 씬, 번들 비트, 퀘스트 본문에 고정됩니다 (lute scenario가 accept 간선을 그립니다).

보상 종류는 지급액이 들어갈 상태 경로(0.23.0)와 target= 계약(0.26.0)을 지정할 수 있습니다:

plugins/game.occasions/rewardkinds/game.yaml
rewardKinds:
GOLD: { credits: user.gold } # a grant adds its amount to user.gold (a range is the engine's roll)
BADGE: {}
ITEM: { target: { entity: bagItem, required: true } } # 0.26.0: target= must be a bagItem member

target: { entity: <kind> }나 { provider: <name> } 계약은 보상마다 target=을 검사하고(엔티티는 비슷한 이름 제안, 낡은 공급자 스냅샷은 경고만), required: true는 대상 없는 보상을 거부합니다: E-REWARD-TARGET (0.26.0). 두 가지를 모두 적은 계약은 플러그인 적재에 실패합니다.

0.50부터 보상 kind는 지급 시점에 적용되는 effects(asserts, retracts, accepts)도 선언할 수 있습니다. 범위 스탬프, 호스트 토큰, 보상 효과, 프로젝트 단계를 참고하세요.

→ 퀘스트와 씬

---
kind: lore
id: ship.records
series: captainsLog
entities:
crew: { members: [vesna] }
topic: { members: [heading] }
relations:
knows: { args: [crew, topic], tier: run }
state:
run.fire: { type: bool, default: false }
---
<entry id="log1" target="item.captains_log" category="note" title="Day 1">
@captain: We changed heading at midnight.
::assert{knows(vesna, heading)}
</entry>
<entry id="log2" target="item.captains_log" category="note" title="Day 2" when="entry.log1.read">
<match subject="run.fire">
<when is="true">
@narrator: The page is scorched.
</when>
<otherwise>
@captain: Nobody noticed.
</otherwise>
</match>
</entry>
  • <entry> 속성: id(필수, 프로젝트 안에서 유일), target, category, title, when, on, priority, once(run 또는 user, 시계가 있으면 day / slot도, 비트에만). 여러 파일에 걸친 시리즈에는 series와 order를 씁니다. 문서 수준의 series:는 파일 안의 위치로 엔트리 순서를 정하며, 그런 문서에서 엔트리별 series=/order=는 E-ENTRY-ATTR입니다.
  • 엔트리 본문에는 콘텐츠 줄, <match>, ::set, ::assert, ::retract, 그리고(0.27.0) 선언된 효과만 가진 플러그인 디렉티브(::pickup{item=…})를 둘 수 있습니다. <branch>, 그 밖의 디렉티브, 제목을 비롯한 나머지는 E-GRAMMAR-NOT-ADMITTED입니다.
  • 효과는 한 런에서 처음 읽을 때만 적용됩니다. 그 뒤로 entry.<id>.read는 true이며 어느 문서에서나 읽을 수 있습니다. run 등급이라 새 런에서 초기화됩니다. entry.<id>.everRead는 그 user 등급 짝으로, 처음 읽을 때 설정되고 새 런에서도 초기화되지 않습니다. entry.X.read를 가정하는 가드 아래에서 프로젝트 전체 check는 X가 모든 경로에서 assert하는 팩트를 압니다(0.24.0). 그래서 거기서 중복된 holds(…)는 W-FACT-GUARANTEED이고, everRead는 tier: user / app 팩트만 셉니다. 한 런에서 다시 읽힐 수 있는 엔트리(조회 엔트리, once 없음, 런보다 짧은 once, spentBy, for)가 쓰기를 하면 W-ENTRY-WRITE-REREAD입니다: 쓰기는 한 런의 첫 읽기에만(for는 멤버마다) 적용됩니다. 반복되어야 하는 쓰기는 <beat … once="false">에 두세요. <beat>의 once 기본값은 반복이 아니라 run입니다.
  • 0.26.0: lute play와 lute test는 엔트리 id를 받는 모든 곳에서 <문서 id>.<엔트리 id>(ship.records.log1)도 받고, 엔트리는 한 종류 전체를 대상으로 할 수 있습니다(target="kind:crew", 비트 참고).
  • 로어 문서에는 씬 본문을 가진 <beat> 블록도 둘 수 있습니다(0.23.0). 비트를 보세요.

→ 로어 엔트리

---
component: greet
params:
who: string
tier: { enum: [cold, warm] }
---
## Greet
@narrator: {{@who}} walks in.
<match subject="@tier">
<when is="warm">
@narrator: A warm welcome.
</when>
<when is="cold">
@narrator: A curt nod.
</when>
</match>

컴포넌트 파일 이름은 name.component.lute입니다. 본문에는 대사, 연출, @param 참조, 파라미터에 대한 <match>를 둘 수 있으며 스스로 상태를 읽거나 쓰지 않습니다. {{@param}}은 int, double, bool, enum 파라미터를 렌더링하고, 0.23.0부터는 string 파라미터도 렌더링합니다. 리터럴 ::use 인자가 펼칠 때 치환되므로 호출 지점마다 자기 문장을 자기 lineId로 출하합니다. 텍스트에 끼워 넣는 string 파라미터에 @def를 넘기면 E-REF-TYPE입니다. 가져오는 씬은 components:에 파일을 적고 ::use로 펼칩니다. 인자로 호출하는 쪽의 @def를 넘길 수 있습니다(tier=@mood). enum 파라미터라면 def가 낼 수 있는 모든 값이 멤버여야 하고(E-COMPONENT-ARG), 컴포넌트가 디렉티브 속성에 넣는 인자가 상태에 따라 바뀌는 def이면 E-ATTR-DEF-DYNAMIC입니다:

---
kind: scene
character: demo
season: 1
episode: 2
uses: ../base.schema.yaml
components: [greet.component.lute]
---
## Greeting by Component
::use{component="greet" who="marina"}
@narrator: And the scene carries on.

컴포넌트가 펼친 줄의 주소는 {prefix}.{component}#{n}.{speaker}_{code}입니다. n은 호스트가 그 컴포넌트를 ::use한 순번(1부터, 문서 순서)이므로, 두 번 쓴 컴포넌트가 같은 lineId를 공유하지 않고 컴포넌트 줄의 code도 주변 호스트 줄에 따라 달라지지 않습니다.

effects: true(0.24.0)를 선언한 컴포넌트는 ::set, ::assert, ::retract를 쓸 수 있고, 팩트 원자에 파라미터를 쓸 수 있습니다(::assert{gifted(@who, @item)}. ::use마다 인자로 묶이며, 상수가 아닌 인자는 E-COMPONENT-ARG, 컴포넌트 밖 팩트의 @param은 E-FACT-DOMAIN). 쓰기는 ::use마다 호스트의 스키마로 검사되고 ::use가 놓인 자리에 컴파일됩니다. 파라미터는 per: 묶음의 멤버를 고를 수도 있습니다(run.approval[@who]): ::use마다 인자가 가리키는 멤버에 쓰고, 묶음의 종류에 속하지 않는 인자는 E-COMPONENT-ARG입니다. speaker 파라미터는 캐스트 id를 받으며(선언된 캐스트 밖이면 E-CAST-UNKNOWN), {{@who}}는 캐스트의 name을 렌더링하고 <match subject="@who">와 디렉티브 속성(::battle{foe=@who})은 id를 봅니다. 0.26.0부터 줄의 as=@who 라벨도 {{@who}}처럼 캐스트 이름을 보여 줍니다.

---
component: praise
effects: true
params:
who: speaker
delta: int
entities: # declared here only so this file checks alone:
companion: { members: [isolde, corvin] } # at `::use` the host's schema decides
state:
run.approval: { type: int, default: 0, per: companion }
---
## Praise
@narrator: {{@who}} approves.
::set{run.approval[@who] += @delta}

본문의 가드와 match 대상은 여전히 상태를 읽을 수 없고(E-COMPONENT-STATE), effects: true 없이 쓰기를 하면 E-COMPONENT-BODY입니다. 예외(0.26.0)는 컴포넌트 자신의 디렉티브 결과입니다: 자기 본문의 ::battle{… resultKey="fight"} 뒤에서 scene.battle.fight.won을 읽을 수 있고, ::use마다 그 결과 슬롯이 호스트에 선언됩니다.

0.26.0부터 컴포넌트는 직접 쓴 텍스트와 같게 동작합니다: @@who: …는 speaker 파라미터가 가리키는 캐스트 멤버로 말하고(초상, 음성, emotions:와 present: 검사를 ::use마다 적용. speaker 파라미터가 아닌 것에 @@x를 쓰면 E-COMPONENT-ARG), 파라미터는 default:(리터럴이나 @def)를 선언해 생략된 인자에 줄 수 있고, 문자열 인자 안의 {{…}}는 보간되며, as=@who 같은 속성은 캐스트 이름을 보여 줍니다. ::use{… when="…"}는 펼친 내용 전체를 실행하거나 전혀 실행하지 않습니다. 컴포넌트의 {mono} 줄은 ::use마다 호출하는 쪽의 시점 인물과 monoSpeakers로 검사하며, 실패하면 그 ::use에 오류가 붙습니다.

---
component: welcome
params:
who: speaker
greeting: { type: string, default: "Welcome back." }
mood: { type: { enum: [cold, warm] }, default: warm }
---
## Welcome
@@who: {{@greeting}}
<match subject="@mood">
<when is="warm">
@narrator: {{@who}} smiles.
</when>
<otherwise>
@@who: Hm.
</otherwise>
</match>

::use{component="welcome" who="mira"}는 Mira로 “Welcome back.”을 말하고, ::use{component="welcome" who="mira" greeting="You again, {{userName}}?" mood="cold"}는 두 기본값을 모두 덮어씁니다. 종류로 타입을 정한 파라미터({ type: { entity: bagItem } })는 ::use마다 검사됩니다.

스키마는 extends: base.schema.yaml로 다른 스키마를 다듬을 수 있습니다. 베이스가 아래층이므로 이름을 다시 선언하면 덮어씁니다. 상태 경로의 type을 바꾸면 E-EXTENDS-STATE-TYPE입니다. uses:는 동등한 스키마들을 합치며, 같은 이름이 두 번 선언되면 오류입니다.

→ 컴포넌트와 extends · 정의와 파라미터

---
kind: scene
id: storm.beat
enums:
framing: [closeUp]
cameraMove: [jolt]
---
## Storm
<timeline duration="1.2">
<track subject="camera">
::camera{focus="mira" framing="closeUp" duration="0.6"}
::camera{move="jolt" duration="0.3" at="0.7"}
</track>
<track channel="sfx">
::sfx{sound="thunder" at="0.5"}
</track>
</timeline>
<timeline duration="1.0">
</timeline>
@narrator: After the pause.

트랙에는 연출 디렉티브와 ::set만 들어갑니다. 트랙 키(subject=, channel=, 또는 subject= + property=)는 유일해야 합니다. at=은 타임라인 자체 시계의 절대 시각입니다. 빈 타임라인은 시간을 정한 멈춤입니다. 산출물에서 클립은 timing.at(초)과 timing.timeline(타임라인 순번)을 싣고, 타임라인 끝은 자기 timeline과 at을 직접 가진 barrier 레코드입니다.

→ 타임라인과 속성 트랙

범위 스탬프, 호스트 토큰, 보상 효과, 프로젝트 단계 (0.50)

섹션 제목: “범위 스탬프, 호스트 토큰, 보상 효과, 프로젝트 단계 (0.50)”

플러그인 stampAttrs 항목은 on:(표면: line, branch, hub, choice, 또는 디렉티브 태그)으로 범위를 정할 수 있습니다. required: true는 on:이 있을 때만 쓸 수 있습니다. on:이 없으면 교차(cross-cutting) 스탬프로, 스탬프를 실을 수 있는 모든 디렉티브와 콘텐츠 줄에 적용되며 <branch>/<hub>/<choice>에는 절대 적용되지 않습니다.

plugins/acme.stage/stampattrs/columns.yaml
stampAttrs:
- { name: voiceAssetId, type: { providerRef: voice }, required: true, on: [line] }
- { name: assetName, type: string, on: [bg, actor] }
- { name: menuType, type: { enum: [plain, timed, minigame] }, on: [branch, hub] }
- { name: optionKey, type: string, required: true, on: [choice] }
- { name: cue, type: string } # cross-cutting, as in 0.49
<branch id="route" prompt="Which way?" menuType="plain">
<choice id="goLeft" text="Left" optionKey="L1">
@mira{voiceAssetId="VO.mira.0010"}: Left it is.
</choice>
</branch>

플러그인은 hostTokens export(hosttokens/*.yaml)로 호스트 토큰을 선언합니다. 그러면 {{playerName}}은 reserved placeholder로 컴파일되고 호스트가 렌더링 시점에 치환합니다(lute.core가 userName을 선언). 선언되지 않은 맨 {{name}}은 E-CEL-PROFILE입니다.

plugins/acme.stage/hosttokens/tokens.yaml
hostTokens:
playerName: { type: string }
rank: { type: int } # {{rank:ordinal}} ok, {{rank:capitalize}} is E-REF-TYPE
platform: { type: { enum: [ios, android, web] } }

보상 kind는 지급 시점에 팩트와 수락을 적용할 수 있습니다(@target은 보상의 target=이며, writes는 허용되지 않고 상태 쓰기는 credits:입니다). 보상마다 grant + credits, 그다음 retracts, asserts, accepts 순입니다:

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

lute.project.yaml은 checker W-* 코드의 단계(off, info, warn, error)를 정하며, 선택적으로 paths glob 아래에만 적용할 수 있습니다. --deny CODE는 off보다 우선하고, 오류는 절대 낮추지 않습니다.

diagnostics:
W-DISPLAY-NAME-DUP: error
W-STAGE-ABSENT: { level: off, paths: ["scenes/imported/**"] }

node 린트(lute.lint.yaml의 custom: 또는 플러그인 lints: export)는 노드마다 한 번 실행됩니다. 모든 인덱스를 가드하세요:

custom:
- id: beat-label-prefix
target: node
when: 'node.tag == "label" && scene.kind == "beat" && "name" in node.attrs && !node.attrs["name"].startsWith("b_")'
level: error
message: "a beat's labels start with b_"

→ 플러그인 매니페스트 · CLI · 린팅

한도 있는 점프, 선언된 raise, 지시어 텍스트, pair lint (0.51)

섹션 제목: “한도 있는 점프, 선언된 raise, 지시어 텍스트, pair lint (0.51)”

::jump는 max를 가지면 앞선 ::label로 되돌아갈 수 있습니다. max는 점프를 따라갈 수 있는 최대 횟수이고, max번 따라간 뒤에는 다음 노드로 넘어갑니다. max는 1~99의 정수 리터럴이어야 하며(그 밖의 값과 앞으로 가는 점프의 max는 E-JUMP-MAX), 루프는 한 본문 안에 머물고(E-JUMP-LOOP-SCOPE), 머리 label로만 진입하며(E-JUMP-INTO-LOOP), 겹치지 않고 중첩됩니다(E-JUMP-LOOP-CROSS). 메뉴를 exit 선택지가 선택될 때까지 다시 보여 줄 때는 <hub>를 씁니다.

::label{name="attempt"}
::tryLock{door="vault"}
<match subject="scene.lock.vault.won">
<when is="false">
@mira: Again!
::jump{to="attempt" max="2"}
</when>
<when is="true">
@mira: It clicks open.
</when>
</match>
@theo: That's enough for today.

디렉티브나 보상 kind는 호출이나 부여 뒤에 호스트가 던지는 occasion을 선언할 수 있습니다. raisedBy: declared인 occasion은 선언된 raises로만 던져집니다. lute scenario는 던지는 쪽에서 그 occasion에 응답하는 모든 장면과 비트로 [raises] 엣지를 그리고, 아무것도 던지지 않으면 W-OCCASION-NEVER-RAISED가 경고합니다. 선언된 raise는 가능할 뿐 보장이 아니며 팩트, 쓰기, Must 주장을 더하지 않습니다.

plugins/acme.stage/occasions/o.yaml
occasions:
cutscene: { select: first, target: { prefix: scene, entity: cutsceneId }, raisedBy: declared }
# plugins/acme.stage/directives/d.yaml
directives:
- name: playScene
attrs:
- { name: scene, required: true, type: { entity: cutsceneId } }
effects:
raises: [{ occasion: cutscene, target: "@scene" }]
# plugins/acme.stage/rewardkinds/r.yaml
rewardKinds:
CUTSCENE: { target: { entity: cutsceneId, required: true }, effects: { raises: [{ occasion: cutscene, target: "@target" }] } }

raise의 target은 디렉티브에서는 @<attr>, 보상 kind에서는 @target, 또는 리터럴(scene.chapter2)입니다. E-PLUGIN-RAISES(어셈블리)와 E-RAISES-TARGET(프로젝트)가 검사합니다.

text 타입 속성은 번역할 수 있는 플레이어용 텍스트입니다. text 속성이 있는 디렉티브는 code=를 받고, 번역 키는 {prefix}~{tag}~{code}~{attr}입니다. lute tag가 빠진 코드를 채우고, lute loc export / import / report가 그 텍스트를 kind: "directive" 행으로 다룹니다.

directives:
- name: banner
attrs:
- { name: title, required: true, type: text }
- { name: subtitle, type: text }
::banner{code="0010" title="Welcome to the hall, {{userName}}"}

pair lint는 닫히지 않은 여는 요소, 열린 적 없는 닫는 요소, 이미 열린 쌍 안에서 다시 열린 요소, 쌍 안에 inside가 허용하지 않는 행을 찾습니다. scope: body(기본)는 한 본문의 형제끼리, scope: section은 깊이와 상관없이 문서 순서대로 행을 짝짓고 섹션마다 초기화합니다. 0.53부터 scope: document는 모든 섹션을 포함해 문서 전체에 슬롯 하나만 둡니다.

custom:
- id: group-balanced
target: pair
open: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "begin"'
close: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "end"'
inside: 'node.kind == "line" || node.tag in ["actor", "sfx"]'
scope: body
level: error
message: "::group begin/end"

list, map, record 속성 타입은 계속 거부됩니다(구조화된 속성 값은 아직 지원하지 않습니다). 필드마다 속성 하나를 두거나, 플레이어에게 보이는 텍스트에는 text 속성을, 옵션 목록에는 <choice> 요소를 쓰세요.

→ 디렉티브 · 플러그인 매니페스트 · 린팅 · 콘텐츠 세트 이식하기

리터럴 중괄호, 허브 떠나기, 메뉴 텍스트, 보상 속성 (0.52)

섹션 제목: “리터럴 중괄호, 허브 떠나기, 메뉴 텍스트, 보상 속성 (0.52)”

\{{는 줄 텍스트에서, 그리고 0.52부터 라벨 문법을 따르는 모든 값(<choice text>, prompt, 플러그인 text 속성)에서 리터럴 {{를 씁니다. 그 밖의 따옴표 값에서는 E-STRING-ESCAPE이고, ::use 인자는 \\{{를 넘깁니다. 마커 바로 앞의 리터럴 {({\{{{{run.a}})는 E-INTERP-BRACE입니다. 대신 { {{run.a}}로 쓰세요.

허브 옵션이나 <return>에서 허브 뒤의 레이블로 가는 앞으로의 ::jump는 허브를 떠납니다. <return>도, 메뉴의 재제시도 없습니다. 따로 실행되는 본문(다른 옵션, <on>·<objective> 본문, 엔트리, 비트)으로 들어가는 점프는 E-JUMP-INTO-BODY입니다.

<hub id="desk">
<choice id="take" text="Take it">
@narrator: Taking it.
::jump{to="after"}
</choice>
<choice id="leave" text="Leave" exit>
@narrator: Leaving.
</choice>
</hub>
::label{name="after"}
@narrator: After the hub.

프롬프트의 {{…}}는 라벨처럼 검사되고, 런타임은 라벨과 프롬프트를 렌더링해 awaitChoice 메뉴(text, texts, prompt, promptTexts)에 싣습니다. 호스트는 이 값을 보여 줍니다. 허브의 각 옵션은 앞선 선택이 남길 수 있는 무대에서 판정되므로, 한 옵션이 내보낸 캐릭터는 다른 옵션에서 W-STAGE-ABSENT입니다.

보상 kind의 attrs:는 <reward>에 쓰고 효과가 @<attr>로 읽습니다. 지급 레코드는 이를 reward.attrs에 담습니다.

rewardKinds:
BADGE:
target: { entity: person, required: true }
attrs: [ { name: rarity, type: { enum: [common, rare, gold] }, default: common } ]
effects: { asserts: ["badge(@target, @rarity)"] }
<reward kind="BADGE" target="mira" rarity="gold"/>

→ 선택지와 허브 · 대사와 캐스트 · 퀘스트와 씬 · 플러그인 매니페스트

섹션을 넘는 루프, 호스트가 부여하는 퀘스트, 중첩 프론트매터 참조, scope: document (0.53)

섹션 제목: “섹션을 넘는 루프, 호스트가 부여하는 퀘스트, 중첩 프론트매터 참조, scope: document (0.53)”

문서의 최상위 ## 섹션은 런타임에서 하나의 범위이듯 하나의 루프 범위입니다(섹션은 표현과 식별용이며 엔트리나 재개 지점이 아닙니다). 한도 있는 ::jump{to max}는 앞선 섹션의 ::label로 되돌아갈 수 있습니다. 그 밖의 루프 규칙은 그대로입니다(머리에서만 진입, 겹침 금지, 허브 옵션·<return>·<on>·<objective>·엔트리·비트 본문 밖으로 나가는 루프 금지). E-JUMP-LOOP-SCOPE는 더 이상 섹션을 이름으로 들지 않습니다.

---
kind: scene
id: lock.retry
title: Retry
---
## Shot 1. The door {#door}
::label{name="attempt"}
@narrator: Mira tries the lock.
## Shot 2. The result {#result}
@narrator: It sticks.
::jump{to="attempt" max="2"}
@narrator: That's enough for today.

조건이 성립하면 호스트가 부여하는 퀘스트는 accept="external" follows="…"로 씁니다. 조건은 호스트가 적용합니다(Lute는 조건을 호스트에 전달하고, 분석은 이를 호스트 정책으로 읽어 heuristic 근거로 다룹니다). 보상이나 ::accept는 follows와 상관없이 퀘스트를 활성화하며, accept="external"과 start를 함께 쓰면 계속 E-ATTR-TYPE입니다. start와 스크립트 안 accept로 서로를 기다리는 퀘스트들이 있고 사이클 밖의 출처도 호스트 경로도 없으면 E-QUEST-START-CYCLE입니다(accept로 활성화되는 퀘스트도 마찬가지). 하나를 accept="external"로 호스트가 부여하게 하세요.

<quest id="letter" accept="external" follows="completed('intro')">
<objective id="read" title="Read the letter"/>
</quest>

플러그인 프론트매터의 record, list, map 값 안에 있는 참조도 최상위 값과 똑같이 검사되고, 메시지가 필드 경로를 이름으로 듭니다. pair lint는 scope: document(문서 전체에 슬롯 하나)를 받습니다. lute fix는 이전 모든 릴리스의 모든 이전(migration)을 적용하며, 프로필이 스탬프로 선언한 디렉티브 속성은 그대로 둡니다.

→ 디렉티브 · 퀘스트와 씬 · 프론트매터와 프로필 · 린트 · lute fix가 실행하는 것

lute compile은 실행 IR을 씁니다. 봉투에는 kind, lute, irVersion, capabilitySnapshot, 정렬된 requiredSemantics, meta, sections, commands가 실립니다. 연출 스니펫을 컴파일하면 이런 레코드가 나옵니다(발췌):

{"kind": "bg", "family": "staging", "position": "001-0100", "location": "diner", "time": "night", "timing": {"wait": true}}
{"kind": "camera", "family": "staging", "position": "001-0400", "focus": "mira", "framing": "closeUp", "move": "pushIn", "timing": {"wait": true, "duration": 0.5}}
{"kind": "line", "family": "content", "position": "001-0800", "role": "mono", "speaker": "fixer", "text": "She remembered.", "lineId": "diner.night.fixer_0020", "voiceKey": "diner.night.fixer-0020"}
{"kind": "line", "family": "content", "position": "001-1500", "role": "os", "speaker": "mira", "text": "Hold on! Just a second.", "lineId": "diner.night.mira_0030", "voiceKey": "diner.night.mira-0030", "segments": [{"text": "Hold on!"}, {"pause": 0.5}, {"text": " "}, {"text": "Just a second.", "styles": ["whisper"]}]}
{"kind": "sequence", "family": "staging", "position": "001-1900", "name": "doorOpen", "timing": {"wait": true}}
{"kind": "cg", "family": "staging", "position": "001-2000", "assetId": "cg.diner.door", "display": "show", "layout": "fullscreen", "timing": {"wait": false}}
필드뜻
kind, family모든 명령이 가집니다. family는 content(line), staging(bg music sfx vfx actor camera cg video sequence), state(set assert retract), control(choice match hub jump end barrier), declaration(quest on entry accept beat), plugin 중 하나입니다.
position001-0100 꼴의 위치입니다. 첫 부분은 1부터 세는 섹션 위치, 나머지는 섹션 안의 명령 순서이며, 소스 순서가 바뀌면 다시 매겨지므로 영속 id로 쓰지 않습니다. 점프와 갈래의 대상(target, converge, otherwise, return)도 같은 꼴입니다.
timingwait, duration, delay, at(초), timeline(타임라인 순번). 값이 하나도 없으면 빠집니다. barrier는 timing 대신 자기 timeline과 at을 직접 가집니다.
roledialogue, narration, mono, os, vo
voiceKey모든 줄에 있습니다.
text, segmentstext는 수식어 표시를 걷어 내고 이스케이프를 푼 평문입니다({{…}}는 그대로). 수식어가 있는 줄만 segments를 가지며, 텍스트 조각은 styles(바깥부터)와 rate를, 멈춤은 pause만 가집니다. 번역된 로캘마다 평문 texts[locale]이 있고, 수식어가 있는 줄은 localeSegments[locale]도 있습니다.
selectionKeychoice와 hub 레코드가 고른 값을 기록하는 키(scene.choices.<id>)입니다. 선택지의 표시 텍스트는 text입니다.
sections산출물의 섹션 표: {"section": 1, "heading": "Counter", "id": "counter"}. id는 {#…}를 쓴 섹션에만 있습니다.
capabilitySnapshot산출물, 프로젝트 색인, lute context --json이 싣는 기능 스냅샷 해시입니다.

→ 런타임 계약

명령용도
lute check [<file>…] [--project <DIR>] [--wip] [--deny-warnings] [--json]문서 하나(또는 여러 개)를 검사합니다. 관계형 가드 판정과 W-WIP은 프로젝트 전체 모드가 필요합니다. --project가 없으면 파일 위쪽에서 가장 가까운 lute.project.yaml을 적용하고, 그 사실을 stderr에 알립니다.
lute check --project <DIR> [--wip] [--deny-warnings] (문서 피연산자 없음)프로젝트 전체 모드: 모든 문서와 함께 연결성, 퀘스트 id, ::accept 대상, 계기, 팩트 가드를 검사합니다. 깨끗한 문서는 모두 컴파일까지 해 보므로, 컴파일 단계 오류(E-DUP-VOICEKEY, E-CAPABILITY-MISMATCH)도 여기서 실패합니다. W-BEAT-PRIORITY-TIE, W-QUEST-HANDLER-DEAD 같은 프로젝트 권고도 여기서 나옵니다. --wip(0.23.0)는 아직 아무것도 만들어 내지 않는 관계(시드, assert, 규칙, reserved 모두 없음) 때문에만 가드가 죽은 E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, E-OBJECTIVE-UNSATISFIABLE을 경고로 낮춥니다. 만드는 쪽이 있는데도 결코 맞지 않는 관계는 여전히 오류입니다. 0.26.0부터 --wip는 바인딩되지 않은 @param을 쓰는 컴포넌트 ::assert만이 만드는 관계도 아직 생산자가 없는 것으로 봅니다.
lute fix <file|dir>기계적 이전을 제자리에서 적용합니다: 옛 :line 표기, as= → into=, test="$ == …" → is=, 그리고 이름이 바뀐 디렉티브·속성·태그 속성(E-RENAMED-*). 카메라 값, cgLayout 멤버, 시점(pov, monoSpeakers)처럼 판단이 필요한 것은 진단만 하고 추측하지 않습니다. 디렉터리를 주면 그 아래의 모든 .lute 파일을 재귀적으로, 정렬 순서대로 처리합니다. 0.53부터는 버전 올리기만이 아니라 이전 모든 릴리스의 모든 이전을 적용합니다. 바뀐 파일의 줄에 적용한 규칙 이름이 나오고, 프로필이 스탬프로 선언한 디렉티브 속성은 그대로 두며 stderr에 출력합니다. 먼저 lute fix --check를 실행해 diff를 검토하세요.
lute tag [<path>…] [--renumber [--commit]] [--check]파일이나 디렉터리(기본 .) 아래 모든 .lute 파일의 모든 줄에 안정적인 code를 채웁니다. --renumber는 다시 번호를 매길 내용을 미리 보여 주기만 하고, --renumber --commit이 실제로 씁니다. --check는 아무것도 쓰지 않고 바뀔 파일이 있으면 1로 끝납니다.
lute compile <file> -o out.json · compile --project <DIR> -o <DIR> (문서 피연산자 없음) · compile --stream <file> · --locale-bundle <file>산출물을 만듭니다. 프로젝트 모드는 beats를 포함한 project.index.json도 씁니다. --stream은 stdin의 본문을 이어 붙여 NDJSON 스냅숏을 냅니다. --project가 없으면 파일 하나를 그 위쪽에서 가장 가까운 lute.project.yaml로 컴파일합니다(0.27.0). check와 같습니다.
lute trace <file> [--mock m.yaml] [--state P=V] [--fact "r(a)"] [--choice id=c[,c]] [--event e] [--accepted-quest q] [--occasion o[@target]] [--entry id | --beat id] [--no-derive] [--expand]프로젝트의 시드 팩트와 규칙을 적용한 채 소스를 목에 맞춰 미리 봅니다. --project가 없으면 파일 위쪽에서 가장 가까운 lute.project.yaml이 프로젝트입니다(0.27.0, check와 같음). 종료 코드 3은 판정할 수 없는 가드를 만났다는 뜻입니다. --occasion talk@npc.mira는 대상에 대해 계기를 발생시킵니다(0.23.0). --beat는 번들 비트 하나를 로컬 id나 정식 id로 제시합니다(없는 id면 E-TRACE-BEAT). @def는 쓴 그대로 출력되고(<match @weekday>), --expand(0.24.0)는 펼친 식을 출력합니다. --accepted-quest는 activate="accept" 자식도 받습니다. 0.26.0: --entry는 <doc>.<entry>도 받고, 실행된 ::jump는 label까지 따라갑니다.
lute run <artifact> [--mock m.yaml] [--occasion o[@target]] [--entry id | --beat id]컴파일된 산출물을 엔진처럼 실행합니다. 로어 산출물에는 --entry와 --beat(번들 비트의 정식 id, 모호하지 않으면 로컬 id) 중 정확히 하나가 필요합니다. 목의 bridges:가 플러그인 호출에 응답합니다(0.24.0).
lute play --project <DIR> --script p.play.yaml [--json] [--ir] [--quiet] [--derivation <ATOM>] [--conditions-output <file>] [--no-derive]프로젝트 전체에 계기를 발생시키며 퀘스트를 진행합니다. expect:가 어긋나면 종료 코드 1입니다. 연출은 작성한 그대로 출력되고, --ir은 대신 로워링된 레코드를 주입된 것까지 표시해 출력합니다. --derivation(반복 가능)은 플레이가 끝난 뒤 ground atom의 도출 트리를, 성립하지 않으면 그것을 결론 낼 수 있는 규칙마다 실패한 전제를 출력합니다. 0.24.0부터 assert된 잎은 출처를 밝힙니다(asserted by scene `cafe.open`, step 1).
lute test [<path>…] [--project <DIR>] [--coverage] [--no-derive]모든 *.test.yaml과, expect:가 있는 모든 *.play.yaml을 실행합니다. 파일 하나를 주면 그 테스트나 플레이만 실행합니다. --project가 없으면 가장 가까운 lute.project.yaml을 기준으로 해석합니다(stderr에 알림). 미완료로 끝난 워크는 실패하고, file:이 없는 테스트도 스위트를 멈추지 않고 실패 하나(E-TEST-FILE)로 남습니다. --coverage는 --project나 가장 가까운 lute.project.yaml의 프로젝트에서 어떤 테스트도 트레이스하지 않고 어떤 플레이도 제시하지 않은 문서를 나열합니다. 0.24.0부터 advance: 스텝의 발생이 제시한 문서도 셈에 들고, 머리글은 두 수를 함께 셉니다: coverage over N traced path(s) and M play(s) (…):. 0.26.0부터 프로젝트를 한 번만 적재하고 테스트와 플레이를 병렬로 실행하며(RAYON_NUM_THREADS 존중), 결과는 원래 순서로 보고합니다. 0.27.0부터 단위는 비트입니다: 번들 비트와 엔트리를 하나씩 나열하고(lore/endings/ren.lute: ember), 플레이가 고른 선택지도 branch/hub 행에 세며, 머리글은 (plays count toward what they presented and the choices they picked, not match arms)이고, 마지막 절에 어떤 플레이도 제시하지 않은 비트를 나열합니다(--json: untested, notPresentedByPlay, 각각 {file, id, kind}).
lute scenario [--project <DIR>] [reach --node <NODE> | envelope --node <NODE> | knowledge [--node <NODE>]] [--facts] [--format text|dot] [--json]after: 그래프, 도달 가능성, 보장되는 상태와 팩트. 노드는 씬 id, quest:<id>, 또는 번들 비트의 정식 id입니다(그대로 또는 beat:<doc>.<beat>, 간선 없는 진입 노드로 그려짐). knowledge(0.23.0)는 팩트 가드가 있는 비트, 엔트리, 목표마다 질의하는 관계를 찾고, 각 관계를 규칙을 거슬러 그것을 만드는 쪽까지 추적합니다: assert하는 문서, 시드 팩트, 엔진(reserved), 또는 만드는 쪽 없음. 부정 전제를 깨뜨릴 수 있는 팩트도 알려 줍니다. --node에는 엔트리 id나 <quest>.<objective>도 줄 수 있습니다. 0.24.0부터 모든 가드 자리를 다루고, 종류 원자를 멤버십으로 읽으며(suitor(sol) — entity kind `suitor`; sol is a member), 규칙의 cel() 전제가 읽는 것을 밝힙니다. 0.26.0: knowledge는 규칙의 count(…) 전제를 그것이 세는 팩트의 생산자까지 추적하고, --facts는 팩트 생산 간선(scene(mid.gameCorner) -> scene(east.ashTowerLens) [hasItem(spectralLens)], --json: factEdges, dot: 점선)을 그립니다. reach --endings[=<OCCASION>](0.27.0)는 엔딩마다 — 그 occasion에 답하는 비트, 또는 인자가 없으면 ::end를 실행할 수 있는 모든 비트 — after: 판정, when 판정(절대 참이 안 됨 / 절대 이기지 못함), 만족 가능한 when이 읽는 것과 그것을 쓰는 쪽(아무도 쓰지 않으면 nothing writes it), 그리고 N ending(s): A reachable, B unreachable, C unknown 요약을 보여 줍니다.
lute beats [--project <DIR>] [--occasion <OCCASION>] [--target <TARGET>] [--json] [--expand]0.23.0. 계기별(대상별) 비트 사다리를 선택 순서대로 보여 줍니다: priority, once(번들 비트의 day / slot 포함), also, after:, when(@def는 쓴 그대로, --expand면 펼침), 제목, 그리고 프로젝트 전체 check의 판정(도달 불가, 가려짐, 동점, once-run-user). 프로젝트가 깨끗하게 검사되지 않아도 됩니다. 0.26.0부터 앞선, 결코 소진되지 않는 비트가 그 when을 함의해 늘 이기는 폴백은 covered by <id>로(--json: coveredBy) 표시되고, 종류 대상 비트에는 kind:<kind> 사다리가 생기며, --target은 아무 멤버나 받습니다.
lute refs [--project <DIR>] --attr <DIRECTIVE>.<ATTR> | --reward <KIND> [--json]0.26.0. 디렉티브 속성(give.item)의 모든 값, 또는 한 보상 종류의 모든 대상을 그것을 쓰는 문서와 줄과 함께 나열합니다. 컴포넌트를 거쳐 전달된 값은 그 ::use에서(via component <name>), 대상 없는 보상은 (no target)으로 나옵니다. 병합 전에 누가 무엇을 주는지 봅니다.
lute calendar [--project <DIR>] [--axis run.day=1..7] [--axis quest.q.state=unset,active] [--axis 'holds('awake', ['toma'])=true,false'] [--axis "visited('cafe.counter')=true,false"] [--axis run.aff.*=6,7] [--axis 'run.aff[run.route]=6,7'] [--axis clock=1..3] [--occasion <O>] [--occasion-axes <O>=<axis>[=<value>],…] [--target <T>] [--relation <relation>] [--script p.play.yaml [--until <step | label>]] [--where <cel>] [--format text|csv] [--json]0.23.0. 축들의 곱의 모든 칸(첫 축이 가장 느리게 바뀜)에서 계기별로 play와 같은 자격 판정을 보여 줍니다: 승자나 제시 목록, 가려진 자격 있는 비트 +N, 판정할 수 없는 칸 ?, 마지막으로 어느 칸에서도 자격이 없는 비트와 (0.24.0) 어딘가에서 자격은 있었지만 한 번도 제시되지 않은 비트. 스크립트의 세이브에서 그 스텝을 재생한 상태(--until까지)나 선언된 기본값에서 시작합니다. --where는 조건이 성립하지 않는 칸을 뺍니다. 대상 있는 계기는 비트가 이름을 붙인 대상마다 열 하나를 받으며, --target mon.inchlet은 대상을 직접 고릅니다(--occasion-axes의 = 뒤에는 대상이 아니라 축을 씁니다). 0.24.0: clock[=d1..d2]는 날 × 슬롯을 시계 순서로 펼치고, visited() 축은 id를 세이브에 넣거나 뺍니다. --occasion-axes dusk=clock.day(또는 =run.day,run.slot=night, 바뀌는 어느 경로든)는 그 계기를 그 축의 값마다 한 번 평가하고 나머지 칸은 비웁니다. --relation at은 칸마다 누가 어디 있는지 보여 줍니다. 0.27.0: --axis run.aff.*=6,7은 per: 패밀리의 모든 멤버를, --axis 'run.aff[run.route]=6,7'은 각 칸에서 run.route 축이 가리키는 멤버 하나만 설정합니다(나머지는 세이브 값이나 기본값 유지). 패밀리 이름만 쓴 --axis run.aff는 두 형태를 알려 주는 사용 오류입니다.
lute lore [--project <DIR>]대상별·시리즈별 엔트리와 비트, 그리고 그것이 드러내는 팩트.
lute context <file> [--project <DIR>] [--node <NODE> | --at <file>:<line>:<col>] [--script <file>… | --all-scripts] [--json]여기서 쓸 수 있는 모든 것: 디렉티브(내장 포함), 어휘, 상태(owner: engine 표시), def, 등급과 reserved 여부를 담은 관계, 대상 도메인을 담은 계기, 캐스트, 컴포넌트 시그니처, 텍스트 수식어, 모든 씬·퀘스트·엔트리 id. --json은 기능 스냅샷 해시 capabilitySnapshot도 싣습니다. --project가 없으면 파일 위쪽에서 가장 가까운 lute.project.yaml의 것입니다(0.27.0).
lute lint [<path>] [--config lute.lint.yaml] [--deny <CODE>]프로젝트별로 설정하는 권고성 편집 린트(L-*). 선형 VN 지표는 비트, 컴포넌트, 퀘스트, 로어를 건너뜁니다. 0.26.0부터 W-DISPLAY-NAME-DUP도 보고합니다(--deny W-DISPLAY-NAME-DUP로 오류로 올림). 0.50부터 custom:이나 플러그인 규칙이 target: node를 쓸 수 있고, CEL 조각에 startsWith/endsWith/contains가 있습니다.
lute doctor [--project <DIR>] [--strict] [--json]툴체인과 프로젝트 설정: 버전, 활성 플러그인, 계기별로 응답하는 비트 수, 플레이 스크립트와 테스트, PATH의 lute-lsp가 이 버전인지, (0.24.0) 실행 중인 lute 옆의 빌드와 같은지(lute-lsp beside lute), 실행 중인 lute-lsp가 낡았는지(에디터 재시작). --strict(0.26.0)는 실패한 검사(✗)가 하나라도 있으면 1로 끝납니다. 0.26.0부터 lute-lsp는 자기 바이너리가 교체된 것을 알아채고, 옛 빌드의 결과 대신 재시작하라는 lute-lsp-stale 진단 하나를 보냅니다.
lute new scene|quest|lore|schema <name> [--project <DIR>] · lute init [<DIR>] [--template minimal|investigation|beats]문서나 프로젝트의 뼈대를 만듭니다. 새 문서에는 id:가 들어가고 defaults:가 채워 주는 것은 빠집니다. 파일 이름은 입력한 대소문자를 그대로 둔 id를 따릅니다. lute new scene <name> --occasion <OCCASION> [--target <prefix>.<member>]은 계기와 대상을 프로젝트에 맞춰 검사한 뒤 비트를 씁니다. 대상 있는 계기에는 --target이 필요합니다.

trace 목(--mock). 테스트의 목 키도 같은 형식입니다:

# mocks/counter.yaml: lute trace scenes/counter.lute --project . --mock mocks/counter.yaml
file: ../scenes/counter.lute # required under mocks/, which whole-project check validates
state: { run.tip: 5 } # path: literal
facts: ["knows(vesna, manifest)"] # base facts, on top of the schema's facts: seeds
choose: { greet: tip, chat: [coffee, leave] } # branch: choice; hub: its visit order
events: [combatEnd] # world events, for <on event>
# bridges: { check: [ { passed: true, margin: 3 } ] } # 0.24.0: plugin-call answers by tag, in call order
# derive: false # the 0.21 model: no seeds, no rules
# a save's history, for the paths the document reads:
# visited: [cafe.counter] # scenes already played; unlisted = not visited
# quests: { regular: complete } # quest.<id>.state
# entriesRead: { run: [log1], user: [vesnaFirst] } # entry.<id>.read / entry.<id>.everRead
# quest walks also take:
# accepts: [lostCup] # accept-driven quests to take up (0.26.0: resolved project-wide)
# occasions: [runEnd, talk@npc.mira] # raised after the walk settles; <occasion>@<target> for a target
# a kind-target beat reads its member from state: { occasion.target: vesna } (0.26.0)

tests/regular.test.yaml (file:은 테스트 파일 기준 상대 경로):

file: ../quests/cafe.lute
state: { run.tips: 3 }
visited: [cafe.counter]
occasions: [runEnd]
expect:
quests: { regular: complete, lostCup: unset } # unset | active | complete | failed
state: { user.xp: 50 }
facts: ["regular(mira)"] # hold at the end, after derivation
notFacts: ["banned(mira)"]
transcriptContains: ["@narrator: You are a regular now."] # lines that played, as `@speaker: text`
transcriptLacks: ["You are no longer welcome."]
end: complete # complete | terminal | incomplete | error; without it, incomplete fails
tests/counter.test.yaml
file: ../scenes/counter.lute
choose: { greet: wave, chat: [coffee, cup, leave] }
expect:
options: { greet: [wave, tip], chat: [coffee, cup, leave] } # exact sets: flirt's `when` failed
accepts: [lostCup] # 0.24.0: the quests this walk's ::accept took, as a set
# tests/barks.test.yaml: a lore test names the entries it presents
file: ../lore/barks.lute
entries: [vesnaFirst, vesnaBack] # in order, read flags set between; or `entry: <id>`
state: { user.runs: 3 }
expect:
transcriptContains: ["So you are the new one.", "Back again?"]
eligible: { vesnaFirst: true, vesnaBark: true } # `when` verdicts; an unpresented key is judged alone
# tests/order.test.yaml: a bundle beat, by its bare or canonical id
file: ../lore/talks.lute
beat: miraOrder # or cafe.talks.miraOrder
state: { run.tips: 3 }
expect:
eligible: true

options:는 branch나 허브가 제시한 선택지 집합 전체를 여러 번의 제시에 걸쳐 정확히 비교합니다(허브는 0.24.0부터 방문마다 자격 있던 선택지를 모두 합칩니다). entry:, entries:, beat:가 모두 없는 로어 테스트는 E-TEST-LORE입니다. 엔트리 하나는 lute trace <file> --entry <id>로 미리 볼 수 있습니다. 엔트리는 <doc>.<entry>로도 부를 수 있습니다(0.26.0). 테스트도 목의 bridges:를 받습니다.

0.26.0부터 목에서 when(또는 after:, 소진된 once: user)이 거짓인 엔트리, 번들 비트, 씬 비트를 걷는 테스트는 eligible:을 단언하지 않는 한 실패하며, 거짓인 전제와 필요한 목을 알려 줍니다. eligible:을 단언하면 자격 없는 본문은 걷지 않습니다:

FAIL tests/north/gull.test.yaml (tests/north/../../lore/north/gull.lute)
eligible northGullFog: not eligible under these mocks (its `when` is false) — the engine would never present it, so the walk proves nothing about play; fix the mocks, or assert `expect: { eligible: { northGullFog: false } }` (the body is then not walked)

0.27.0부터 다른 불일치도 전제를 댑니다. 단언한 eligible: { ember: true }가 틀리면 eligible ember: expected true, got false — its `after="visited('ren.confession')"` is false — mock `visited: [ren.confession]` 로 읽힙니다. 가드가 거짓인 선택지에 떨어진 --choice(또는 테스트의 choose:)는 가드, 그 가드를 거짓으로 만든 읽기 하나하나, 그리고 그것을 바꿀 목을 댑니다: its guard `holds('found', ['receipt'])` decided false: `found(receipt)` does not hold (mock `--fact "found(receipt)"`) — 테스트에서는 facts: ["found(receipt)"]로, 플레이에서는 YAML 키로 씁니다.

plays/first.play.yaml. 최상위 키는 state, facts, choose, derive, bridges(0.24.0), 세이브 시드인 visited, presented, quests, entriesRead, 그리고 expect와 steps입니다. 각 스텝은 occasion, engine, event, newRun, advance, include, end 중 하나입니다:

visited: [cafe.counter] # save seeds, applied before step 1
presented: { user: [vesna.gift] } # spent `once: user` / `once: run` beats
quests: { lostCup: active } # unset | active | complete | failed; objectives start undone
entriesRead: { user: [vesnaFirst] } # run: entry.<id>.read + everRead · user: entry.<id>.everRead
state: { user.runs: 10, run.tips: 3, quest.lostCup.objectives.find.done: true } # objective progress
facts: ["knows(vesna, manifest)"]
choose: { greet: wave, chat: [coffee, leave] } # hub: visit order; a branch list: one per presentation
bridges: { check: [ { passed: true, margin: 3 } ] } # 0.24.0: answers to plugin calls, consumed in order
steps:
- occasion: townVisit
label: arrival # printed in the step header
choose: { greet: tip } # this step only, replacing that key
expect: { options: { greet: [wave, tip] } } # 0.24.0: the options this step offered, as a set
- occasion: talk
target: npc.vesna # <prefix>.<member> of the occasion's target domain
expect: { winner: vesnaBack, offered: [vesnaBack, vesnaBark], notOffered: [vesna.gift] }
- occasion: talk
target: npc.mira
expect: { presented: [cafe.talks.miraOrder, cafe.talks.miraHum] } # the winner, then its `also` beats
- occasion: inbox
pick: megNote # required on a non-empty `select: all`, refused on `select: first` and `sequence`
- occasion: inbox
pick: none # pass: nothing presented or spent
- occasion: evening # select: sequence: every eligible beat, in selection order
- occasion: runEnd # a same-named world event's <on event> first, then <objective on="runEnd">
expect: { quests: { lostCup: active }, state: { run.tips: 3 } } # judged right after this step settles
- event: combatEnd # a world event: active quests' <on event> run
- engine: # writes what the engine owns; presents nothing
state: { run.day: { add: 1 } } # a literal, or { add: n }; quest.* is refused
facts: ["awake(toma)"] # any declared base relation, reserved ones included
retract: ["awake(vesna)"]
- advance: day # 0.24.0, with a clock: slot | day | <n>; settles, raises the clock's raise: occasions
engine: { facts: ["awake(toma)"] } # optional: writes of the same moment, applied where the clock arrives
- advance: { to: night } # 0.26.0: the next night (or { to: { weekday: Fri, slot: morning } }), forward only
expect: { clock: { slot: night } } # 0.26.0: where the clock stands (weekday, slot, day)
- engine: { accept: [lostCup] } # 0.26.0: the engine accepts an accept-driven quest (quest lostCup accepted (engine))
- include: common.steps.yaml # 0.24.0: splice another script's steps here (path relative to this file)
- include: day.steps.yaml # 0.27.0: repeat / choose / bridges on an include script its spliced steps
label: a day # 0.28.0: labels every spliced step without its own
repeat: 3
choose: { firstLook: ren }
bridges: { exam: [ { grade: pass } ] }
- newRun: { facts: ["knows(vesna, manifest)"] } # or `true`; resets run.*, run facts, once: run, tier="run" quests
- occasion: townVisit
repeat: 2
- end: true # ends the playthrough (exit 0); later steps print as skipped
- occasion: townVisit # skipped
expect: # judged at the end; a miss exits 1
end: complete
quests: { regular: complete, lostCup: unset }
state: { user.xp: 50, run.day: 1 }
facts: ["can_halt(vesna)"] # after derivation
notFacts: ["awake(toma)"]
transcriptContains: ["@narrator: You are a regular now."]
transcriptLacks: ["You are no longer welcome."]
  • engine: 스텝은 선언된 상태, 팩트, retract를 쓰며, 무엇이든 재생하기 전에 타입을 검사합니다. 쓴 뒤에는 퀘스트 수명 주기를 정산하므로 쓰기 한 번으로 그 자리에서 퀘스트가 완료될 수 있습니다. quest.*는 거부합니다: 퀘스트 상태는 수명 주기의 몫이며 quests:로 시드합니다. newRun은 같은 state:와 facts:를 새 런의 시드로 받습니다.
  • target은 occasion 스텝에만, pick과 choose는 occasion이나 advance 스텝에 씁니다. label은 어느 스텝에나, repeat은 end를 뺀 어느 스텝에나 쓸 수 있습니다. include: 스텝은 label과 expect를 받지 않습니다. 0.27.0부터는 repeat: n(파일을 n번 이어 붙임)과 자신의 choose: / bridges:를 쓸 수 있으며, 이것들은 이어 붙인 스텝 전부에 스크립트 자신의 것보다 먼저 적용되고 그 구간이 끝나면 버려집니다. 구간이 재생되는 동안 덮어쓴 키에 대한 스크립트 자신의 목록은 움직이지 않습니다. 스텝의 expect:는 occasion 스텝과 advance 스텝에서 winner(계기가 지나가면 none), offered(자격 있는 비트의 부분집합), notOffered, presented(0.23.0: 제시된 id 전체를 순서대로)를 받습니다. advance 스텝에서 winner, offered, notOffered는 시계가 멈춘 자리의 발생을 판정하고, presented는 그 스텝이 제시한 비트를 모두, dayStart / dayEnd 발생의 것부터 나열합니다. end를 뺀 모든 스텝에서 quests, state, facts, notFacts(0.23.1)와 clock(0.26.0: { weekday, slot, day } 중 아무것이나)을 받습니다. 어긋나면 그 스텝과 양쪽 값을 알려 줍니다. target:이 있는 스텝은 그 대상의 <objective on target>도 판정합니다. 승자나 pick:은 엔트리를 id나 (0.26.0) <doc>.<entry>로 부릅니다.
  • newRun 스텝은 자신이 스냅숏한 prev.run.* 값 하나하나(--json: prevRun)와, 실제로 초기화한 run 등급 퀘스트를 이전 상태와 함께 출력합니다. ::accept{at="nextRun"}으로 미뤄 둔 퀘스트는 그 직후에 활성화됩니다. 받아들일 수 없는 수락(부모가 활성이 아닌 activate="accept" 자식)은 note: accept of quest c spent — its parent quest p is not active yet …로 출력됩니다.
  • advance: 스텝은 시계를 앞으로만 옮깁니다. 그 engine: 쓰기는 시계가 도착하는 곳에 — 가는 길에 발생한 dayEnd / dayStart 뒤, 마지막 정산과 발생 전에 — 적용됩니다. 거기서 시계 자신의 날이나 슬롯 경로에 쓰거나, clock.index를 뒤로 옮기는 engine: 스텝은 사용 오류(종료 코드 2)입니다. include: 순환도 사용 오류입니다.
  • bridges:(0.24.0): 응답마다 그 호출이 읽는 결과 필드를 정확히 줍니다. 최상위 목록은 플레이 전체에서 호출 순서대로 소비되고, 스텝 자신의 bridges:는 그 스텝의 호출이 먼저 소비하며 남은 응답은 그 스텝을 실패시킵니다. 모르는 태그, 남거나 빠진 필드, 맞지 않는 값은 로드 시점의 사용 오류입니다. 응답 없는 호출은 그 호출에서 플레이를 멈추고(종료 코드 3), lute trace와 lute test는 그 결과를 알 수 없음으로 읽습니다. 목이나 테스트에서 빠진 필드는 그 파일 안을 가리키는 E-TRACE-MOCK-TYPE입니다. scene.* 시드는 더 이상 호출에 응답하지 않으며, lute play는 그런 시드를 거부합니다(종료 코드 2).
  • 스텝의 expect.options: { <branch 또는 hub>: [ids] }(0.24.0)는 그 스텝이 제시한 선택지를 단언합니다. 자격 있는 비트가 없는 select: all 계기에는 pick:이 필요 없습니다.
  • transcriptContains / transcriptLacks는 실제로 재생된 줄만, 한 줄에 @speaker: text 하나로 비교합니다 (텍스트 조각만 써도 맞습니다). 건너뛴 가드 줄, 스텝 머리글, 연출은 lute play와 lute test 모두에서 결코 맞지 않습니다. 속성 블록이 없는 찾는 문자열은 줄의 속성과 상관없이 맞습니다. 0.27.0부터는 찾는 문자열의 속성 블록을 판정합니다(0.26.0은 떼어 냈습니다): "@mara{emotion=\"shy\"}: Any luck?"는 emotion="shy"를 가진 줄(속성이 더 있어도 됨)과만 맞습니다. 그래서 블록이 있는 transcriptLacks는 같은 말을 다른 식으로 했을 때 성립합니다. “결코 말하지 않음”이라는 뜻이면 블록 없이 쓰세요. 줄이 보여 주지 않는 블록 키(emotoin=, when=)나 도메인 밖의 값은 무엇이든 재생하기 전에 did-you-mean과 함께 거부됩니다: lute play에서는 사용 오류(종료 코드 2), 시나리오 테스트에서는 E-TEST-NEEDLE(테스트 실패)입니다. 맞지 않으면 가장 가까운 제시된 줄을 알려 줍니다.
  • 종류 대상 비트(0.26.0)의 {{occasion.target}}은 lute play에서 멤버의 캐스트 name:으로 렌더링됩니다. 브리지 응답은 결과 슬롯이나 기능의 result: 형태로 타입이 정해지며, 타입 없는 응답은 문자열로 저장하지 않고 거부합니다.
  • lute play --project . --script plays/first.play.yaml --derivation "can_halt(vesna)"는 어떤 규칙이 그 atom을 결론 냈고 각 전제가 어디서 왔는지 보여 줍니다.

→ CLI 레퍼런스 · 트레이싱 · 스토리 플레이

코드보통의 의미
E-KIND-MISSING프론트매터에 kind:가 없고 defaults:도 채워 주지 않습니다.
E-META-MISSING씬에 id:도 character + season + episode도 없습니다.
E-META-PARSE프론트매터가 올바른 YAML이 아닙니다. 대개 : 가 들어간 값을 따옴표 없이 썼을 때입니다.
E-META-UNKNOWN-KEY이 kind에서 쓸 수 없는 키입니다. 예: 퀘스트의 after:(<quest follows=…>를 쓰세요).
E-CONTENT-OUTSIDE-SECTION첫 ## 제목 섹션 앞에 콘텐츠가 있습니다.
E-DOMAIN-UNKNOWNemotion=, action=, anchor, framing, cgLayout, textStyle 같은 도메인을 썼지만 아무도 그 도메인을 선언하지 않았습니다.
E-UNDECLARED / E-UNDECLARED-REF상태 경로나 @def가 선언되지 않았거나, 그 스키마를 가져오지 않았습니다.
E-MAYBE-UNSET기본값도, 앞선 ::set도, has 가드도 없는 경로를 읽었습니다. prev.run.*를 읽을 때는 항상 필요합니다.
E-CAST-UNKNOWN캐스트가 선언되어 있는데(스키마의 cast:나 플러그인의 cast 내보내기) 이 화자는 그 안에 없거나, (0.24.0) ::actor{character}, ::camera{focus}, speaker 컴포넌트 인자가 캐스트 밖을 가리킵니다. 메시지가 가장 가까운 id를 제안합니다.
W-CAST-ABSENT0.24.0. 화자의 캐스트 항목이 present:를 선언했는데 줄을 감싼 가드가 그것을 함의하지 않습니다({vo} 줄은 제외, {os} 줄은 검사). 줄에 가드를 달거나(@corvin{when="holds('inParty', ['corvin'])"}) 그런 가드 아래로 옮기세요. 가드를 거짓으로 만들 수 있는 쓰기만 그 가드를 무효로 합니다. 단일 파일 check는 모든 경로에서 assert된 팩트를 볼 수 없지만 프로젝트 전체 check는 봅니다. 0.25.0: 관계의 changedOn: 계기 뒤의 줄에서는 assume: true가 그 관계를 덮지 않습니다.
E-BAD-ENUMenum 밖의 값입니다. 0.24.0부터는 화자의 캐스트 emotions: 밖의 emotion=도 해당합니다(happy는 isolde의 감정이 아님). 0.26.0부터는 { entity: K }로 타입을 정한 속성에 그 종류 밖의 값을 줄 때도 해당합니다(비슷한 이름 제안과 함께, 컴포넌트 파라미터를 거친 값도).
E-ENGINE-OWNED-WRITE::set이 owner: engine으로 선언된 경로에 씁니다. 콘텐츠는 읽기만 합니다. lute play에서는 engine: 스텝으로, trace와 test에서는 목의 state:로 쓰세요.
W-QUEST-STATE-HAShas(quest.<id>.state)는 항상 참입니다. 'unset'과 비교하세요.
W-TEXT-LOOKS-LIKE-REF줄의 텍스트 전체가 def나 파라미터 이름인 @name이라 글자 그대로 출하됩니다. {{@name}}으로 쓰세요.
E-UNSET-UNCOVERED / E-NONEXHAUSTIVE<match>가 unset, enum 멤버, 숫자 틈을 놓쳤습니다(메시지가 빠진 값을 알려 줌). 갈래나 <otherwise>를 추가하세요. 0.24.0부터는 비트의 when:이 먼저 도메인을 좁히므로, 그것이 배제한 갈래는 쓰지 않아도 됩니다.
E-WHEN-LITERAL-DOMAIN<when is> 리터럴, 또는 0.26.0부터 가드가 == / != / in [...]로 비교하는 문자열이 대상의 도메인(enum, occasion.target의 종류, 퀘스트의 state / failedBy) 멤버가 아닙니다. 메시지가 멤버를 나열하고, 비교라면 가장 가까운 멤버를 제안합니다.
E-TAG-INLINE-BODY / E-TAG-NOT-ONE-LINE태그와 본문이 한 줄에 있거나, 태그가 여러 줄로 나뉘었습니다.
E-BRANCH-ALL-GUARDED / E-HUB-NO-EXIT메뉴가 빌 수 있거나, 허브가 끝나지 않을 수 있습니다.
E-SET-TYPE / E-REF-TYPE / E-ATTR-TYPE값의 타입이 자리에 맞지 않습니다. 디렉티브 안의 따옴표 친 "@def"나, 컴포넌트가 텍스트에 끼워 넣는 string 파라미터에 넘긴 @def가 흔한 원인입니다.
E-ATTR-DEF-DYNAMIC디렉티브 속성에 상태를 읽는 @def를 넣었습니다. 속성 값은 상수여야 하니 <match>로 나누세요.
E-INTERP-DEF{{@def}}의 본문을 식 하나로 펼칠 수 없습니다(펼침 순환, 또는 $를 읽는 본문).
E-ATTR-QUOTE속성 값을 작은따옴표로 감쌌거나, 워드 프로세서가 넣은 둥근 따옴표(“…”, ‘…’)로 감쌌습니다. 곧은 "…"를 쓰고, 값 안의 "는 \"로 쓰세요.
E-DEF-DECLdef 형식이 잘못되었습니다: 타입을 추론할 수 없거나, type: 없이 params:를 썼거나, 모르는 키가 있습니다.
E-OBJECTIVE-MISSING-DONE / E-OBJECTIVE-QUEST-DONE목표에 done이 없거나, quest=와 done=을 함께 썼습니다.
E-QUEST-TIER-MIX하위 퀘스트의 tier가 부모와 다릅니다. 둘의 등급을 맞추세요: 섞인 트리는 새 런이 한쪽만 초기화하면 영영 잠깁니다.
E-GRAMMAR-NOT-ADMITTED이 kind에서 허용되지 않는 구문입니다. 예: 엔트리 안의 <branch>, 퀘스트 안의 제목. 0.52부터 <quest> 바로 안의 <on>, <objective>, <reward> 이외의 것도 해당합니다(퀘스트는 그 본문만 실행합니다). 그 순간에 맞는 본문 안으로 옮기세요.
E-BEAT-ATTR비트 키 형식이 잘못되었거나 on이 없거나, when이 scene.*를 읽거나, 대상 없는 계기에 씬이나 번들 비트의 target을 썼거나 대상이 대상 도메인 밖에 있거나(비슷한 이름 제안과 함께), 엔트리의 once가 run, user, day, week, slot, season:<name>이 아니거나, 시계가 없는데 once: day / week / slot을 썼거나(week는 week:도 필요), spentBy를 once: false나 share와 함께 썼거나(0.28.0), also가 bool이 아니거나 엔트리 또는 select: all / sequence 계기에 있습니다. id가 없거나 id에 -가 있거나 문서에 id:가 없는 <beat>, 형식이 잘못되었거나 on이 없는 목표의 target=, on= 없는 until=도 E-BEAT-ATTR입니다. 0.26.0: target="kind:K"의 종류가 계기 도메인 안의 닫힌 종류가 아니거나, 도메인을 가진 계기로 플러그인이 선언하지 않은 계기에 쓴 경우.
E-OCCASION-UNKNOWN플러그인이 계기를 선언했는데 이 계기는 그중에 없습니다.
W-BEAT-PRIORITY-TIE한 select: first 계기에서 대상이 같거나 둘 다 없고 priority도 같은 두 비트의 when이 서로 배타적임을 증명할 수 없습니다. 승자는 파일 순서로 정해집니다. 0.26.0부터 부정을 먼저 정규화하고(!(x < 1)은 x >= 1), 두 조건이 왜 배타적이지 않은지(once: run보다 오래 사는 플래그, 다른 곳에서 assert되는 팩트, 각자 읽는 경로) 말해 줍니다. 종류 대상 비트는 멤버를 지목한 비트와 동점이 되지 않습니다.
W-BEAT-ONCE-RUN-USER기본값 once: run을 그대로 둔 비트의 when이 user 등급 상태만 읽어서 런마다 다시 재생됩니다. 의도한 것이면 once: run을 직접 쓰고, 아니면 once: user를 쓰세요.
W-QUEST-HANDLER-DEAD결코 실패할 수 없는 퀘스트(fail도, by= 기한이 있는 필수 목표도, 실패할 수 있는 필수 하위 퀘스트도, 부모 퀘스트도 없음)에 <on event="questFailed">가 있습니다.
W-STAGE-ABSENT어떤 경로에서 퇴장했거나 ::bg 장면 전환으로 자동으로 숨겨진 캐릭터를 줄이 무대에 세웁니다. 선택지와 <match> 갈래는 따로 따라가므로 한 갈래의 퇴장이 형제 갈래에서 경고를 내지 않습니다. 갈래가 다시 합쳐진 뒤에는 모든 갈래가 무대에 남겨 둔 캐릭터만 무대에 있습니다.
E-BEAT-UNREACHABLE / E-ARM-DEAD조건이 결코 성립할 수 없습니다. 프로젝트 전체 check는 팩트 질의도 판정합니다. 0.23.0부터는 한 && 안의 모순(run.n > 5 && run.n < 3)도 잡습니다. lute check --project . --wip에서는 아직 아무것도 만들어 내지 않는 관계 때문에만 죽은 가드가 경고 W-WIP이고, 메시지가 원래 코드를 밝힙니다.
E-ACCEPT-TARGET::accept가 없는 퀘스트나 start가 있는 퀘스트를 가리키거나, (0.24.0) 부모와 함께 활성화되는 자식을 가리키거나(그 자식에 activate="accept"를 선언하세요), at이 "nextRun"이 아닙니다.
W-QUEST-NEVER-ACCEPTED0.24.0, 프로젝트 전체 check. 어떤 ::accept도 이름을 부르지 않는 수락형 퀘스트(start 없음, 또는 activate="accept" 자식)입니다. 결코 활성화되지 않습니다. 0.25.0부터 목의 accepts:는 치지 않으며(메시지가 그 목을 밝힙니다), 엔진이 수락하는 퀘스트에는 accept="external"을 선언하세요.
E-FACT-EXCLUSIVE / E-RULE-EXCLUSIVE0.25.0. ::assert가 두 excludes: 관계를 모든 경로에서(lute trace / lute test에서는 걸은 경로에서) 함께 성립하게 하거나, 규칙이 다른 쪽이 성립하는 곳에서만 한쪽을 도출합니다.
W-LUTE-VERSION-STALE문서의 luteVersion이 툴체인과 다릅니다. 0.25.0: 매니페스트의 defaults: luteVersion에서 물려받은 경우 프로젝트 전체 check는 문서마다가 아니라 lute.project.yaml의 그 줄에서 한 번 보고합니다(… — every document inherits it (N documents)).
W-DEADLINE-BEFORE-DONE0.24.0. on= 목표의 by=가 done이 성립할 때마다 성립하므로, 매 정산마다 판정하는 by가 계기가 done을 판정하기 전에 목표를 실패시킵니다. 그 조건을 until=로 쓰세요.
E-STATE-DECL상태 선언이 잘못되었습니다. 0.24.0부터는 열린 종류나 모르는 종류에 대한 per:, 그리고 멤버가 아닌 키를 쓰거나 값도 _도 없는 멤버를 남기거나(`default:` gives no value for `guild`) per: 없는 경로에 쓴 맵 default:도 해당합니다.
W-DOMAIN-UNREAD선언했지만 아무것도 읽지 않는 enum이나 종류입니다. 0.24.0부터는 per: 색인, subsetOf: 부모, 규칙 본문이나 holds(…)의 종류 원자도 읽기로 치며, 경고는 선언 자신의 줄에 붙습니다. 0.53부터는 그 타입을 쓰는 플러그인 프론트매터 키(깊이 무관)도 읽기로 칩니다.
E-ENTITY-KIND-SHAPE엔티티 종류가 잘못되었습니다. 0.26.0부터는 한 종류나 enum에 같은 멤버를 두 번 적었거나 두 파일이 같은 멤버를 더한 경우(두 줄을 모두 밝힘), 어떤 import도 선언하지 않은 종류에 add:를 쓴 경우(비슷한 이름 제안), open: 종류에 add:를 쓰거나 members: / open: 옆에 쓴 경우도 해당합니다. 하위 종류의 멤버는 부모에 다시 적지 않아도 됩니다. 스키마 줄에서 한 번 보고합니다.
E-STATE-DECL-CONFLICT0.26.0, 프로젝트 전체 check. 두 문서가 한 상태 경로를 다른 type, default, per, owner로 선언합니다(scene.*는 제외, extends:로 다듬은 것은 충돌 아님). 함께 쓰는 경로는 두 문서가 모두 가져오는 스키마에 한 번만 선언하세요.
E-REWARD-TARGET0.26.0. 보상의 target=이 보상 종류의 target: 계약을 어깁니다: { entity: K } 종류의 멤버가 아니거나, { provider: … }가 모르거나, required: true인데 없습니다.
E-RULE-AGGREGATE-CYCLE0.26.0. 규칙의 count(…) / countDistinct(…)가 그 규칙의 머리에 의존하는 관계를 셉니다. 머리가 먹이지 않는 관계만 세세요.
W-DISPLAY-NAME-DUP0.26.0, 프로젝트 전체 check와 lute lint. 두 화자가 같은 표시 이름으로 보입니다: 캐스트 name: 둘, 캐스트 이름과 ::use{… name="…"} 문자열, 또는 그런 문자열 둘. 하나를 바꾸거나, 의도한 역할 이름이면 캐스트 항목에 sharedName: true를 적으세요.
E-PLUGIN-STAMP-SURFACE0.50. stampAttrs의 on:이 line, branch, hub, choice, 또는 해결된 프로필의 스탬프 가능한 디렉티브가 아닌 것을 지정합니다(오타, jump/label/clear, 비활성 플러그인의 디렉티브). 이름을 고치거나 그 디렉티브를 선언한 플러그인을 depends:에 넣으세요.
E-DIAGNOSTICS-CONFIG0.50. lute.project.yaml의 diagnostics: 항목이 잘못되었거나 허용되지 않습니다: E-*나 L-* 코드, 알 수 없는 코드, 잘못된 level, 잘못된 paths. 단계를 바꿀 수 있는 것은 W-* 코드뿐입니다.
E-JUMP-MAX / E-JUMP-LOOP-SCOPE / E-JUMP-INTO-LOOP / E-JUMP-LOOP-CROSS0.51. max가 1~99의 정수 리터럴이 아니거나 앞으로 가는 점프에 붙었습니다. 루프가 자기 본문을 벗어납니다(0.53: ## 섹션은 본문이 아니며 루프는 섹션을 넘을 수 있습니다). 앞으로 가는 점프가 루프의 머리 뒤로 들어갑니다(0.53: 그 점프가 <choice> 본문의 마지막 노드이고 다른 점프가 그 label을 가리키지 않으면 메시지가 블록을 그 옵션 안으로 옮기라고 제안합니다). 두 루프가 중첩되지 않고 겹칩니다.
E-JUMP-INTO-BODY0.52. ::jump가 자기 range로 실행되는 본문(허브 옵션·<return>, <on>·<objective> 본문, 엔트리, 비트)으로 바깥에서 들어갑니다. 허브 본문에서 허브 뒤의 레이블로 나가는 점프는 허용되며 허브를 떠납니다.
E-INTERP-BRACE0.52. 줄 텍스트에서 {{…}} 마커 바로 앞에 리터럴 {(홀수 개)가 있거나, 평범한 {{로 끝나는 ::use 인자가 마커 앞에 끼워집니다. 둘 사이에 글자를 넣거나 \\{{를 넘기세요. 0.53. 표현식 자체가 {로 시작하는 마커({{{run.a}}, 라벨 문법 값 포함)도 해당하며 0.52는 이를 E-CEL-PROFILE로 보고했습니다. 메시지가 중괄호 개수 n을 밝힙니다.
E-L10N-MARKERS0.53. 번역의 {{…}} 마커가 파싱되지 않거나 소스의 마커가 아닙니다(각각 같은 횟수, 순서 무관). 그 번역은 병합되지 않고 compile --locale-bundle이 실패하며, loc import는 파싱되지 않는 마커를 거부합니다.
E-OBJECTIVE-TITLE-INTERP0.52. <objective title>에 {{가 있습니다. 제목은 쓰인 그대로 보입니다. 평문으로 쓰세요.
E-PLUGIN-RAISES / E-RAISES-TARGET / W-OCCASION-NEVER-RAISED0.51. raises 항목이 알 수 없는 occasion이나 잘못된 꼴의 target을 가리킵니다. 해석된 raise target이 도메인 밖이거나 어떤 장면·비트도 응답하지 않습니다. raisedBy: declared occasion에 응답하는 쪽이 있는데 아무것도 던지지 않습니다.
E-LINE-CODE-RESERVED / E-LINE-ID-DUP0.51. code에 ~가 있습니다. 두 로컬라이제이션 단위가 같은 최종 lineId를 가집니다.
W-ENTRY-WRITE-REREAD0.26.0. 한 런에서 다시 읽힐 수 있는 엔트리(조회 엔트리, once 없음, 런보다 짧은 once, spentBy, for)가 ::set, ::retract, 또는 쓰기 효과만 가진 디렉티브를 씁니다: 쓰기는 한 런에서 처음 읽을 때만 적용됩니다. 반복되는 쓰기는 <beat … once="false">에, 아니면 once="run"이나 when="!entry.<id>.read" 가드를 쓰세요. ::assert는 제외입니다.
E-COMPONENT-ARG::use 인자가 파라미터에 맞지 않거나 없는 파라미터를 가리킵니다. 0.26.0부터는 speaker 파라미터가 아닌 것에 쓴 @@x:나 컴포넌트 밖의 @@x:도 해당합니다.
E-UNKNOWN-ATTR디렉티브가 선언하지 않은 속성입니다. 0.26.0부터는 내장 레코드로 로워링되는 디렉티브(::bg, ::sfx 같은 코어 연출 디렉티브, ::end, ::label, 플러그인 lower: 레코드)의 when=과 when이라는 이름의 플러그인 속성도 해당합니다.
E-ENTITY-KIND-CLASH두 종류가 같은 멤버를 나열합니다. 두 id 공간의 id가 철자만 같은 것이라면 두 공간의 멤버를 모두 나열하는 공유 루트 종류를 선언하고 각각을 그 하위 종류로 만드세요({first}: { subsetOf: <root>, members: […] }). 모든 참조는 여전히 자기 종류에 대해 검사됩니다.
E-PATH-IDENT. 뒤에 쓴 이름 세그먼트가 식별자가 아닙니다. 엔터티/enum 멤버가 다른 시스템의 id(vendor:reply)라면 프로바이더를 선언하고 속성을 { providerRef: <provider> }로 타입 지정하세요.
E-CONN-UNKNOWN-NODEvisited('…')나 after가 프로젝트에 없는 씬(0.24.0부터는 번들 비트도 가능)을 가리킵니다.
E-CLOCK-DECL0.24.0. 스키마의 clock:이 잘못되었습니다: day / slot이 선언되지 않았거나 타입이 틀렸거나 owner: engine이 아니거나(`day: run.day` must be declared `owner: engine`), slots가 슬롯 enum의 멤버가 아니거나 slot 없이 쓰였거나, 모르는 raise 계기(플러그인이 계기를 선언할 때)나 payload:가 있는 raise 계기(0.28.0)이거나, 두 번째 시계입니다.
E-SEASON-DECL0.27.0. seasons: 항목이 잘못되었거나(맵이 아님, live가 없거나 비어 있음, 알 수 없는 키, 잘못된 이름), 두 스키마가 한 시즌을 다르게 선언했거나, season.<name>.* 경로·once: season:<name>·tier="season:<name>"가 선언되지 않은 시즌을 가리킵니다. prev.season.*에 쓰면 prev.run.*처럼 E-QUEST-RESERVED-WRITE입니다.
E-TEMPLATE0.27.0. 비트 템플릿을 잘못 썼습니다: <beat use>가 가져오지 않은 컴포넌트(비슷한 이름 제안)나 beat: 머리가 없는 컴포넌트를 가리키거나, 머리에 모르는 키(id 포함)나 파라미터가 아닌 @name이 있거나, 일반 텍스트 머리 키에 식 인자가 들어가거나, ::body가 템플릿 최상위가 아닌 곳에 있습니다.
E-CHAPTERS0.28.0. lute.project.yaml의 chapters:가 { on, scenes } 사슬의 목록이 아니거나, 씬을 두 번 적었거나, 한 계기에 사슬을 둘 두었습니다. 또는 (프로젝트 전체 check) 사슬이 어떤 플러그인도 선언하지 않은 계기에 응답하거나, 어느 씬도 선언하지 않은 id(비슷한 id 제안)나 자기 on:으로 다른 계기에 응답하는 씬을 적었거나, 대상 있는 계기에서 target:이 없는 씬을 적었습니다. 그 밖에는 적용된 사슬의 각 씬이 직접 쓰지 않은 on:, 내려가는 priority:, 그리고 (select: sequence 계기가 아니면) after: visited("<앞 씬>")를 받습니다.
W-CHAPTER-STALL0.28.0. 사슬의 씬이 가진 자기 when:이 끝내 참이 되지 않을 수도 있는 상태를 읽는데, 다음 씬의 after:가 그 씬을 기다리므로 장이 거기서 멈출 수 있습니다. 시계만 읽는 when:은 그 창이 닫힐 때(계기의 이후 어떤 raise도 맞추지 못할 때)만 보고합니다.
W-CHAPTER-ORDER0.28.0. select: sequence 계기에서 사슬의 씬이 가진 자기 priority:가 목록 순서를 벗어나게 재생합니다.
E-ENUM-LABEL-NOT-MEMBER0.24.0. enum의 labels:가 그 멤버가 아닌 것을 가리킵니다.
E-CEL-TYPE0.24.0. %의 피연산자가 정수가 아닙니다: `%` takes two integers: `2.5` is not an integer.
E-RULE-GUARD-DEF0.24.0. 규칙의 cel() 가드가 없는 @def를 부르거나 인자 개수가 틀렸거나 $를 씁니다.
E-RESERVED-NAME0.28.0. 선언한 이름이 Lute가 스스로 쓰는 이름입니다. CEL 호출·키워드·규칙 단어(has, holds, visited, not, …)와 같은 관계 이름, entity 멤버나 def 이름인 state 루트(clock), 멤버나 id 이름인 unset/true/false/null, id 이름인 none, cast:의 narrator가 그렇습니다. 메시지가 대신 쓸 이름을 제시합니다(visited → wasAt). 전체 목록은 예약된 이름에 있습니다.
W-RELATION-UNREAD / W-DEF-UNUSED0.24.0, 프로젝트 전체 check, 선언 위치에서. 관계가 assert, 시드, 도출되지만 어떤 조건, 규칙 본문, def도 읽지 않거나, 어떤 콘텐츠, def, 규칙 가드도 @def를 참조하지 않습니다. 플레이 스크립트와 테스트는 읽기로 치지 않습니다.
E-PLUGIN-PARSE / E-PLUGIN-KEY플러그인 내보내기를 파싱할 수 없거나, (E-PLUGIN-KEY) 받지 않는 키가 있습니다(has no key `selct` — did you mean `select`?, 그 줄에서. 0.28.0부터 plugin.yaml도 검사합니다). E-PLUGIN-PARSE는 코어 훅이 아닌 lower: { kind: builtin }도 포함하며, 이어지는 E-PLUGIN-MISSING-ACTIVE는 플러그인을 불러오지 못했다고 알려 줍니다.
E-CONN-EPISODE-ID-DUP / E-QUEST-ID-DUP두 문서가 같은 씬 id나 퀘스트 id를 쓰거나, 번들 비트의 정식 <doc>.<beat> id가 씬 id와 같습니다.
E-DUP-VOICEKEY수식어를 뗀 텍스트가 다른 줄들이 같은 voiceKey로 컴파일됩니다. 내레이션과 속마음을 포함한 모든 줄을 프로젝트 전체에서 비교합니다. 대개 {speaker}-{code} 템플릿으로 고정했을 때입니다. 기본값 {prefix}.{speaker}-{code}를 쓰거나 줄마다 다른 code=를 주세요.
E-CAPABILITY-MISMATCH프로젝트의 문서들이 서로 다른 기능 스냅샷으로 해석되어(다른 프로필이나 씬별 plugins:) 하나로 컴파일할 수 없습니다.
E-TEST-LORE*.test.yaml이 entry:, entries:, beat: 없이 로어 문서를 가리킵니다. 제시할 것을 적으세요.
E-TEST-FILE*.test.yaml의 file:이 가리키는 문서가 없습니다. 그 테스트만 실패하고 나머지 스위트는 계속 실행됩니다.
E-TRACE-BEATlute trace --beat <id>가 문서의 어떤 번들 비트도 가리키지 않습니다(또는 로어 문서가 아닙니다). lute run --beat는 같은 경우를 종료 코드 2로 거부합니다.
W-REWARD-DOUBLE-CREDIT퀘스트의 <on>이나 목표 본문이 보상 종류가 이미 credits:로 지정한 경로를 ::set해서 두 번 지급됩니다. ::set이나 보상 중 하나를 지우세요.
W-FACT-GUARANTEED팩트 가드가 모든 경로에서 항상 참이라 불필요합니다.
W-BEAT-SHADOWED항상 자격이 있고 소진되지 않는 앞선 비트가 매번 이깁니다.
W-ENTRY-REF-UNKNOWNentry.<id>.read나 entry.<id>.everRead가 아무도 선언하지 않은 엔트리를 가리킵니다.
E-LEGACY-CONTENT-SIGIL · W-WHEN-TEST-LITERAL옛 문법입니다. lute fix가 고쳐 줍니다.
E-PERSIST-REMOVED선택지에서 persist=를 직접 지우세요. into=만으로 run 팩트가 기록됩니다.
E-INERT-TITLE본문에 # 제목이 있습니다. 문서 제목은 프론트매터 title:에 씁니다.
E-SECTION-DUP한 문서의 두 섹션이 같은 {#id}를 씁니다.
E-MONO-POV / E-MONO-NO-POV{mono} 줄의 화자가 유효 시점 인물도 아니고 monoSpeakers:에도 없거나, 시점 인물이 아예 정해지지 않았습니다. 메시지가 pov:나 monoSpeakers:에 적을 이름을 알려 줍니다. lute fix는 시점을 추측하지 않습니다.
E-CAMERA-EMPTY::camera에 focus, framing, move, transition이 하나도 없습니다.
E-MISSING-ATTR필수 속성이 빠졌습니다: ::actor{character}, ::cg{assetId}, ::video{assetId}, ::sequence{name} 등.
E-TEXT-MODIFIER / E-TEXT-ESCAPE인라인 수식어 형식이 잘못되었거나(닫히지 않은 스팬, 이름 없는 값, textStyle 멤버에 준 속성), 모르는 이스케이프입니다.
E-L10N-MODIFIERS번역의 수식어 묶음(이름, 잎/스팬 형태, 속성)이 원문과 다릅니다. 위치는 달라도 되지만, 빠지거나 더해지거나 바뀐 수식어가 있으면 그 번역은 병합되지 않습니다.
E-AUTHOR-CASE작가가 쓰는 키(속성, 태그 속성, 프론트매터, 프로젝트 키)에 _가 있습니다. 메시지가 lowerCamelCase 이름을 알려 줍니다.
E-REWARD-DUP한 퀘스트(목표의 보상 포함)의 두 <reward>가 같은 id=를 씁니다.
E-JUMP-BACKWARD / W-CODE-AFTER-JUMP::jump가 max 없이 앞쪽에 있지 않은 label을 가리키거나(0.51부터 max="N"이면 N번까지 허용), max 없는 가드 없는 ::jump 뒤에 실행될 수 없는 콘텐츠가 있습니다.
E-RENAMED-DIRECTIVE / E-RENAMED-ATTR / E-RENAMED-TAG-ATTR이 버전에 없는 디렉티브, 속성, 태그 속성 이름입니다. 메시지가 쓸 이름을 알려 주며, lute fix가 기계적으로 고칩니다.
E-REMOVED-ATTR / E-CAMERA-REMOVED / E-CG-LAYOUT그대로 옮길 수 없는 속성입니다. 메시지가 대신 쓸 도메인(framing, cameraMove, cgLayout 등)을 알려 주며, 손으로 고칩니다.
E-REMOVED-PROJECT-KEY / E-REMOVED-TAGlute.project.yaml의 sequence: 키(대체 키가 없습니다. 장 사슬은 chapters:)나 예약 태그 <scene>(문서의 섹션은 ##)입니다.

start가 없는 퀘스트는 스스로 활성화되지 않습니다. 수락형이라서, 씬이 ::accept{quest="id"}를 실행하거나, 엔진이 accept="external" 퀘스트를 수락하거나, 목이나 테스트가 accepts:에 적을 때까지 unset으로 남습니다(플레이 스크립트는 quests:로 상태를 시드할 수 있습니다). start가 있는 퀘스트에 ::accept를 쓰면 E-ACCEPT-TARGET입니다. 예외는 quest=로 지정된 자식으로, activate="accept"를 선언하지 않는 한 부모와 함께 활성화됩니다. 부모와 함께 활성화되는 자식을 ::accept해도 E-ACCEPT-TARGET입니다.

once는 씬과 엔트리에서 뜻이 다릅니다. 씬 비트의 기본값은 once: run이라서, once: false로 쓰지 않으면 런마다 최대 한 번 재생됩니다. once가 없는 엔트리는 반복됩니다. once="run"은 새 런이 entry.<id>.read를 초기화할 때까지 엔트리를 소진시키고, once="user"는 영구히 소진시킵니다 (entry.<id>.everRead). 기본값 once: run을 그대로 둔 비트의 when이 user 등급 상태만 읽으면 런마다 다시 재생됩니다(W-BEAT-ONCE-RUN-USER). 대개 once: user를 뜻한 것이며, once: run을 직접 쓰면 다시 재생되는 것이 의도임을 밝힙니다.

after:는 구조이고, 상태는 when:이 담당합니다. after:는 &&와 ||로 묶은 visited, completed, active만 읽으며 씬 그래프에 쓰입니다. 비트는 둘 다 성립할 때만 자격이 있습니다. <quest>의 follows=는 그래프 메타데이터일 뿐 활성화를 늦추지 않습니다. 퀘스트를 씬에 묶으려면 조건을 start에 넣으세요: start="visited('cafe.counter')".

::end는 lute play에서 자신의 제시만 끝냅니다. 플레이 전체가 끝나지는 않습니다. 그 스텝은 정산되고 플레이는 다음 스텝으로 이어집니다. 플레이를 일찍 멈추려면 - end: true 스텝을 추가하세요. 뒤의 스텝은 건너뜀으로 출력되고 플레이는 0으로 종료합니다. 게임을 끝내는 것은 스키마의 terminal:뿐이고(그 뒤로는 outsideRun: true 계기만 발생), 시계의 last:는 시계를 끝냅니다(clock.ended). 플레이는 어느 쪽이었는지 expect: { end: terminal }로 단언합니다. 에필로그는 terminal:을 성립시키는 쓰기 뒤나, 그것이 읽는 퀘스트의 questComplete 핸들러에 둡니다.

visited()는 세이브 전체에 걸칩니다. 새 런에서도 지워지지 않습니다. 단일 파일 check는 이를 판정하지 않고, 프로젝트 전체 check는 id를 검증하며, trace와 test에는 visited: 목록이 필요하고, 세이브에서 시작하는 플레이 스크립트는 최상위 visited:에 적습니다.

엔진 소유 상태는 콘텐츠가 아니라 하네스에서 옵니다. owner: engine 경로에 ::set을 쓰면 오류입니다. lute play에서는 engine: 스텝으로, trace와 test에서는 목의 state:로 쓰세요:

---
kind: scene
id: clock.cheat
state:
run.day: { type: int, default: 1, owner: engine }
---
## Night
::set{run.day += 1}
@narrator: The day ends.

퀘스트 상태도 엔진이 쓰는 값이 아닙니다: engine: 스텝은 quest.*를 거부합니다. 세이브의 퀘스트 상태는 스크립트 최상위 quests:로 시드하세요. 시계가 있으면(0.24.0) 시간은 advance: 스텝으로 옮기세요. clock.index를 뒤로 옮기는 engine: 스텝은 사용 오류입니다.

::bg 장면 전환은 모두를 무대에서 내립니다. 자동으로 숨겨진 캐릭터는 퇴장한 것으로 기록되므로, ::actor로 다시 등장시키기 전의 대사는 W-STAGE-ABSENT입니다. ::clear(0.24.0)도 같은 방식으로 모두를 퇴장시키되 배경은 그대로 두며, 이때 경고는 그 ::clear를 가리킵니다. ::bg의 경우:

---
kind: scene
id: dock.night
enums:
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
---
## Dock
::actor{character="mira" action="fadeInUp"}
@mira: Over here.
::bg{location="street" time="night"}
::actor{character="mira" action="fadeInUp"}
@mira: Keep walking.

{mono}는 시점 인물의 속마음입니다. 시점 인물(pov:, 없으면 프로젝트 defaults.pov)이 아닌 화자의 {mono}는 그 화자를 monoSpeakers:에 적어야 합니다. 아니면 대사, {os}, {vo}로 쓰세요:

---
kind: scene
id: diner.aside
pov: fixer
---
## Aside
@mira{mono}: He came back.

def의 타입은 본문에서 나옵니다. calm: "run.pressure < 2"는 bool입니다. 다른 def만 담은 본문 ("@calm")은 추론할 수 없고, params:가 있는 def에는 type:이 필요합니다.

: 가 들어가거나 !, @, {, [, *, &로 시작하는 YAML 값은 따옴표로 감싸세요. title: Chapter 1: Start는 파싱 오류이고, 따옴표 없는 when: !run.x는 부정이 아니라 YAML 태그입니다:

---
kind: scene
id: chapter.one
title: Chapter 1: Start
---
## Start
@narrator: Once upon a time.

scene.choices.<branch>는 branch가 고르면 설정됩니다(0.28.0). timeout이 없는 branch 뒤의 match에는 unset 갈래가 필요 없습니다. timeout은 고르지 않고 branch를 끝낼 수 있으니, 그때는 match에 is="unset" 갈래나 <otherwise>를 두세요:

---
kind: scene
id: choice.readback
---
## Ask
<branch id="ask" timeout="10" default="no">
<choice id="yes" text="Yes">
@mira: Great.
</choice>
<choice id="no" text="No">
@mira: Oh.
</choice>
</branch>
<match subject="scene.choices.ask">
<when is="yes">
@mira: You said yes.
</when>
<when is="no">
@mira: You said no.
</when>
</match>

단일 파일 명령은 모두 가장 가까운 프로젝트를 찾습니다(0.27.0). lute check, trace, compile, compile --stream, context는 --project가 없으면 파일 위쪽에서 가장 가까운 lute.project.yaml을 적용하고 stderr에 알립니다(lute: note: using project <dir> (nearest lute.project.yaml); pass --project to choose another). 0.27.0 전에는 check만 그렇게 했고, 다른 명령은 defaults:로 끌어올린 것과 플러그인이 선언한 것 없이 해석했습니다. lute test는 테스트 문서와 플레이 모두 --project나 가장 가까운 lute.project.yaml로 해석합니다. 프로젝트 전체 check, play, scenario는 --project로 넘겨받은 디렉터리(없으면 현재 디렉터리에서 찾은 가장 가까운 프로젝트)에서 프로젝트를 읽습니다. 위쪽에 매니페스트가 없으면 코어만으로 해석합니다.

trace, test, play는 도출합니다: 결론이 아니라 전제를 목으로 주세요. 스키마의 facts: 시드를 불러오고, 목으로 준 팩트와 assert된 팩트 위에 Datalog 규칙을 적용합니다. lute run과 같습니다. 도출된 atom을 목으로 주어도 시드 하나로 받아들여집니다. 목으로 주지 않은 기본 팩트는 거짓입니다. trace가 판정할 수 없는 상태에 걸린 규칙 가드는 결론을 unknown으로 남기고 워크를 멈춥니다(종료 코드 3). lute test에서는 expect: { end: incomplete }를 선언하지 않은 테스트가 이때 실패합니다. derive: false(목, 테스트, 플레이 스크립트 키)나 --no-derive는 0.21의 명시적 세계로 되돌립니다: 시드가 없고, 목으로 주지 않은 도출 atom은 unknown입니다.

디렉티브 속성에는 상수 def를 따옴표 없이 참조합니다. ::camera{framing="wide" duration=@holdTime}처럼 쓰세요. 따옴표 친 duration="@holdTime"은 문자열 @holdTime 그대로라서 숫자 속성에서는 E-ATTR-TYPE입니다. 상태를 읽는 def는 E-ATTR-DEF-DYNAMIC입니다.

속성 값은 큰따옴표로 감쌉니다. text='"Hi."'는 E-ATTR-QUOTE입니다. text="\"Hi.\""로 쓰면 선택지 텍스트는 "Hi."가 됩니다. 워드 프로세서에서 붙여 넣은 둥근 따옴표(text=“Hi”)도 E-ATTR-QUOTE이니 곧은 "로 다시 치세요.

<match>에서 숫자는 실수입니다. is="1..9" 다음에 is="10.."를 써도 9.5는 다뤄지지 않습니다. <otherwise>를 두거나, 끝이 맞닿는 열린 범위를 쓰세요.

0.23.0부터 검사기는 더 많은 조건이 거짓임을 증명합니다. && 전체에 걸쳐 경로별로 추론하므로 run.n > 5 && run.n < 3, run.slot == 'a' && run.slot == 'b', x && !x는 결코 성립하지 않고, 경우들이 경로의 도메인을 모두 덮는 ||는 항상 성립합니다. 0.22에서 깨끗하게 검사되던 프로젝트가 이제 E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, E-ARM-DEAD, E-OBJECTIVE-UNSATISFIABLE, W-BEAT-PRIORITY-TIE를 보고할 수 있습니다. 실제 모순이니 조건을 고치세요. unset도 하나의 값으로 치므로, 기본값 없는 경로에서 run.m > 5 && run.m < 3은 판정되지 않고 has(run.m) && run.m > 5 && run.m < 3은 거짓입니다.

---
kind: scene
id: late.shift
on: townVisit
when: "run.day > 5 && run.day < 3"
state:
run.day: { type: int, default: 1 }
---
## Late
@mira: You're early.

lute trace는 실행된 ::jump를 따라갑니다(0.26.0). 0.26 전에는 트레이스가 점프를 보고한 뒤 끝났기 때문에, label 뒤의 콘텐츠에 닿지 않은 워크로도 테스트가 end: complete로 통과할 수 있었습니다. 이제 trace와 test는 lute play, lute run처럼 label에서 이어 가고(트랜스크립트의 <jump -> outro>), end: complete는 워크가 본문 끝에 닿았다는 뜻입니다.

자격 없는 비트를 걷는 테스트는 실패합니다(0.26.0). trace는 여전히 when이 거짓인 엔트리나 비트를 제시하지만, lute test는 eligible:을 단언하지 않은 그런 테스트를 실패로 판정하고 거짓인 전제(when, after:와 그것에 필요한 visited: / quests: 목, 소진된 once: user)를 알려 줍니다. 목을 고치세요. eligible: false는 그것이 요점일 때만 단언하세요.

상태 경로는 두 프론트매터가 아니라 스키마로 나눠 쓰세요. 두 문서가 각자 state:에 run.tide를 선언하면 타입, 기본값, per, owner가 같아야 합니다(E-STATE-DECL-CONFLICT, 0.26.0). 두 문서가 모두 가져오는 스키마에 한 번만 선언하세요. 여러 작가가 쓰는 프로젝트라면 여러 작가, 한 프로젝트를 보세요.

by=는 시점이고 until=은 장소입니다(0.24.0). by= 기한은 매 정산마다 판정하므로, 목표의 계기로 돌아가지 않는 플레이어도 기한을 놓칩니다. 0.23.1은 on= 목표의 by를 그 계기가 발생할 때만 판정했습니다. 장소에 묶인 기한이 필요하면 until=을 쓰세요(on=이 필요합니다). 옛 규칙에 맞춰 쓴, done이 by를 함의하는 on= 목표(done="run.v == 'fell'" by="run.v != 'undecided'")는 이제 계기를 발생시키는 스텝에서 둘이 함께 성립하지 않는 한 실패합니다. W-DEADLINE-BEFORE-DONE이 이를 알리고 같은 조건의 until=을 제안합니다.

::set{… when="…"}은 확정 대입이 아닙니다. 쓰기가 일어나지 않을 수 있으므로, 기본값이 없는 경로를 나중에 읽으면 여전히 값이 없을 수 있습니다:

---
kind: scene
id: tab.open
state:
run.day: { type: int, default: 1 }
run.tab: { type: int }
---
## Tab
::set{run.tab = 0 when="run.day > 1"}
@mira: Your tab is {{run.tab}}.

%에는 정수가 필요합니다. 두 피연산자 모두 정수여야 하며, 소수 리터럴이나 숫자가 아닌 피연산자는 E-CEL-TYPE입니다. 실행 중에는 소수 값이나 0으로 나누기가 알 수 없음으로 읽힙니다.

---
kind: scene
id: week.end
state:
run.hours: { type: int, default: 0 }
---
## Payday
@mira{when="run.hours % 7.5 == 0"}: Payday.

_는 규칙 본문에만 씁니다. 본문에서는 새 변수입니다. 머리의 _는 E-DATALOG-PARSE인데, 머리의 모든 인자는 묶인 변수나 상수여야 하기 때문입니다:

---
kind: scene
id: ledger.rule
entities:
person: { members: [ada, hollis] }
relations:
owes: { args: [person, person], tier: run }
debtor: { args: [person, person], derive: true }
rules:
- "debtor(D, _) :- owes(D, _)"
---
## Ledger
@narrator{when="holds('debtor', ['ada', 'hollis'])"}: Ada owes.

transcriptContains는 재생된 줄만 봅니다. 0.24.0부터 lute play와 lute test 모두 제시된 콘텐츠 줄을 한 줄에 @speaker: text 하나로 비교합니다. 건너뛴 가드 줄(skip @x "…" — when: false), 스텝 머리글, 후보 목록, 연출은 결코 맞지 않으므로, 건너뛴 줄 덕분에 통과하던 transcriptContains는 이제 제대로 실패합니다.

대상 없는 계기에서 엔트리의 target=은 메타데이터입니다. 엔트리는 어떤 대상을 적었든 그 계기의 모든 발생에 응답합니다. 대상에 묶고 싶다면 계기에 target: 도메인을 주세요. 씬이나 번들 비트의 target은 거기서 여전히 E-BEAT-ATTR입니다.

응답 없는 브리지는 멈춥니다. 기본값으로 채우지 않습니다. 뒤의 가드가 결과를 읽는 플러그인 호출에는 bridges: 응답이 필요합니다. 응답이 없으면 lute play는 기본 갈래로 가기 전에 그 호출에서 멈추고(종료 코드 3), lute trace / lute test는 결과를 알 수 없음으로 읽어, 그대로 쓸 수 있는 답인 bridges: { check: [ { passed: <bool>, margin: <int> } ] } 힌트와 함께 미완료로 멈춥니다. 예전에는 상태 모양의 기본값을 읽었습니다. 결과 슬롯을 시드하던 0.23.1 테스트나 목(state: { scene.check.guards.passed: true })은 더 이상 가드를 정하지 못하니, 그 시드를 bridges: { check: [ { passed: true, margin: 3 } ] }로 바꾸세요.

플러그인 내보내기의 오타는 이제 오류입니다. { selct: all }은 예전에 select: first로 불러와졌지만, 0.24.0부터 모든 내보내기 파일은 모르는 키를 거부하며(비슷한 이름을 제안하는 E-PLUGIN-PARSE), 코어 훅이 아닌 lower: { kind: builtin }도 마찬가지입니다. 일반 패스스루가 필요하면 lower:를 빼세요.

단일 파일 검사의 W-CAST-ABSENT는 오경보일 수 있습니다. lute check는 줄을 감싼 가드만 봅니다. 모든 경로에서 앞서 실행된 ::assert{inParty(isolde)}나 아무도 retract하지 않는 시드가 있으면 프로젝트 전체 check는 모든 경로에서 성립하는 팩트를 세므로 @isolde: …가 깨끗합니다. 단일 파일 검사는 그럴 수 없으며, 메시지가 그 사실을 알려 줍니다.

에이전트는 사람이 읽는 출력을 파싱하지 말고 JSON 보고서 봉투를 사용해야 합니다. JSON 보고서는 stdout에 command, schemaVersion, ok를 포함한 객체 하나를 출력하며, ok는 프로세스 종료 코드가 0일 때만 true입니다. LUTE_TELEMETRY=0 target/debug/lute schema check의 실제 출력은 schemaVersion: "0.48.0.schema"이며 --json 검사 보고서(0.48.0.check)의 인자와 출력을 설명합니다. lute schema는 항상 JSON이며 인자, 옵션, 출력, inputs(lute schema patch의 FILE 요청, 다른 명령은 빈 목록)를 나열합니다.

JSON 모드의 사용법·I/O·입력 오류도 객체 하나입니다. error.code는 E-CLI-USAGE, E-CLI-IO, E-CLI-INPUT 중 하나이며 가능한 경우 error.path, error.argument, error.hint가 들어갑니다. CLI 참고서의 기계 판독 출력에서 안정적인 필드를 확인하세요.

텔레메트리는 로컬에서만 기록되며 선택 해제할 수 있습니다. 결정적인 예제와 CI에서는 LUTE_TELEMETRY=0(또는 DO_NOT_TRACK=1)을 설정하세요. 명령 출력은 바뀌지 않습니다.