@lingfan-engine/core 0.1.5 → 0.3.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.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/contracts/guard.ts +9 -0
  3. package/src/contracts/index.ts +1 -0
  4. package/src/contracts/media/entities.ts +85 -3
  5. package/src/contracts/media/ports.ts +48 -0
  6. package/src/contracts/param-direction.ts +103 -0
  7. package/src/contracts/runtime/column-summary.ts +24 -0
  8. package/src/contracts/runtime/controls.ts +49 -0
  9. package/src/contracts/runtime/entities.ts +21 -0
  10. package/src/contracts/runtime/index.ts +2 -0
  11. package/src/contracts/runtime/reader.ts +41 -1
  12. package/src/contracts/runtime/state-keys.ts +70 -2
  13. package/src/contracts/runtime/timer.ts +8 -1
  14. package/src/contracts/save.ts +18 -1
  15. package/src/contracts/shell.ts +26 -0
  16. package/src/contracts/story/entities.ts +23 -8
  17. package/src/data/format/column.ts +33 -4
  18. package/src/data/format/validate/dialog.ts +32 -3
  19. package/src/data/index.ts +1 -0
  20. package/src/data/project/assemble.ts +37 -6
  21. package/src/data/project/conflict.ts +4 -3
  22. package/src/data/project/diff.ts +3 -3
  23. package/src/data/project/index.ts +6 -1
  24. package/src/data/project/naming.ts +11 -5
  25. package/src/data/project/serialize.ts +49 -16
  26. package/src/data/text/entry.ts +17 -9
  27. package/src/data/text/generate/command/dialog.ts +15 -3
  28. package/src/data/text/generate/command/index.ts +2 -1
  29. package/src/data/text/generate/command/media.ts +33 -2
  30. package/src/data/text/generate/command/ui.ts +29 -0
  31. package/src/data/text/generate/command.ts +10 -0
  32. package/src/data/text/literals.ts +140 -0
  33. package/src/data/text/parse/statement/dialog.ts +19 -0
  34. package/src/data/text/parse/statement/flow.ts +1 -1
  35. package/src/data/text/parse/statement/index.ts +2 -1
  36. package/src/data/text/parse/statement/media.ts +91 -2
  37. package/src/data/text/parse/statement/ui.ts +47 -0
  38. package/src/data/text/parse/statement.ts +9 -0
  39. package/src/index.ts +8 -1
  40. package/src/runtime/dispatch.ts +24 -0
  41. package/src/runtime/engine/columns.ts +31 -0
  42. package/src/runtime/engine/forwarders.ts +7 -4
  43. package/src/runtime/engine/game-state.ts +29 -0
  44. package/src/runtime/engine/index.ts +2 -0
  45. package/src/runtime/engine/state.ts +13 -1
  46. package/src/runtime/engine.ts +46 -21
  47. package/src/runtime/expr/builtins.ts +1 -1
  48. package/src/runtime/expr/evaluate.ts +1 -1
  49. package/src/runtime/expr/index.ts +1 -1
  50. package/src/runtime/expr/interpolate.ts +1 -1
  51. package/src/runtime/expr/parser/cursor.ts +1 -1
  52. package/src/runtime/history/index.ts +1 -0
  53. package/src/runtime/history/rollback.ts +23 -8
  54. package/src/runtime/index.ts +9 -3
  55. package/src/runtime/internal/context.ts +25 -2
  56. package/src/runtime/internal/helpers.ts +18 -0
  57. package/src/runtime/internal/index.ts +9 -1
  58. package/src/runtime/internal/session.ts +11 -0
  59. package/src/runtime/internal/waiting.ts +51 -3
  60. package/src/runtime/opRegistry.ts +7 -0
  61. package/src/runtime/ops/dialog.ts +2 -11
  62. package/src/runtime/ops/flow.ts +1 -1
  63. package/src/runtime/ops/index.ts +11 -1
  64. package/src/runtime/ops/interaction.ts +14 -1
  65. package/src/runtime/ops/media/audio.ts +230 -0
  66. package/src/runtime/ops/media/index.ts +18 -0
  67. package/src/runtime/ops/media/instances.ts +312 -0
  68. package/src/runtime/ops/media/notify.ts +32 -0
  69. package/src/runtime/ops/media/shared.ts +133 -0
  70. package/src/runtime/ops/media/video.ts +118 -0
  71. package/src/runtime/ops/ui.ts +95 -0
  72. package/src/runtime/paramDirection.ts +35 -0
  73. package/src/runtime/save/export.ts +52 -6
  74. package/src/runtime/save/import.ts +21 -6
  75. package/src/runtime/scope.ts +17 -0
  76. package/src/runtime/session/index.ts +21 -0
  77. package/src/runtime/session/pause.ts +114 -0
  78. package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
  79. package/src/runtime/shell.ts +68 -0
  80. package/src/runtime/state/index.ts +2 -1
  81. package/src/runtime/state/reader.ts +47 -1
  82. package/src/runtime/state/writer.ts +1 -1
  83. package/src/shared/audio.ts +106 -0
  84. package/src/shared/guards.ts +38 -4
  85. package/src/shared/index.ts +3 -0
  86. package/src/shared/mounts.ts +42 -0
  87. package/src/shared/ui-name.ts +41 -0
  88. package/src/runtime/ops/media.ts +0 -274
  89. /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
  90. /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 引擎级暂停与恢复:把「停住」与「中断」分成两件事。
