@lingfan-engine/core 0.1.4 → 0.2.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/core",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "灵泛叙事引擎核心:框架无关的叙事语义(格式/执行/回溯/作用域/存档编排)",
6
6
  "license": "Apache-2.0",
@@ -38,6 +38,17 @@ export interface MenuChoice {
38
38
  target: string;
39
39
  }
40
40
 
41
+ /**
42
+ * 输入候选:显示文本与要填进变量的值成对(数组顺序即呈现顺序)。
43
+ *
44
+ * 与 `MenuChoice` 分开定义:候选是「替玩家填一个值」,不是「跳到另一列」,
45
+ * 字段名不同是刻意的——把两者混用会在多语言环境下把译文写进变量。
46
+ */
47
+ export interface InputCandidate {
48
+ text: string;
49
+ value: string;
50
+ }
51
+
41
52
  /**
42
53
  * 类型化读口:按用途分组读状态。
43
54
  *
@@ -53,6 +64,8 @@ export interface StateReader {
53
64
  videoCommand(): VideoCommand | null;
54
65
  /** 菜单选项(文本与目标列成对;两键分属不同状态条目,任一畸形都按空处理) */
55
66
  menuChoices(): MenuChoice[];
67
+ /** 输入候选(显示文本与要填的值成对;缺省或形状不合 = 空数组,界面只渲染输入框) */
68
+ inputCandidates(): InputCandidate[];
56
69
  /** 当前说话人(缺省空串) */
57
70
  dialogSpeaker(): string;
58
71
  /** 当前正文(缺省空串) */
