narraeon 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
3
3
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
4
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
5
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
6
+ import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withCurrentPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
7
  import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -457,8 +457,8 @@ export class FileNativePlayPresetStore {
457
457
  .map((path) => ({ path, contents: binding.files[path] }));
458
458
  }
459
459
  async importPortable(input) {
460
- // A released preset gains the tabletop builtins at import, as at upgrade.
461
- const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
460
+ // A released preset gains the current builtins at import, as at upgrade.
461
+ const files = withCurrentPromptBuiltins(toFileMap(input.files, this.#limits));
462
462
  const parsed = parsePlayPresetFiles(files, this.#limits);
463
463
  if (parsed.kind === "invalid")
464
464
  throw parsed.error;
@@ -568,8 +568,8 @@ export class FileNativePlayPresetStore {
568
568
  return true;
569
569
  }
570
570
  /**
571
- * One-time upgrade for ordered presets saved before the tabletop builtins
572
- * existed. An affected revision is replaced by a new revision that inserts
571
+ * One-time upgrade for ordered presets saved before the tabletop or
572
+ * continuation builtins existed. An affected revision is replaced by a new revision that inserts
573
573
  * them; the old revision stays for frozen requests. A pending draft gets the
574
574
  * same insertion, because a draft that kept the old list would fail the
575
575
  * builtin-retention rule on save. The library is one atomically written file,
@@ -580,7 +580,7 @@ export class FileNativePlayPresetStore {
580
580
  for (const stored of document.presets) {
581
581
  const current = stored.revisions[stored.currentRevision];
582
582
  if (current !== undefined) {
583
- const migrated = withTabletopPromptBuiltins(current);
583
+ const migrated = withCurrentPromptBuiltins(current);
584
584
  if (migrated !== current) {
585
585
  const revision = revisionForFiles(migrated, this.#limits);
586
586
  stored.revisions[revision] = cloneFiles(migrated);
@@ -592,7 +592,7 @@ export class FileNativePlayPresetStore {
592
592
  ? undefined
593
593
  : stored.revisions[stored.draftRevision];
594
594
  if (draft !== undefined) {
595
- const migrated = withTabletopPromptBuiltins(draft);
595
+ const migrated = withCurrentPromptBuiltins(draft);
596
596
  if (migrated !== draft) {
597
597
  const revision = revisionForFiles(migrated, this.#limits);
598
598
  stored.revisions[revision] = cloneFiles(migrated);
@@ -118,6 +118,81 @@ export function withTabletopPromptBuiltins(files) {
118
118
  }
119
119
  return changed ? { ...files, "preset.yaml": document.toString() } : files;
120
120
  }
121
+ const alternativeGroups = () => [
122
+ {
123
+ anchor: "play.style",
124
+ before: [
125
+ "play.adjudication",
126
+ "play.state",
127
+ "play.narrative",
128
+ "play.continue-stage",
129
+ "play.tabletop",
130
+ ],
131
+ entries: personPromptEntries(),
132
+ },
133
+ {
134
+ anchor: "play.narrative",
135
+ before: ["play.tabletop"],
136
+ entries: continuationPromptEntries(),
137
+ },
138
+ ];
139
+ /**
140
+ * Insert the narrative-person and continuation builtins that an ordered preset
141
+ * does not list yet. Same identity contract as the tabletop insertion: files
142
+ * that are not ordered v2 or do not parse come back unchanged, and the same
143
+ * object is returned when nothing needs inserting.
144
+ */
145
+ export function withAlternativePromptBuiltins(files) {
146
+ const source = files["preset.yaml"];
147
+ if (source === undefined)
148
+ return files;
149
+ const document = parseDocument(source, { uniqueKeys: true });
150
+ if (document.errors.length > 0 ||
151
+ document.warnings.length > 0 ||
152
+ !isMap(document.contents) ||
153
+ document.get("format") !== "narraeon.play-preset/v2")
154
+ return files;
155
+ const playPrompts = document.get("playPrompts", true);
156
+ if (!isSeq(playPrompts))
157
+ return files;
158
+ let changed = false;
159
+ for (const group of alternativeGroups()) {
160
+ if (group.entries.some((entry) => hasEntry(playPrompts, entry.id)))
161
+ continue;
162
+ const anchor = playPrompts.items.findIndex((item) => entryField(item, "id") === group.anchor);
163
+ const fallback = playPrompts.items.findIndex((item) => {
164
+ const id = entryField(item, "id");
165
+ return ((typeof id === "string" && group.before.includes(id)) ||
166
+ entryField(item, "kind") === "world");
167
+ });
168
+ const at = anchor >= 0
169
+ ? anchor + 1
170
+ : fallback >= 0
171
+ ? fallback
172
+ : playPrompts.items.length;
173
+ playPrompts.items.splice(at, 0, ...group.entries.map((entry) => document.createNode(entry)));
174
+ changed = true;
175
+ }
176
+ return changed ? { ...files, "preset.yaml": document.toString() } : files;
177
+ }
178
+ /** Every builtin insertion a stored or imported ordered preset must receive. */
179
+ export function withCurrentPromptBuiltins(files) {
180
+ return withAlternativePromptBuiltins(withTabletopPromptBuiltins(files));
181
+ }
182
+ function alternativeEntries(prefix, ids, enabledId) {
183
+ return ids.map((id) => ({
184
+ id: `${prefix}${id}`,
185
+ kind: "builtin",
186
+ builtin: `${prefix}${id}`,
187
+ enabled: id === enabledId,
188
+ }));
189
+ }
190
+ function personPromptEntries() {
191
+ return alternativeEntries("play.person-", ["second", "first", "third"], "second");
192
+ }
193
+ function continuationPromptEntries() {
194
+ return alternativeEntries("play.continue-", ["stage", "advance"], "stage");
195
+ }
121
196
  function hasEntry(sequence, id) {
122
197
  return sequence.items.some((item) => entryField(item, "id") === id);
123
198
  }
@@ -239,9 +314,12 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
239
314
  if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
240
315
  path.endsWith(".md"))
241
316
  add(path, false);
242
- // The tabletop builtin joins converted presets just before the world placeholder.
317
+ // Converted presets gain the person and continuation builtins and then the
318
+ // tabletop builtin just before the world placeholder. The legacy style and
319
+ // narrative copies keep their own wording as user text; the current builtins
320
+ // arrive beside them.
243
321
  const worldIndex = entries.findIndex((entry) => entry.kind === "world");
244
- entries.splice(worldIndex, 0, {
322
+ entries.splice(worldIndex, 0, ...personPromptEntries(), ...continuationPromptEntries(), {
245
323
  id: "play.tabletop",
246
324
  kind: "builtin",
247
325
  builtin: "play.tabletop",
@@ -66,6 +66,15 @@ export class FileNativePlayDocuments {
66
66
  // Creation capabilities come from this prefix, not past directory slots.
67
67
  this.#declaredDirectories = declaredStateDirectories(bootstrap);
68
68
  }
69
+ /**
70
+ * Re-grant one exact read the model already completed in a retained
71
+ * transcript. The scope is resolved against the current snapshot again, so a
72
+ * document or node that no longer exists grants nothing. Returns whether the
73
+ * scope resolved.
74
+ */
75
+ replayRead(shortRef, locator) {
76
+ return authorizeResolvedRead(this.#reads, this.#candidate.snapshot, shortRef, locator);
77
+ }
69
78
  execute(call, history, options = {}) {
70
79
  let historyQuery;
71
80
  const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
@@ -156,26 +165,31 @@ function bootstrapAuthorizations(bootstrap, snapshot) {
156
165
  for (const { status, complete, readAuthorization } of bootstrap.coverage) {
157
166
  if (status !== "resolved" || !complete || readAuthorization === undefined)
158
167
  continue;
159
- if (readAuthorization.locator === null) {
160
- const resolved = snapshot.query({
161
- kind: "read_document",
162
- document: { shortRef: readAuthorization.shortRef },
163
- });
164
- if (resolved.kind === "read_document" && resolved.ok)
165
- authorizeRead(result, snapshot.id, resolved.document.shortRef, null);
166
- }
167
- else {
168
- const selected = snapshot.query({
169
- kind: "select_node",
170
- document: { shortRef: readAuthorization.shortRef },
171
- locator: readAuthorization.locator,
172
- });
173
- if (selected.kind === "select_node" && selected.ok)
174
- authorizeRead(result, snapshot.id, selected.document.shortRef, selected.node.locator);
175
- }
168
+ authorizeResolvedRead(result, snapshot, readAuthorization.shortRef, readAuthorization.locator);
176
169
  }
177
170
  return result;
178
171
  }
172
+ function authorizeResolvedRead(authorizations, snapshot, shortRef, locator) {
173
+ if (locator === null) {
174
+ const resolved = snapshot.query({
175
+ kind: "read_document",
176
+ document: { shortRef },
177
+ });
178
+ if (resolved.kind !== "read_document" || !resolved.ok)
179
+ return false;
180
+ authorizeRead(authorizations, snapshot.id, resolved.document.shortRef, null);
181
+ return true;
182
+ }
183
+ const selected = snapshot.query({
184
+ kind: "select_node",
185
+ document: { shortRef },
186
+ locator,
187
+ });
188
+ if (selected.kind !== "select_node" || !selected.ok)
189
+ return false;
190
+ authorizeRead(authorizations, snapshot.id, selected.document.shortRef, selected.node.locator);
191
+ return true;
192
+ }
179
193
  function declaredStateDirectories(bootstrap) {
180
194
  // Catalog coverage is the durable compiler projection shared by current
181
195
  // state_list contexts and legacy context_list contexts.
@@ -131,16 +131,64 @@ function assembleRetainedTrace(source, endpoint, selection) {
131
131
  export function restorePlayDocuments(files, context, events) {
132
132
  const documents = new FileNativePlayDocuments(files);
133
133
  try {
134
- // Read authorizations are memory-only: a restored prefix always starts from
135
- // its bootstrap, and stale write authority is caught by the commit's hash.
134
+ // Read authorizations are memory-only (ADR-0044). A restored prefix starts
135
+ // from its bootstrap, then replays the exact reads the retained transcript
136
+ // still shows the model, so it is not asked to read again what it has
137
+ // already seen. Stale write authority is caught by the commit's hash.
136
138
  const runs = playPromptRunsThroughEvents(context, events);
137
139
  documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
140
+ for (const read of completedReadsThroughEvents(context, events))
141
+ documents.replayRead(read.shortRef, read.locator);
138
142
  }
139
143
  catch (error) {
140
144
  throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
141
145
  }
142
146
  return documents;
143
147
  }
148
+ /**
149
+ * The exact document scopes successfully read by context_read within the
150
+ * selected event prefix, in event order. Tool results are keyed by exchange and
151
+ * call ID, and the exchange is the one of the nearest preceding assistant event.
152
+ */
153
+ function completedReadsThroughEvents(context, events) {
154
+ const completed = new Map(context.completedTools.map((tool) => [tool.key, tool.result]));
155
+ const reads = [];
156
+ let exchange = 0;
157
+ for (const event of events) {
158
+ if (event.kind === "assistant") {
159
+ exchange = event.exchange;
160
+ continue;
161
+ }
162
+ if (event.kind !== "tool_result" || !event.ok)
163
+ continue;
164
+ const result = completed.get(`${exchange}:${event.callId}`);
165
+ const authorization = result?.readAuthorization;
166
+ if (!result?.ok || !isReplayableReadAuthorization(authorization))
167
+ continue;
168
+ reads.push({
169
+ shortRef: authorization.shortRef,
170
+ locator: structuredClone(authorization.locator),
171
+ });
172
+ }
173
+ return reads;
174
+ }
175
+ function isReplayableReadAuthorization(value) {
176
+ if (typeof value !== "object" || value === null)
177
+ return false;
178
+ const { shortRef, locator } = value;
179
+ if (typeof shortRef !== "string")
180
+ return false;
181
+ if (locator === null)
182
+ return true;
183
+ if (typeof locator !== "object" || locator === null)
184
+ return false;
185
+ const { yaml, markdown } = locator;
186
+ if (Array.isArray(yaml))
187
+ return yaml.every((segment) => typeof segment === "string" ||
188
+ (Number.isSafeInteger(segment) && Number(segment) >= 0));
189
+ return (Array.isArray(markdown) &&
190
+ markdown.every((segment) => typeof segment === "string"));
191
+ }
144
192
  export function independentContextCopy(source) {
145
193
  const context = structuredClone(source);
146
194
  context.continuityContextId ??= source.chainId;
@@ -11,7 +11,7 @@ export const defaultNarrationPromptZhCN = `# 玩家可见叙事规则
11
11
 
12
12
  ## 承接与表现
13
13
 
14
- 有新玩家输入时,把其中明确行动或台词自然接入场景,补足不改变选择的表达细节,再写相应反应。已经演过的部分不用重述,不以“你说要……”机械复读。原生对话末尾已有主持正文、其后没有新玩家输入时,本次是空输入续写:从那段正文的最后局面继续,只推进既有授权动作、NPC 和环境。不重新回答已经回应的问题,不复位已完成的递接或走动,不把旧玩家输入再演一次,也不替玩家增加决定。
14
+ 有新玩家输入时,先按承接规则处理玩家这次的行动或台词,再写相应反应;承接补足的细节不改变玩家的选择。此前轮次已经演过的部分不再重述,“你说要……”式的旁白转述不算承接。原生对话末尾已有主持正文、其后没有新玩家输入时,本次是空输入续写:从那段正文的最后局面继续,只推进既有授权动作、NPC 和环境。不重新回答已经回应的问题,不复位已完成的递接或走动,不把旧玩家输入再演一次,也不替玩家增加决定。
15
15
 
16
16
  本轮新发生且玩家能感知的结果,要成为具体可读的事:物品怎样递接、人物如何离开、态度变化怎样在话语或行动中显露。文档中补录旧结果、去重和整理不要求再次演出。界面数值不能代替事件表现,叙事也不能与当前值冲突。未向玩家表露的判断和场外事实无需用旁白揭露。
17
17
 
@@ -39,7 +39,7 @@ For in-world play, write the body of an interactive novel so the player experien
39
39
 
40
40
  ## Continuation and dramatization
41
41
 
42
- When there is new player input, naturally stage its explicit action or dialogue with details that preserve the choice, then its reactions. Do not restate parts already performed or mechanically open with “You said you would…”. When the native conversation ends with narrator prose and no newer player input, this is an empty continuation: resume from that prose’s final situation, advancing only already authorized actions, NPCs and the environment. Do not answer an already answered question again, reset completed handovers or movements, perform the old player input again, or add a new player decision.
42
+ When there is new player input, first handle this turn's action or dialogue according to the continuation rules, then write the reactions; details added in continuation must preserve the player's choice. Do not restate parts already performed in earlier turns, and a narrated summary such as “You said you would…” does not count as continuation. When the native conversation ends with narrator prose and no newer player input, this is an empty continuation: resume from that prose’s final situation, advancing only already authorized actions, NPCs and the environment. Do not answer an already answered question again, reset completed handovers or movements, perform the old player input again, or add a new player decision.
43
43
 
44
44
  Make new perceivable outcomes this turn concrete: an object handed over, a person leaving, an attitude expressed through words or action. Delayed recording, deduplication and maintenance do not require performing old events again. Interface values do not replace dramatization, and prose must agree with current values. Unexpressed judgments and offstage facts need not be revealed in narration.
45
45
 
@@ -22,7 +22,7 @@ roles:
22
22
  `,
23
23
  "blocks/style.md": `# 通用叙事呈现
24
24
 
25
- 这是一部互动式小说,玩家是其中的主角。玩家可见叙事以第二人称“你”称呼玩家角色,保持已经建立的观察视角,只写虚构世界中的体验。
25
+ 这是一部互动式小说,玩家是其中的主角。称呼玩家角色的人称按人称规则执行,保持已经建立的观察视角,只写虚构世界中的体验。
26
26
 
27
27
  ## 写出小说的质感
28
28
 
@@ -39,6 +39,18 @@ roles:
39
39
  ## 人物与场面
40
40
 
41
41
  用正在进行的事呈现场面,不把所有在场者按名字点一遍。人物之间可以交谈、忙自己的事;细节按场景需要选取。名字已知就自然使用名字,避免不断重新介绍。文风负责表现,不能改变已经成立的结果。
42
+ `,
43
+ "blocks/person-second.md": `# 人称:第二人称
44
+
45
+ 玩家可见叙事以第二人称“你”称呼玩家角色。玩家的知觉、动作和话语都以“你”写出;其他人物按名字或第三人称称呼。观察视角跟随玩家角色,不写他无法感知的事。
46
+ `,
47
+ "blocks/person-first.md": `# 人称:第一人称
48
+
49
+ 玩家可见叙事以第一人称“我”写玩家角色,像玩家角色自己在讲述。知觉、判断和情绪只能是“我”能感知到的;其他人物按名字或第三人称称呼。叙述口吻贴近玩家角色已经建立的性格,不替他下结论或表态。本块启用时取代第二人称的要求。
50
+ `,
51
+ "blocks/person-third.md": `# 人称:第三人称
52
+
53
+ 玩家可见叙事以第三人称写玩家角色:已知名字时用名字,否则用与其性别一致的“他/她”,并与其他人物明确区分。观察视角仍跟随玩家角色,不越入其他人物未表露的内心。玩家原文里的“我”“你”在正文中转为相应的第三人称。本块启用时取代第二人称的要求。
42
54
  `,
43
55
  "blocks/style-intimate.md": `# 文风:情感与亲密
44
56
 
@@ -181,6 +193,34 @@ NPC 的回答可以短、冷、有停顿;对话的紧张不要求玩家连续
181
193
  ## 节制
182
194
 
183
195
  比喻少而准,按这段文字的需要取舍。不要连续堆叠形容词。删掉之后意思不变的句子就删掉。
196
+ `,
197
+ "blocks/continue-stage.md": `# 承接:先演玩家行动
198
+
199
+ 玩家原文是玩家对本轮行动和台词的简写,不是已经成立的小说正文。玩家页面上它以“你”的身份单独显示,正文里还没有它。
200
+
201
+ ## 先写玩家做了什么
202
+
203
+ 每段正文先把玩家这次的行动和台词以叙事方式写出来:动作怎样完成、话怎样说出口、周围的人和环境此刻的样子,然后才写世界的反应。台词可以保留原话,也可以按人物口吻微调措辞,不改变意思、立场和承诺。不用“你说要……”式的旁白转述代替演出。
204
+
205
+ ## 不改变选择
206
+
207
+ 承接只补足表达细节,不替玩家增加目标、请求、同意或新的决定;结果仍由裁决和当前事实决定,尝试不自动成功,声称不自动成真。
208
+
209
+ ## 分量
210
+
211
+ 玩家行动本身的篇幅按文风的节奏规则定:平常动作一两句,关键时刻可以拆开写,但不能跳过。要演的是本轮新输入;此前轮次已经演过的内容不再重述。
212
+ `,
213
+ "blocks/continue-advance.md": `# 承接:直接推进
214
+
215
+ 玩家原文已经是正文的一部分:玩家页面上它以“你”的身份显示,玩家刚读过。不要把它再演一遍。本块启用时取代“先演玩家行动”的要求。
216
+
217
+ ## 从下一件事写起
218
+
219
+ 正文从玩家输入之后发生的第一件事开始:动作的结果、对方的回应、环境的变化。玩家的台词不再复述,直接写听者如何接话。
220
+
221
+ ## 必要的衔接
222
+
223
+ 输入只是简写(“我去找他”)、不交代过程就无法呈现结果时,用最短的衔接把它落到场景里,不扩写玩家的动作和话语本身。结果仍由裁决和当前事实决定,尝试不自动成功。
184
224
  `,
185
225
  "blocks/adjudication.md": `# 通用玩家代理权与裁判底线
186
226
 
@@ -282,7 +322,7 @@ roles:
282
322
  `,
283
323
  "blocks/style.md": `# General narrative presentation
284
324
 
285
- This is an interactive novel with the player as its protagonist. Address the player character as “you”, preserve the established viewpoint, and describe only experiences within the fictional world.
325
+ This is an interactive novel with the player as its protagonist. Refer to the player character in the person set by the narrative-person rules, preserve the established viewpoint, and describe only experiences within the fictional world.
286
326
 
287
327
  ## Give the prose the texture of a novel
288
328
 
@@ -299,6 +339,18 @@ Detail adds information: how an action unfolds, a sound changes or attention shi
299
339
  ## People and scenes
300
340
 
301
341
  Show a scene through ongoing activity. Do not inventory everyone by name. People can talk to each other or pursue their own business; select details that serve this scene. Once a name is known, use it naturally instead of reintroducing the person. Style shapes expression without changing established outcomes.
342
+ `,
343
+ "blocks/person-second.md": `# Person: second person
344
+
345
+ Address the player character as “you” in all player-visible narrative. Their perceptions, actions and words are written as “you”; other characters are named or referred to in the third person. The viewpoint follows the player character and does not describe what they cannot perceive.
346
+ `,
347
+ "blocks/person-first.md": `# Person: first person
348
+
349
+ Write the player character in the first person, “I”, as if the player character were telling the story. Perceptions, judgments and feelings are limited to what “I” can perceive; other characters are named or referred to in the third person. Keep the voice close to the player character's established personality without drawing conclusions or taking stances on their behalf. When this block is enabled it replaces the second-person requirement.
350
+ `,
351
+ "blocks/person-third.md": `# Person: third person
352
+
353
+ Write the player character in the third person: by name when known, otherwise with a pronoun that matches their gender, clearly distinguished from other characters. The viewpoint still follows the player character and does not enter other characters' unexpressed thoughts. “I” and “you” in the player's text become the corresponding third person in the prose. When this block is enabled it replaces the second-person requirement.
302
354
  `,
303
355
  "blocks/style-intimate.md": `# Style: emotion and intimacy
304
356
 
@@ -441,6 +493,34 @@ People rarely state their inner thoughts. Give dialogue a surface and an undersi
441
493
  ## Restraint
442
494
 
443
495
  Use few metaphors and make them exact, according to the passage’s needs. Do not stack adjectives. Delete any sentence whose removal changes nothing.
496
+ `,
497
+ "blocks/continue-stage.md": `# Continuation: stage the player's action first
498
+
499
+ The player's text is shorthand for this turn's action and dialogue, not finished prose of the novel. The player page shows it separately under “you”; the narrative does not contain it yet.
500
+
501
+ ## Write what the player does first
502
+
503
+ Each passage first stages the player's action and dialogue as narrative: how the movement is carried out, how the words come out, what the people and surroundings look like at that moment. Only then write the world's reaction. Dialogue may keep the original words or be lightly adjusted to the character's voice without changing its meaning, stance or commitments. Do not replace the staging with a narrated summary such as “You said you would…”.
504
+
505
+ ## Preserve the choice
506
+
507
+ Staging only adds expressive detail. It must not add goals, requests, consent or new decisions for the player. Outcomes still follow adjudication and current facts: attempts are not automatically successes, and claims are not automatically true.
508
+
509
+ ## Weight
510
+
511
+ The length of the player's own action follows the style's pacing rules: an ordinary action takes a sentence or two, a critical moment may be unfolded, but it is never skipped. Stage this turn's new input only; content already performed in earlier turns is not restated.
512
+ `,
513
+ "blocks/continue-advance.md": `# Continuation: advance directly
514
+
515
+ The player's text is already part of the narrative: the player page shows it under “you” and the player has just read it. Do not perform it again. When this block is enabled it replaces the requirement to stage the player's action first.
516
+
517
+ ## Start with the next thing
518
+
519
+ Begin the passage with the first thing that happens after the player's input: the result of the action, the other person's reply, a change in the surroundings. Do not repeat the player's dialogue; write directly how the listener responds.
520
+
521
+ ## Minimal bridging
522
+
523
+ When the input is shorthand (“I go find him”) and the result cannot be shown without some process, use the shortest bridge that places it in the scene, without expanding the player's own action or words. Outcomes still follow adjudication and current facts; attempts are not automatically successes.
444
524
  `,
445
525
  "blocks/adjudication.md": `# General player agency and adjudication
446
526
 
@@ -57,6 +57,15 @@ ${historyUseGuidance(locale)}`,
57
57
  body: host["blocks/style.md"],
58
58
  required: false,
59
59
  },
60
+ // Narrative-person blocks are alternatives like the continuation blocks:
61
+ // second person ships enabled, first and third disabled.
62
+ ...["second", "first", "third"].map((id) => ({
63
+ id: `play.person-${id}`,
64
+ name: /^#\s+(.+)$/mu.exec(host[`blocks/person-${id}.md`])?.[1] ??
65
+ `blocks/person-${id}.md`,
66
+ body: host[`blocks/person-${id}.md`],
67
+ required: false,
68
+ })),
60
69
  ...["adjudication", "state"].map((id) => ({
61
70
  id: `play.${id}`,
62
71
  name: zh
@@ -75,6 +84,16 @@ ${historyUseGuidance(locale)}`,
75
84
  body: defaultNarrationPromptForLocale(locale),
76
85
  required: false,
77
86
  },
87
+ // Continuation blocks decide how this turn's player input enters the
88
+ // prose. They are alternatives, not layers: staging ships enabled and
89
+ // direct advancement disabled, and the player may swap them.
90
+ ...["stage", "advance"].map((id) => ({
91
+ id: `play.continue-${id}`,
92
+ name: /^#\s+(.+)$/mu.exec(host[`blocks/continue-${id}.md`])?.[1] ??
93
+ `blocks/continue-${id}.md`,
94
+ body: host[`blocks/continue-${id}.md`],
95
+ required: false,
96
+ })),
78
97
  ...Object.entries(host)
79
98
  .filter(([path]) => path.startsWith("blocks/style-"))
80
99
  .map(([path, body]) => ({
@@ -98,7 +117,12 @@ export function defaultOrderedPlayPrompts() {
98
117
  id,
99
118
  kind: "builtin",
100
119
  builtin: id,
101
- enabled: !id.startsWith("play.style-"),
120
+ enabled: !id.startsWith("play.style-") &&
121
+ ![
122
+ "play.continue-advance",
123
+ "play.person-first",
124
+ "play.person-third",
125
+ ].includes(id),
102
126
  }))
103
127
  .concat([{ id: "world", kind: "world" }]);
104
128
  }