3
+ *
4
+ * 暂停只做三件事——挂起等待定时器、让正在出声的媒体收声、记下暂停标记;
5
+ * 外部接管(小游戏 / 玩法系统 / 扩展 op 等待)**保持挂载**,它们只是不再收到推进输入。
6
+ * 所以这里不调 `abortExternalTakeovers`:那是中断的语义(销毁、导航、读档、回溯),
7
+ * 调了之后宿主会卸载接管面板,再恢复就不是「接着玩」而是「重进」。
8
+ *
9
+ * 恢复按暂停时记下的剩余毫秒续上原来的等待,媒体按**当前状态**逐个恢复——
10
+ * 暂停期间被命令改成 stop 的通道不复活(留着一条「该出声却永远不出声」的通道更糟)。
11
+ */
12
+ import { SYS, type VideoCommand } from "../../contracts/index.ts";
13
+ import {
14
+ armWaitTimer,
15
+ nowMs,
16
+ pausedSession,
17
+ type OpContext,
18
+ } from "../internal/index.ts";
19
+ import {
20
+ execAudioPause,
21
+ execAudioResume,
22
+ pauseAudioInstances,
23
+ resumeAudioInstances,
24
+ } from "../ops/index.ts";
25
+
26
+ /** 视频命令流里「正在播」的形态判定(暂停只对正在播的过场有意义) */
27
+ function playingVideo(value: unknown): value is VideoCommand {
28
+ return (
29
+ value !== null &&
30
+ typeof value === "object" &&
31
+ (value as { kind?: unknown }).kind === "play"
32
+ );
33
+ }
34
+
35
+ /** 视频命令流里「已暂停」的形态判定(恢复只对暂停过的过场有意义) */
36
+ function pausedVideo(value: unknown): value is VideoCommand {
37
+ return (
38
+ value !== null &&
39
+ typeof value === "object" &&
40
+ (value as { kind?: unknown }).kind === "pause"
41
+ );
42
+ }
43
+
44
+ /**
45
+ * 挂起等待定时器:记下剩余毫秒并取消,等恢复时按剩余量重排。
46
+ *
47
+ * 剩余量靠到期时刻与当前时刻相减——`clear` 之后句柄再也问不出「还剩多久」。
48
+ * 没有到期时刻(非本模块建立的定时器)就退回「不知道剩余量」,
49
+ * 恢复时宁可丢弃这段等待,也不排一个凭空多出来的完整时长。
50
+ */
51
+ function suspendWaitTimer(ctx: OpContext): void {
52
+ const handle = ctx.pendingTimer;
53
+ if (handle === null) return;
54
+ const dueAt = ctx.pendingTimerDueAt;
55
+ ctx.pausedTimerRemainingMs =
56
+ dueAt === null ? null : Math.max(0, dueAt - nowMs(ctx));
57
+ ctx.timerPort.clear(handle);
58
+ ctx.pendingTimer = null;
59
+ ctx.pendingTimerDueAt = null;
60
+ }
61
+
62
+ /**
63
+ * 引擎级暂停:幂等,重复调用不改状态、不重复发事件。
64
+ *
65
+ * 常驻音频复用 `audio_pause` 的执行语义(只暂停 play 态、把暂停前的播放态记账),
66
+ * 引擎级暂停与作者手写 `audio_pause` 因此是同一件事,恢复时走同一条路。
67
+ * 实例族另走一遍:它们是另一张表(`SYS.audioInstances`),不受通道暂停覆盖,
68
+ * 漏掉就会留下「引擎已暂停、作者编排的环境音还在放」的状态。
69
+ */
70
+ export function pauseSession(ctx: OpContext): void {
71
+ if (pausedSession(ctx)) return;
72
+ ctx.setSystem(SYS.paused, true);
73
+ suspendWaitTimer(ctx);
74
+ execAudioPause(ctx, { op: "audio_pause" });
75
+ pauseAudioInstances(ctx);
76
+ const video = ctx.get(SYS.video);
77
+ if (playingVideo(video)) {
78
+ ctx.videoSeq += 1;
79
+ ctx.setSystem(SYS.video, {
80
+ kind: "pause",
81
+ seq: ctx.videoSeq,
82
+ } satisfies VideoCommand);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 解除引擎级暂停:未暂停时幂等空操作。
88
+ *
89
+ * 续起等待前先确认故事仍停在同一个 wait 上——暂停期间可能读过档或导航过,
90
+ * 那时剩余毫秒属于一段已经不存在的等待,排出来只会提前解除别的等待。
91
+ */
92
+ export function resumeSession(ctx: OpContext): void {
93
+ if (!pausedSession(ctx)) return;
94
+ ctx.setSystem(SYS.paused, false);
95
+ const remaining = ctx.pausedTimerRemainingMs;
96
+ ctx.pausedTimerRemainingMs = null;
97
+ if (
98
+ remaining !== null &&
99
+ ctx.pendingTimer === null &&
100
+ ctx.get(SYS.waiting) === "wait"
101
+ ) {
102
+ armWaitTimer(ctx, remaining);
103
+ }
104
+ execAudioResume(ctx, { op: "audio_resume" });
105
+ resumeAudioInstances(ctx);
106
+ const video = ctx.get(SYS.video);
107
+ if (pausedVideo(video)) {
108
+ ctx.videoSeq += 1;
109
+ ctx.setSystem(SYS.video, {
110
+ kind: "resume",
111
+ seq: ctx.videoSeq,
112
+ } satisfies VideoCommand);
113
+ }
114
+ }
@@ -16,7 +16,7 @@
16
16
  * 块体(if/while/switch/func 体)内的等待点:其检查点坐标按 `checkpointCoord` 的块帧回退语义
17
17
  * 落在**块进入命令**上——故静态切分时「块体内含等待 ⇒ 该块命令本身即边界」,无需逐 op 特判。
18
18
  */
19
- import type { OpExtension, WaitingState } from "../contracts/index.ts";
19
+ import type { OpExtension, WaitingState } from "../../contracts/index.ts";
20
20
 
21
21
  /** 单个 op 的等待规格:等待态 + 是否硬等待(`pause` = 不可跳过) */
22
22
  export interface WaitSpec {
@@ -10,12 +10,16 @@
10
10
  */
11
11
 
12
12
  import {
13
+ AUDIO_CHANNELS,
14
+ DEFAULT_AUDIO_CONFIG,
13
15
  DEFAULT_LAYER_Z,
14
16
  DEFAULT_SAVES_CONFIG,
15
17
  DEFAULT_STAGE_CONFIG,
16
18
  isOrientationMode,
17
19
  LAYER_IDS,
18
20
  SYS,
21
+ type AudioChannel,
22
+ type AudioConfig,
19
23
  type LayerId,
20
24
  type LayerZTable,
21
25
  type OrientationMode,
@@ -32,12 +36,14 @@ import {
32
36
  * 后续并入包出口时统一改走包出口。
33
37
  */
34
38
  export {
39
+ DEFAULT_AUDIO_CONFIG,
35
40
  DEFAULT_LAYER_Z,
36
41
  DEFAULT_SAVES_CONFIG,
37
42
  DEFAULT_STAGE_CONFIG,
38
43
  LAYER_IDS,
39
44
  } from "../contracts/index.ts";
40
45
  export type {
46
+ AudioConfig,
41
47
  LayerId,
42
48
  LayerZOverrides,
43
49
  LayerZTable,
@@ -145,6 +151,26 @@ export function slotIds(count: number): string[] {
145
151
  return Array.from({ length: count }, (_, i) => `slot_${i + 1}`);
146
152
  }
147
153
 
154
+ /** 取清单里一个非空字符串字段;不是字符串或是空串一律返回 null,由调用方决定回落 */
155
+ function nonEmptyText(value: unknown): string | null {
156
+ return typeof value === "string" && value.length > 0 ? value : null;
157
+ }
158
+
159
+ /**
160
+ * 解析工程展示名:`name` → `id` → 空串。
161
+ *
162
+ * 展示名是**给玩家看的**那一行字,与工程 id 是两个用途:id 是存储与引用目标(不可随意改),
163
+ * name 才是可随时换的招牌。缺 name 时退回 id 是为了让工程至少有个名字可显示;
164
+ * 两者都没有才算没名字(空串),由调用方决定怎么兜。
165
+ *
166
+ * 入参与其他壳配置解析同口径:`unknown`、不猜不抛——清单是外部文件,畸形是常态不是异常。
167
+ */
168
+ export function resolveProjectName(manifest: unknown): string {
169
+ if (manifest === null || typeof manifest !== "object") return "";
170
+ const record = manifest as Record<string, unknown>;
171
+ return nonEmptyText(record.name) ?? nonEmptyText(record.id) ?? "";
172
+ }
173
+
148
174
  /** 解析存档壳配置:内建默认 × 工程覆盖(`shell.saves`,逐键合并;非法值/越界忽略) */
149
175
  export function resolveSavesConfig(manifest: unknown): SavesConfig {
150
176
  const resolved: SavesConfig = {
@@ -173,6 +199,48 @@ export function resolveSavesConfig(manifest: unknown): SavesConfig {
173
199
  return resolved;
174
200
  }
175
201
 
202
+ /* ============================ 音频壳配置 ============================ */
203
+
204
+ /** 通道音量:有限数按物理范围钳制;其余(含缺键)回 null,由调用方保持默认 */
205
+ function channelVolume(value: unknown): number | null {
206
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
207
+ return Math.min(1, Math.max(0, value));
208
+ }
209
+
210
+ /**
211
+ * 解析音频壳配置:内建默认(四通道皆 1)× 工程覆盖(`shell.audio.volumes`)。
212
+ *
213
+ * 与 `resolveSavesConfig` 同一口径(清单入参 `unknown`、不猜不抛),差别在越界处理:
214
+ * 越界值**钳制后采纳**(`1.5` ⇒ `1`)而不是丢弃——与命令面 `volume=` 的行为一致,
215
+ * 作者手滑写个越界数不该让音量悄悄回满。非有限数(`NaN`/`Infinity`/字符串)仍忽略,
216
+ * 它们不是「差一点」而是「不是数」。
217
+ *
218
+ * 真正的畸形在组装期就 fail-closed 了(`project.json: shell.audio.volumes.<通道>
219
+ * 必须为 0..1 的有限数字`),这里的宽松只是不让解析函数自己抛。
220
+ */
221
+ export function resolveAudioConfig(
222
+ manifest: unknown,
223
+ defaults: AudioConfig = DEFAULT_AUDIO_CONFIG,
224
+ ): AudioConfig {
225
+ const resolved: Record<AudioChannel, number> = { ...defaults.volumes };
226
+ if (manifest === null || typeof manifest !== "object") {
227
+ return { volumes: resolved };
228
+ }
229
+ const shell = (manifest as { shell?: unknown }).shell;
230
+ if (shell === null || typeof shell !== "object") return { volumes: resolved };
231
+ const audio = (shell as { audio?: unknown }).audio;
232
+ if (audio === null || typeof audio !== "object") return { volumes: resolved };
233
+ const volumes = (audio as { volumes?: unknown }).volumes;
234
+ if (volumes === null || typeof volumes !== "object") {
235
+ return { volumes: resolved };
236
+ }
237
+ for (const channel of AUDIO_CHANNELS) {
238
+ const value = channelVolume((volumes as Record<string, unknown>)[channel]);
239
+ if (value !== null) resolved[channel] = value;
240
+ }
241
+ return { volumes: resolved };
242
+ }
243
+
176
244
  /* ============================ 舞台适配配置 ============================ */
177
245
 
178
246
  /** 取 [min,max] 闭区间内的有限数(尺寸/倍率允许小数);越界或非有限一律 null */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 状态子层出口:写入通道、实例 z、表达式求值、出站面与状态读口。
2
+ * 状态子层出口:写入通道、写入契约守卫、实例 z、表达式求值、出站面与状态读口。
3
3
  *
4
4
  * 执行器与运行层内部模块从这里取状态相关成员;子层各文件之间仍按需直接引用。
5
5
  * 这里只做转发,不放任何实现。
@@ -9,3 +9,4 @@ export { rejectBadInstanceZ, setInstanceZ } from "./instance-z.ts";
9
9
  export { evalValue, readVariable, resolveName } from "./evaluate.ts";
10
10
  export { emitChange, emitErrorEvent, publishEvent } from "./notify.ts";
11
11
  export { createStateReader } from "./reader.ts";
12
+ export { findJsonValueError, jsonUnsafeReason } from "./stateContract.ts";
@@ -11,12 +11,15 @@
11
11
  */
12
12
  import {
13
13
  SYS,
14
+ sceneTypeOf,
14
15
  type AudioChannel,
15
16
  type AudioChannelState,
16
17
  type ElementInstance,
18
+ type InputCandidate,
17
19
  type LayerId,
18
20
  type LayerZOverrides,
19
21
  type MenuChoice,
22
+ type SceneType,
20
23
  type StateReader,
21
24
  type StateSource,
22
25
  type VideoCommand,
@@ -43,7 +46,7 @@ function inUnitRange(value: unknown, fallback: number): number {
43
46
  }
44
47
 
45
48
  /**
46
- * 音频通道状态收窄:`stop` 只认淡出参数,`play` 缺资源或资源为空串即视为「无媒体」。
49
+ * 音频通道状态收窄:`stop` 只认淡出参数,`pause` 只认序号,`play` 缺资源或资源为空串即视为「无媒体」。
47
50
  *
48
51
  * `fadeMs` 非正数按 0(无淡出)算;`volume` 非有限数按 1 并钳到 0..1;
49
52
  * `autoStop` / `restart` 只在显式为 `true` 时保留(缺字段与 `false` 同义);
@@ -64,6 +67,10 @@ function channelStateOf(value: unknown): AudioChannelState | null {
64
67
  const fade = finiteNumber(state.fadeMs);
65
68
  const fadeMs = fade !== null && fade > 0 ? fade : 0;
66
69
  if (state.kind === "stop") return { kind: "stop", fadeMs };
70
+ if (state.kind === "pause") {
71
+ const pauseSeq = finiteNumber(state.seq);
72
+ return { kind: "pause", ...(pauseSeq === null ? {} : { seq: pauseSeq }) };
73
+ }
67
74
  if (state.kind !== "play") return null;
68
75
  if (typeof state.resource !== "string" || state.resource === "") return null;
69
76
  const seq = finiteNumber(state.seq);
@@ -132,6 +139,27 @@ function textOf(value: unknown): string {
132
139
  /** 见契约 `StateReader`:各方法的降级口径写在契约的字段注释里。 */
133
140
  export function createStateReader(source: StateSource): StateReader {
134
141
  const get = (key: string): unknown => source.get(key);
142
+
143
+ /**
144
+ * 当前列 id:非字符串一律当空串(与 `textOf` 同口径)。
145
+ *
146
+ * 空串同时表达「还没进列」与「值被改坏」——两者对界面是同一件事:没有当前位置。
147
+ */
148
+ const currentColumnId = (): string => textOf(get(SYS.currentSceneColumn));
149
+
150
+ /**
151
+ * 当前列类型:从列清单里查 id 对应的类型,查不到即缺省。
152
+ *
153
+ * 「查不到」有三种来源,对界面是同一件事(没有可用的当前列):启动期尚未进列、
154
+ * 读口拿不到列清单、id 已失效(热重载后坐标过期)。三种都交给 `sceneTypeOf` 归一,
155
+ * 缺省口径因此只有一处——与列清单、与可回溯判定同源,这里不另判一遍。
156
+ */
157
+ const currentColumnType = (): SceneType => {
158
+ const id = currentColumnId();
159
+ const columns = source.columns?.() ?? [];
160
+ return sceneTypeOf(columns.find((column) => column.id === id) ?? {});
161
+ };
162
+
135
163
  return {
136
164
  audioChannel(channel: AudioChannel): AudioChannelState | null {
137
165
  return channelStateOf(get(AUDIO_CHANNEL_KEY[channel]));
@@ -159,6 +187,20 @@ export function createStateReader(source: StateSource): StateReader {
159
187
  }));
160
188
  },
161
189
 
190
+ inputCandidates(): InputCandidate[] {
191
+ // 候选是单条目里的对象数组:畸形项整项丢弃(不猜字段),全部丢弃即等于「无候选」
192
+ const raw = get(SYS.inputOptions);
193
+ if (!Array.isArray(raw)) return [];
194
+ const out: InputCandidate[] = [];
195
+ for (const item of raw) {
196
+ if (item === null || typeof item !== "object") continue;
197
+ const { text, value } = item as { text?: unknown; value?: unknown };
198
+ if (typeof text !== "string" || typeof value !== "string") continue;
199
+ out.push({ text, value });
200
+ }
201
+ return out;
202
+ },
203
+
162
204
  dialogSpeaker(): string {
163
205
  return textOf(get(SYS.currentDialogSpeaker));
164
206
  },
@@ -219,5 +261,9 @@ export function createStateReader(source: StateSource): StateReader {
219
261
  }
220
262
  return overrides;
221
263
  },
264
+
265
+ currentColumnId,
266
+
267
+ currentColumnType,
222
268
  };
223
269
  }
@@ -6,7 +6,7 @@ import {
6
6
  RESERVED_STATE_KEYS,
7
7
  } from "../../contracts/index.ts";
8
8
  import type { OpContext } from "../internal/index.ts";
9
- import { findJsonValueError } from "../stateContract.ts";
9
+ import { findJsonValueError } from "./stateContract.ts";
10
10
 
11
11
  /**
12
12
  * 全局层写入(作者 `set`/`define` / 小游戏奖励 / 数组族 op 的唯一汇聚点)。
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 音频实例表的读入判据:把运行期到手的 `unknown` 收窄成契约里的实例表。
3
+ *
4
+ * 契约层只声明形态,实例表却可能来自存档、来自扩展、来自被改坏的工程文件,
5
+ * 所以每次读入都得先判后用(与 `guards.ts` 同一套纪律:判不过就由调用方
6
+ * fail-closed,这里不抛错也不猜默认值)。
7
+ *
8
+ * 三条规则一次判清,缺一条都会让坏数据漏进状态:
9
+ * - 表是普通对象(数组与 `null` 不算——数组会把下标当实例 id)
10
+ * - 实例 id 合 `AUDIO_INSTANCE_ID_PATTERN`(作者要按名字点名,形态必须统一)
11
+ * - 实例数不超 `MAX_AUDIO_INSTANCES`(整表进快照与存档,无上限存档会写到写不动)
12
+ */
13
+ import {
14
+ AUDIO_INSTANCE_ID_PATTERN,
15
+ MAX_AUDIO_INSTANCES,
16
+ type AudioChannel,
17
+ type AudioChannelState,
18
+ type AudioInstanceState,
19
+ type AudioInstanceTable,
20
+ } from "../contracts/media/index.ts";
21
+ import { isPlainObject } from "./guards.ts";
22
+
23
+ const AUDIO_CHANNELS: readonly string[] = ["bgm", "se", "ambient", "voice"];
24
+
25
+ /** 是否是已知的音频通道(`bgm` / `se` / `ambient` / `voice`) */
26
+ export function isAudioChannel(value: unknown): value is AudioChannel {
27
+ return typeof value === "string" && AUDIO_CHANNELS.includes(value);
28
+ }
29
+
30
+ /** 是否是合形态的实例 id(与扩展 / 玩法系统同一份短 id 规则) */
31
+ export function isAudioInstanceId(value: unknown): value is string {
32
+ return typeof value === "string" && AUDIO_INSTANCE_ID_PATTERN.test(value);
33
+ }
34
+
35
+ /**
36
+ * 通道状态判据:按 `kind` 分支判各自字段。
37
+ *
38
+ * 不能先统一要求 `fadeMs` 存在——`pause` 态不带淡出参数,那样会把合法的暂停
39
+ * 判成坏数据(读入 fail-closed 后暂停就丢了)。淡出只在 `play` / `stop` 分支里判。
40
+ */
41
+ function isChannelState(value: unknown): value is AudioChannelState {
42
+ if (!isPlainObject(value)) return false;
43
+ if (value.kind === "pause") {
44
+ return value.seq === undefined || isOptionalSeq(value.seq);
45
+ }
46
+ if (typeof value.fadeMs !== "number" || !Number.isFinite(value.fadeMs)) {
47
+ return false;
48
+ }
49
+ const fadeMs = value.fadeMs;
50
+ if (value.kind === "stop") {
51
+ return value.seq === undefined || isOptionalSeq(value.seq);
52
+ }
53
+ if (value.kind !== "play") return false;
54
+ return (
55
+ typeof value.resource === "string" &&
56
+ value.resource !== "" &&
57
+ typeof value.volume === "number" &&
58
+ Number.isFinite(value.volume) &&
59
+ typeof value.loop === "boolean" &&
60
+ fadeMs >= 0 &&
61
+ (value.autoStop === undefined || typeof value.autoStop === "boolean") &&
62
+ (value.restart === undefined || typeof value.restart === "boolean") &&
63
+ (value.seq === undefined || isOptionalSeq(value.seq))
64
+ );
65
+ }
66
+
67
+ function isOptionalSeq(value: unknown): boolean {
68
+ return typeof value === "number" && Number.isFinite(value);
69
+ }
70
+
71
+ /** 是否是合形态的单个实例状态:通道已知 + 播放状态合形态 */
72
+ export function isAudioInstanceState(
73
+ value: unknown,
74
+ ): value is AudioInstanceState {
75
+ if (!isPlainObject(value)) return false;
76
+ return isAudioChannel(value.channel) && isChannelState(value.state);
77
+ }
78
+
79
+ /**
80
+ * 是否是合形态的实例表:普通对象 + 键合 id + 值合形态 + 条数不超上限。
81
+ *
82
+ * **上限在这里判**而不是在写入 op 里判:读入与写入都走同一个判据,
83
+ * 存档里被人改成 100 个实例时也能在装载阶段挡下来。
84
+ */
85
+ export function isAudioInstanceTable(
86
+ value: unknown,
87
+ ): value is AudioInstanceTable {
88
+ if (!isPlainObject(value)) return false;
89
+ const ids = Object.keys(value);
90
+ if (ids.length > MAX_AUDIO_INSTANCES) return false;
91
+ for (const id of ids) {
92
+ if (!isAudioInstanceId(id)) return false;
93
+ if (!isAudioInstanceState(value[id])) return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * 取实例表的 id 清单(字典序):不合形态的表按空表处理。
100
+ *
101
+ * 渲染层按 id 排序后下发才能保证「同一份状态 → 同一串播放动作」,
102
+ * 否则对象键序会让同一存档在两台机器上叠出不同顺序的音轨。
103
+ */
104
+ export function audioInstanceIds(value: unknown): readonly string[] {
105
+ return isAudioInstanceTable(value) ? Object.keys(value).sort() : [];
106
+ }
@@ -61,17 +61,51 @@ export function isPlainObject(
61
61
  return typeof value === "object" && value !== null && !Array.isArray(value);
62
62
  }
63
63
 
64
- /** 是否是已知的场景类型(非法值由解析层 fail-closed,这里只做收窄) */
64
+ /**
65
+ * 是否是契约承认的场景类型(**只认 `game` / `ui`**)。
66
+ *
67
+ * 调用点都在「拒绝非法输入」的位置:故事文件里写别的值一律 fail-closed 报错,
68
+ * 不静默当剧情——那种错要等存档/回溯出问题才发现,代价极高。
69
+ */
65
70
  export function isSceneType(value: unknown): value is SceneType {
66
- return value === "game" || value === "menu" || value === "ui";
71
+ return value === "game" || value === "ui";
72
+ }
73
+
74
+ /**
75
+ * 把外部输入的场景类型拼写归一为契约值:`game` / `ui`。
76
+ *
77
+ * **单一归一函数**:解析层已用 `isSceneType` fail-closed 拒绝非法值,
78
+ * 这里只负责「缺省」这一种合法输入——`undefined` → `game`。
79
+ *
80
+ * 为什么仍单独成函数:`type` 的缺省语义(不写 = 剧情)在 JSON 解析、DSL 解析、
81
+ * 读侧兜底三处都要用,散写三份 `?? "game"` 会让「缺省是什么」出现三个答案。
82
+ *
83
+ * 注意:**归一是读侧兜底,不是校验**。故事文件里的非法 `type` 要 fail-closed 报错,
84
+ * 那是解析层(`columnShapeDefect`)的职责,不靠这里静默吃掉。
85
+ */
86
+ export function normalizeSceneType(raw: unknown): SceneType {
87
+ return isSceneType(raw) ? raw : "game";
67
88
  }
68
89
 
69
90
  /**
70
- * 该列是否参与历史与存档:`type` 缺省按 `game` 算,`menu` / `ui` 不参与。
91
+ * 该列是否参与历史与存档:`type` 缺省按 `game` 算,`ui` 不参与。
71
92
  *
72
93
  * **单一判定点**——引擎的写入守卫与编辑器的章节分组都调用这里,谁都不许另写一份,
73
94
  * 否则两侧对「哪些列进存档」的理解会分叉。
74
95
  */
75
96
  export function isReplayableColumn(column: Pick<StoryColumn, "type">): boolean {
76
- return (column.type ?? "game") === "game";
97
+ return sceneTypeOf(column) === "game";
98
+ }
99
+
100
+ /**
101
+ * 列的**归一化**运行语义类型:缺省(以及解析层已 fail-closed 的非法值)一律取 `game`。
102
+ *
103
+ * **为什么需要它**:`isReplayableColumn` 只回答「参不参与存档」,回答不了「是哪一种」。
104
+ * 宿主列清单与状态读口要的是具体类型,若各自写 `column.type ?? "game"`,
105
+ * 缺省口径就分叉了。归一口径本身归 `normalizeSceneType`,这里只是它的列级入口。
106
+ *
107
+ * 注意:**这是读侧归一**。故事文件里的非法 `type` 由解析层拒收,不靠这里兜底。
108
+ */
109
+ export function sceneTypeOf(column: Pick<StoryColumn, "type">): SceneType {
110
+ return normalizeSceneType(column.type);
77
111
  }
@@ -2,6 +2,9 @@
2
2
  * 通用出口:没有业务含义的取值判据与小工具,不依赖包内其他层,任何层都可以引用。
3
3
  * 实现按用途分文件,消费方只从这里取。
4
4
  */
5
+ export * from "./audio.ts";
5
6
  export * from "./guards.ts";
7
+ export * from "./mounts.ts";
6
8
  export * from "./numbers.ts";
7
9
  export * from "./ordering.ts";
10
+ export * from "./ui-name.ts";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 挂载表的读入判据:把运行期到手的 `unknown` 收窄成契约里的挂载表。
3
+ *
4
+ * 契约层只声明形态,挂载表却可能来自存档、来自被改坏的工程文件,
5
+ * 所以每次读入都得先判后用(与 `audio.ts` 的实例表同一套纪律:判不过就由调用方
6
+ * fail-closed,这里不抛错也不猜默认值)。
7
+ *
8
+ * 三条规则一次判清,缺一条都会让坏数据漏进渲染层:
9
+ * - 表是普通对象(数组与 `null` 不算——数组会把下标当 UI 名)
10
+ * - 键合 UI 名形态(名字同时是宿主预加载键与目录名,形态必须统一)
11
+ * - 值只有 `visible: boolean` 与 `props: 普通对象` 两项
12
+ */
13
+ import type { MountEntry, MountTable } from "../contracts/runtime/index.ts";
14
+ import { isPlainObject } from "./guards.ts";
15
+ import { isUiName } from "./ui-name.ts";
16
+
17
+ /** 是否是合形态的挂载记录 */
18
+ export function isMountEntry(value: unknown): value is MountEntry {
19
+ if (!isPlainObject(value)) return false;
20
+ if (typeof value.visible !== "boolean") return false;
21
+ return isPlainObject(value.props);
22
+ }
23
+
24
+ /** 是否是合形态的挂载表:普通对象 + 键合 UI 名 + 值合记录 */
25
+ export function isMountTable(value: unknown): value is MountTable {
26
+ if (!isPlainObject(value)) return false;
27
+ for (const name of Object.keys(value)) {
28
+ if (!isUiName(name)) return false;
29
+ if (!isMountEntry(value[name])) return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /**
35
+ * 取挂载表的 UI 名清单(码点序):不合形态的表按空表处理。
36
+ *
37
+ * 渲染层按名字排序后下发才能保证「同一份状态 → 同一串挂载动作」,
38
+ * 否则对象键序会让同一存档在两台机器上挂出不同顺序的组件。
39
+ */
40
+ export function mountNames(value: unknown): readonly string[] {
41
+ return isMountTable(value) ? Object.keys(value).sort() : [];
42
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 列级 UI 名字的形态与定序:名字 = `src/ui/<名>/` 的目录名。
3
+ *
4
+ * 为什么名字要卡形态:名字同时是**产物里的字符串**、宿主的**预加载键**、以及
5
+ * 目录/文件名的来源。不卡形态的话,`Hub` 与 `hub` 会在不同文件系统上落到同一个目录、
6
+ * 却成为两个不同的产物键,产物与实现就对不上了。
7
+ *
8
+ * 形态与扩展 id / 玩法系统 id 同源(`SHORT_ID_PATTERN`):同一个系统不出现两种写法,
9
+ * 且名字作为产物字段时仍然短小可读。
10
+ */
11
+ import { SHORT_ID_PATTERN } from "../contracts/identifiers.ts";
12
+ import { compareOrdinal } from "./ordering.ts";
13
+
14
+ /** 合法 UI 名:小写字母开头,其后小写字母 / 数字 / `_` / `-`,总长 1..32 */
15
+ export const UI_NAME_PATTERN = SHORT_ID_PATTERN;
16
+
17
+ /** 是否为合法 UI 名(类型收窄到 `string`,可直接用于过滤未校验的 JSON 数组) */
18
+ export function isUiName(value: unknown): value is string {
19
+ return typeof value === "string" && UI_NAME_PATTERN.test(value);
20
+ }
21
+
22
+ /**
23
+ * 归一列级 `uis`:**去重 + 按名字码点序**。
24
+ *
25
+ * 为什么必须归一:`uis` 是产物字节的一部分,而它的来源(导入语句的书写顺序、
26
+ * 文件系统的目录枚举顺序)在不同机器上不一样。不归一的产物会「同源不同字节」,
27
+ * 缓存与逐字节比对全部失效。码点序(而非 locale 序)才与运行环境的语言无关。
28
+ *
29
+ * 这是全链路唯一的定序点:解析、汇编、写出三处都调这里,不各写一份比较器。
30
+ */
31
+ export function normalizeUiNames(names: readonly string[]): string[] {
32
+ const seen = new Set<string>();
33
+ const unique: string[] = [];
34
+ for (const name of names) {
35
+ if (seen.has(name)) continue;
36
+ seen.add(name);
37
+ unique.push(name);
38
+ }
39
+ unique.sort(compareOrdinal);
40
+ return unique;
41
+ }