@@ -36,6 +36,14 @@ export const SYS = {
36
36
  menuTargets: "__menu_targets",
37
37
  menuSelected: "__menu_selected",
38
38
  inputPrompt: "__input_prompt",
39
+ /**
40
+ * 输入等待的**候选快捷值**(`input options=[…]` 写入):`{ text, value }[]`。
41
+ *
42
+ * 语义:玩家既可以点候选(把该项 `value` 直接当输入值提交),也可以自己打字。
43
+ * 与 `menuOptions` 分开存,因为两者字段不同(候选是「替玩家填值」不是「跳列」),
44
+ * 混用一个键会让读口无法判断该按哪种形状解析。缺省 = 无候选。
45
+ */
46
+ inputOptions: "__input_options",
39
47
  rollbackActive: "__rollback_active",
40
48
  /** I18N 当前语言(setLanguage 命令写入,空串 = 默认语言/原文直出) */
41
49
  currentLanguage: "__current_language",
@@ -6,6 +6,37 @@ import { requireNonEmptyString } from "../guards.ts";
6
6
  import { validateInstanceZ } from "./shape.ts";
7
7
  import { isPlainObject } from "../../../shared/index.ts";
8
8
 
9
+ /**
10
+ * `input.options` 的候选快捷值:`{ text, value }[]`,每项两字段都必填非空。
11
+ *
12
+ * 形态选 `text` + `value` 而非 `string[]` 或 menu 的 `{text, target}`:
13
+ * - `text` 进翻译表(多语言只译显示文本),`value` 原样写进 `store`——若用 `string[]`,
14
+ * 译文会被写进变量,多语言环境下故事逻辑跟着语言漂移;
15
+ * - 与 `menu` 的 `{text, target}` 区分开:input 的候选是「替玩家填一个值」,
16
+ * 不是「跳到另一列」,两者语义不同,不共用字段名以免误用。
17
+ *
18
+ * 缺省(字段不存在)= 无候选,只有输入框;空数组与 menu 不同**不算错**(等于没写)。
19
+ */
20
+ function validateInputOptions(
21
+ options: unknown,
22
+ at: string,
23
+ issues: string[],
24
+ ): void {
25
+ if (options === undefined) return;
26
+ if (!Array.isArray(options)) {
27
+ issues.push(`${at} 必须为数组`);
28
+ return;
29
+ }
30
+ for (const [i, option] of options.entries()) {
31
+ if (!isPlainObject(option)) {
32
+ issues.push(`${at}[${i}] 必须为对象`);
33
+ continue;
34
+ }
35
+ requireNonEmptyString(option.text, `${at}[${i}].text`, issues);
36
+ requireNonEmptyString(option.value, `${at}[${i}].value`, issues);
37
+ }
38
+ }
39
+
9
40
  /**
10
41
  * 校验本族 op 的负载(7 个 op:`say` `assert` `guard` `notify` `wait` `pause` `input`)。
11
42
  *
@@ -59,9 +90,7 @@ export function validateDialogCommand(
59
90
  case "input":
60
91
  requireNonEmptyString(cmd.prompt, `${at}.prompt`, issues);
61
92
  requireNonEmptyString(cmd.store, `${at}.store`, issues);
62
- if (cmd.options !== undefined) {
63
- issues.push(`${at}.options 选项式输入暂未实现(fail-closed)`);
64
- }
93
+ validateInputOptions(cmd.options, `${at}.options`, issues);
65
94
  validateInstanceZ(cmd, at, issues); // input 的实例 z(choices 层)
66
95
  break;
67
96
  }
@@ -1,5 +1,9 @@
1
1
  /** dialog 族的文本投影。 */
2
- import { quoteForText, instanceZText } from "../../literals.ts";
2
+ import {
3
+ quoteForText,
4
+ instanceZText,
5
+ generateInputOptions,
6
+ } from "../../literals.ts";
3
7
  import type { StoryCommand } from "../../../../contracts/index.ts";
4
8
  import type { GenerateContext } from "../context.ts";
5
9
 
@@ -51,11 +55,19 @@ export function generateDialogCommand(
51
55
  case "pause":
52
56
  out.push(`${pad}pause ${cmd.seconds}`);
53
57
  return;
54
- case "input":
58
+ case "input": {
59
+ // 候选数组内部含空格与引号,必须整体投影成一行内联字面量(解析侧同法整体摘取)
60
+ const candidates = cmd.options as
61
+ Array<{ text: string; value: string }> | undefined;
62
+ const optionsText =
63
+ Array.isArray(candidates) && candidates.length > 0
64
+ ? ` options=${generateInputOptions(candidates)}`
65
+ : "";
55
66
  out.push(
56
- `${pad}input ${quoteForText(cmd.prompt as string)} store=${quoteForText(cmd.store as string)}${instanceZText(cmd)}`,
67
+ `${pad}input ${quoteForText(cmd.prompt as string)} store=${quoteForText(cmd.store as string)}${optionsText}${instanceZText(cmd)}`,
57
68
  );
58
69
  return;
70
+ }
59
71
  case "nvl": {
60
72
  const mode = (cmd.mode as string | undefined) ?? "enter";
61
73
  out.push(
@@ -110,6 +110,146 @@ export function extractKeyedDictLiteral(
110
110
  };
111
111
  }
112
112
 
113
+ /**
114
+ * 提取 `key=[…]` 形式的数组字面量(方括号计数,引号内的括号不算)。
115
+ *
116
+ * 与 `extractKeyedDictLiteral` 同法,只是括号类型不同;`key` 只认**字符串外**且前面是空白
117
+ * 或行首的那一处(避免提示语里恰好出现 `options=` 被误当参数)。
118
+ * 返回字面量与剥离后的余文;找不到或不闭合返回 `null`。
119
+ */
120
+ export function extractKeyedArrayLiteral(
121
+ source: string,
122
+ key: string,
123
+ ): { literal: string; remainder: string } | null {
124
+ const at = findKeyOutsideString(source, key);
125
+ if (at < 0) return null;
126
+ const bracketAt = source.indexOf("[", at + key.length + 1);
127
+ if (bracketAt < 0) return null;
128
+ let depth = 0;
129
+ let end = -1;
130
+ let inString = false;
131
+ for (let i = bracketAt; i < source.length; i += 1) {
132
+ const ch = source[i]!;
133
+ if (inString) {
134
+ if (ch === "\\") {
135
+ i += 1;
136
+ continue;
137
+ }
138
+ if (ch === '"') inString = false;
139
+ continue;
140
+ }
141
+ if (ch === '"') {
142
+ inString = true;
143
+ continue;
144
+ }
145
+ if (ch === "[") depth += 1;
146
+ else if (ch === "]") {
147
+ depth -= 1;
148
+ if (depth === 0) {
149
+ end = i;
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ if (end < 0) return null;
155
+ return {
156
+ literal: source.slice(bracketAt, end + 1),
157
+ remainder: `${source.slice(0, at)} ${source.slice(end + 1)}`,
158
+ };
159
+ }
160
+
161
+ /** 找 `key=` 在字符串外、且位于行首或空白之后的位置;找不到返回 -1 */
162
+ function findKeyOutsideString(source: string, key: string): number {
163
+ let inString = false;
164
+ for (let i = 0; i < source.length; i += 1) {
165
+ const ch = source[i]!;
166
+ if (inString) {
167
+ if (ch === "\\") {
168
+ i += 1;
169
+ continue;
170
+ }
171
+ if (ch === '"') inString = false;
172
+ continue;
173
+ }
174
+ if (ch === '"') {
175
+ inString = true;
176
+ continue;
177
+ }
178
+ if (
179
+ source.startsWith(`${key}=`, i) &&
180
+ (i === 0 || /\s/.test(source[i - 1]!))
181
+ ) {
182
+ return i;
183
+ }
184
+ }
185
+ return -1;
186
+ }
187
+
188
+ /**
189
+ * `[{"text": …, "value": …}, …]` 数组字面量 → 输入候选表。
190
+ *
191
+ * 与 `generateInputOptions` 严格互逆(往返字节级一致);字段不是字符串的项原样保留为
192
+ * `undefined` 之外的空串,交由校验层报错——解析器不替作者猜。
193
+ */
194
+ export function parseInputOptionsLiteral(
195
+ raw: string,
196
+ ): Array<{ text: string; value: string }> {
197
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
198
+ const parts: string[] = [];
199
+ let cur = "";
200
+ let inString = false;
201
+ let depth = 0;
202
+ for (let i = 0; i < inner.length; i += 1) {
203
+ const ch = inner[i]!;
204
+ if (inString) {
205
+ cur += ch;
206
+ if (ch === "\\") {
207
+ cur += inner[i + 1] ?? "";
208
+ i += 1;
209
+ continue;
210
+ }
211
+ if (ch === '"') inString = false;
212
+ continue;
213
+ }
214
+ if (ch === '"') {
215
+ inString = true;
216
+ cur += ch;
217
+ continue;
218
+ }
219
+ if (ch === "{") depth += 1;
220
+ if (ch === "}") depth -= 1;
221
+ if (ch === "," && depth === 0) {
222
+ if (cur.trim() !== "") parts.push(cur);
223
+ cur = "";
224
+ continue;
225
+ }
226
+ cur += ch;
227
+ }
228
+ if (cur.trim() !== "") parts.push(cur);
229
+ return parts.map((part) => {
230
+ const dict = parseDictLiteral(part);
231
+ return {
232
+ text: typeof dict.text === "string" ? dict.text : "",
233
+ value: typeof dict.value === "string" ? dict.value : "",
234
+ };
235
+ });
236
+ }
237
+
238
+ /**
239
+ * 输入候选表的文本投影:`[{"text": "…", "value": "…"}, …]`。
240
+ *
241
+ * 与 `parseInputOptionsLiteral` 严格互逆;每项经 `generateDictLiteral`(键序 text → value,
242
+ * 插入序确定,故同一份数据两次投影字节相同)。
243
+ */
244
+ export function generateInputOptions(
245
+ options: readonly { text: string; value: string }[],
246
+ ): string {
247
+ const items = options.map((item) =>
248
+ generateDictLiteral({ text: item.text, value: item.value }),
249
+ );
250
+ return `[${items.join(", ")}]`;
251
+ }
252
+
113
253
  /** {"f":v} 字典字面量 → 对象(字段值走 parseValueLiteral 语义) */
114
254
  export function parseDictLiteral(raw: string): Record<string, unknown> {
115
255
  const inner = raw.trim().replace(/^\{/, "").replace(/\}$/, "");
@@ -1,6 +1,10 @@
1
1
  /** dialog 族的读向解析。 */
2
2
  import { parseSay } from "../say.ts";
3
3
  import { unquote } from "../../lexer.ts";
4
+ import {
5
+ extractKeyedArrayLiteral,
6
+ parseInputOptionsLiteral,
7
+ } from "../../literals.ts";
4
8
  import type { StoryCommand } from "../../../../contracts/index.ts";
5
9
  import type { StatementContext } from "../state.ts";
6
10
 
@@ -89,6 +93,21 @@ export function parseDialogStatement(c: StatementContext): StoryCommand | null {
89
93
  prompt: unquote(prompt),
90
94
  store: unquote(storeEq.slice(6)),
91
95
  };
96
+ // 候选快捷值:`input "提示" store="k" options=[{"text": "…", "value": "…"}]`
97
+ // 从整行里摘(不能只搜 token——数组内部有空格,会被切词拆散)
98
+ const optionsLiteral = extractKeyedArrayLiteral(rest, "options");
99
+ if (optionsLiteral !== null) {
100
+ const candidates = parseInputOptionsLiteral(optionsLiteral.literal);
101
+ // 缺字段的项在字面量层会落成空串;这里补一条带定位的 issue,
102
+ // 不让它悄悄变成「点了候选写入空值」(与 JSON 侧的 requireNonEmptyString 同口径)
103
+ const broken = candidates.findIndex(
104
+ (item) => item.text === "" || item.value === "",
105
+ );
106
+ if (broken >= 0) {
107
+ return fail(`input 候选 options[${broken}] 需要 text 与 value`);
108
+ }
109
+ cmd.options = candidates;
110
+ }
92
111
  const zEq = tokens.find(
93
112
  (t) => t.startsWith("z=") || t.startsWith("z-index="),
94
113
  );
@@ -26,7 +26,13 @@ const INTERACTION_FIELDS = new Set([
26
26
  "z", // 实例级 z(与 minigame 同层:外部系统整屏接管)
27
27
  ]);
28
28
 
29
- /** input(prompt + store)→ 进入 input 等待;options 选项式输入延后(解析层拒绝) */
29
+ /**
30
+ * input(prompt + store,可选 options 候选)→ 进入 input 等待。
31
+ *
32
+ * 候选值 `{ text, value }[]`:`text` 走翻译表(显示),`value` 原样进状态(玩家点选后
33
+ * 由命令面按普通输入提交)。没有候选时也写一个空数组,**不能跳过**——否则上一次输入
34
+ * 留下的候选会被这一轮读到(状态是跨命令持久的)。
35
+ */
30
36
  export function execInput(
31
37
  ctx: OpContext,
32
38
  frame: Frame,
@@ -41,6 +47,13 @@ export function execInput(
41
47
  SYS.inputPrompt,
42
48
  typeof cmd.prompt === "string" ? ctx.translate(cmd.prompt) : "",
43
49
  );
50
+ const candidates = Array.isArray(cmd.options)
51
+ ? (cmd.options as Array<{ text: string; value: string }>).map((o) => ({
52
+ text: ctx.translate(o.text),
53
+ value: o.value,
54
+ }))
55
+ : [];
56
+ ctx.setSystem(SYS.inputOptions, candidates);
44
57
  ctx.inputStore = cmd.store as string;
45
58
  ctx.setInstanceZ(SYS.choicesZ, cmd.z); // 输入形态的实例 z(choices 层)
46
59
  ctx.setSystem(SYS.waiting, "input");
@@ -14,6 +14,7 @@ import {
14
14
  type AudioChannel,
15
15
  type AudioChannelState,
16
16
  type ElementInstance,
17
+ type InputCandidate,
17
18
  type LayerId,
18
19
  type LayerZOverrides,
19
20
  type MenuChoice,
@@ -159,6 +160,20 @@ export function createStateReader(source: StateSource): StateReader {
159
160
  }));
160
161
  },
161
162
 
163
+ inputCandidates(): InputCandidate[] {
164
+ // 候选是单条目里的对象数组:畸形项整项丢弃(不猜字段),全部丢弃即等于「无候选」
165
+ const raw = get(SYS.inputOptions);
166
+ if (!Array.isArray(raw)) return [];
167
+ const out: InputCandidate[] = [];
168
+ for (const item of raw) {
169
+ if (item === null || typeof item !== "object") continue;
170
+ const { text, value } = item as { text?: unknown; value?: unknown };
171
+ if (typeof text !== "string" || typeof value !== "string") continue;
172
+ out.push({ text, value });
173
+ }
174
+ return out;
175
+ },
176
+
162
177
  dialogSpeaker(): string {
163
178
  return textOf(get(SYS.currentDialogSpeaker));
164
179
  },