@lingfan-engine/core 0.2.0 → 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 (85) 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 +28 -1
  12. package/src/contracts/runtime/state-keys.ts +62 -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/index.ts +1 -0
  19. package/src/data/project/assemble.ts +37 -6
  20. package/src/data/project/conflict.ts +4 -3
  21. package/src/data/project/diff.ts +3 -3
  22. package/src/data/project/index.ts +6 -1
  23. package/src/data/project/naming.ts +11 -5
  24. package/src/data/project/serialize.ts +49 -16
  25. package/src/data/text/entry.ts +17 -9
  26. package/src/data/text/generate/command/index.ts +2 -1
  27. package/src/data/text/generate/command/media.ts +33 -2
  28. package/src/data/text/generate/command/ui.ts +29 -0
  29. package/src/data/text/generate/command.ts +10 -0
  30. package/src/data/text/parse/statement/flow.ts +1 -1
  31. package/src/data/text/parse/statement/index.ts +2 -1
  32. package/src/data/text/parse/statement/media.ts +91 -2
  33. package/src/data/text/parse/statement/ui.ts +47 -0
  34. package/src/data/text/parse/statement.ts +9 -0
  35. package/src/index.ts +8 -1
  36. package/src/runtime/dispatch.ts +24 -0
  37. package/src/runtime/engine/columns.ts +31 -0
  38. package/src/runtime/engine/forwarders.ts +7 -4
  39. package/src/runtime/engine/game-state.ts +29 -0
  40. package/src/runtime/engine/index.ts +2 -0
  41. package/src/runtime/engine/state.ts +13 -1
  42. package/src/runtime/engine.ts +46 -21
  43. package/src/runtime/expr/builtins.ts +1 -1
  44. package/src/runtime/expr/evaluate.ts +1 -1
  45. package/src/runtime/expr/index.ts +1 -1
  46. package/src/runtime/expr/interpolate.ts +1 -1
  47. package/src/runtime/expr/parser/cursor.ts +1 -1
  48. package/src/runtime/history/index.ts +1 -0
  49. package/src/runtime/history/rollback.ts +23 -8
  50. package/src/runtime/index.ts +9 -3
  51. package/src/runtime/internal/context.ts +25 -2
  52. package/src/runtime/internal/helpers.ts +18 -0
  53. package/src/runtime/internal/index.ts +9 -1
  54. package/src/runtime/internal/session.ts +11 -0
  55. package/src/runtime/internal/waiting.ts +51 -3
  56. package/src/runtime/opRegistry.ts +7 -0
  57. package/src/runtime/ops/dialog.ts +2 -11
  58. package/src/runtime/ops/flow.ts +1 -1
  59. package/src/runtime/ops/index.ts +11 -1
  60. package/src/runtime/ops/media/audio.ts +230 -0
  61. package/src/runtime/ops/media/index.ts +18 -0
  62. package/src/runtime/ops/media/instances.ts +312 -0
  63. package/src/runtime/ops/media/notify.ts +32 -0
  64. package/src/runtime/ops/media/shared.ts +133 -0
  65. package/src/runtime/ops/media/video.ts +118 -0
  66. package/src/runtime/ops/ui.ts +95 -0
  67. package/src/runtime/paramDirection.ts +35 -0
  68. package/src/runtime/save/export.ts +52 -6
  69. package/src/runtime/save/import.ts +21 -6
  70. package/src/runtime/scope.ts +17 -0
  71. package/src/runtime/session/index.ts +21 -0
  72. package/src/runtime/session/pause.ts +114 -0
  73. package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
  74. package/src/runtime/shell.ts +68 -0
  75. package/src/runtime/state/index.ts +2 -1
  76. package/src/runtime/state/reader.ts +32 -1
  77. package/src/runtime/state/writer.ts +1 -1
  78. package/src/shared/audio.ts +106 -0
  79. package/src/shared/guards.ts +38 -4
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/mounts.ts +42 -0
  82. package/src/shared/ui-name.ts +41 -0
  83. package/src/runtime/ops/media.ts +0 -274
  84. /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
  85. /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 参数方向查表:`PARAM_DIRECTION` 的取值实现。
3
+ *
4
+ * **为什么实现在 runtime 而不是 contracts**:契约层只放类型与数据,出现函数关键字即违约
5
+ * (由 `tests/engine/runtime/scene-type.test.ts` 的「契约层只声明类型」判据锁住)。
6
+ * 表本身仍是声明(`contracts/param-direction.ts`),本文件只负责查。
7
+ */
8
+ import {
9
+ PARAM_DIRECTION,
10
+ type ParamDirection,
11
+ } from "../contracts/param-direction.ts";
12
+
13
+ /**
14
+ * 查某键的方向档位:精确键优先,其次最长前缀匹配,都不命中 = `readonly`。
15
+ *
16
+ * 最长前缀而非首个命中:`game.` 与 `game.walk.` 同时存在时后者应赢,
17
+ * 否则细分命名空间无法覆盖粗粒度默认。
18
+ *
19
+ * 缺省 `readonly` 是 fail-closed:新键默认不给写权限,要开放必须显式登记。
20
+ */
21
+ export function directionOf(key: string): ParamDirection {
22
+ const exact = PARAM_DIRECTION[key];
23
+ if (exact !== undefined) return exact;
24
+ let best: ParamDirection | undefined;
25
+ let bestLength = 0;
26
+ for (const [pattern, direction] of Object.entries(PARAM_DIRECTION)) {
27
+ if (!pattern.endsWith(".")) continue; // 精确键已在上面查过
28
+ if (!key.startsWith(pattern)) continue;
29
+ if (pattern.length > bestLength) {
30
+ best = direction;
31
+ bestLength = pattern.length;
32
+ }
33
+ }
34
+ return best ?? "readonly";
35
+ }
@@ -2,16 +2,50 @@
2
2
  * 存档导出。
3
3
  */
4
4
  import {
5
+ SESSION_STATE_KEYS,
5
6
  SYS,
6
7
  type ColumnCoordinate,
7
8
  type SaveDataV1,
8
9
  } from "../../contracts/index.ts";
9
- import { parseMenuReturn, type OpContext } from "../internal/index.ts";
10
- import { findJsonValueError } from "../stateContract.ts";
10
+ import {
11
+ parseMenuReturn,
12
+ type Frame,
13
+ type OpContext,
14
+ } from "../internal/index.ts";
15
+ import { findJsonValueError } from "../state/stateContract.ts";
16
+
17
+ /** 存档面的状态条目:会话态键不写进档(读档 = 会话重开,见 `SESSION_STATE_KEYS`) */
18
+ function saveStateEntries(
19
+ entries: Iterable<[string, unknown]>,
20
+ ): [string, unknown][] {
21
+ return [...entries]
22
+ .filter(([key]) => !SESSION_STATE_KEYS.has(key))
23
+ .map(([key, value]) => [key, value]);
24
+ }
25
+
26
+ /**
27
+ * 历史检查点对应的列级作用域条目(未用到 = 字段缺席)。
28
+ *
29
+ * 检查点的帧栈自带各帧作用域深拷贝,取其中列帧那一层即可;找不到列帧
30
+ * (坐标属已删列等)⇒ 缺席,读档侧按空层重建。
31
+ */
32
+ function historyScopeOf(frames: readonly Frame[]): {
33
+ scope?: [string, unknown][];
34
+ } {
35
+ for (let i = frames.length - 1; i >= 0; i -= 1) {
36
+ const frame = frames[i]!;
37
+ if (frame.columnId === null) continue;
38
+ const entries = frame.scope.ownEntries();
39
+ return entries.length > 0 ? { scope: entries } : {};
40
+ }
41
+ return {};
42
+ }
11
43
 
12
44
  /**
13
45
  * 导出存档载荷。必须在等待点调用(列尾/未启动 fail-closed 拒绝)。
14
- * 载荷 = 等待点坐标 + 全局状态 + rngState + 函数表 + 历史;不含块/列级作用域与帧栈。
46
+ * 载荷 = 等待点坐标 + 全局状态 + 列级作用域 + rngState + 函数表 + 历史;
47
+ * 块级作用域与帧栈不随档(由坐标重放重建,见 `columnFrameAt`);
48
+ * **会话态键也不随档**(暂停标记 / 菜单返回记账 / 重放标记,见 `SESSION_STATE_KEYS`)。
15
49
  *
16
50
  * **场景类型守卫**:在 `menu`/`ui` 场景按存档,**存的是「菜单前的游戏进度」
17
51
  * 而不是菜单状态**(对标 Ren'Py Esc 菜单存档)。
@@ -49,7 +83,10 @@ export function exportSave(ctx: OpContext): SaveDataV1 | null {
49
83
  let coord: ColumnCoordinate;
50
84
  let historyCursor = ctx.cursor;
51
85
  let historyList = ctx.history;
52
- if (!ctx.isCurrentColumnReplayable()) {
86
+ // 存的是不是「当前列的游戏进度」:菜单/界面列存档走 `__menu_return`,
87
+ // 存的是菜单前的坐标,与当前帧不是同一列(那条路径下作用域拿不到)。
88
+ const isReplayable = ctx.isCurrentColumnReplayable();
89
+ if (!isReplayable) {
53
90
  if (menuReturn === null) {
54
91
  ctx.fail(
55
92
  "save-invalid",
@@ -68,20 +105,29 @@ export function exportSave(ctx: OpContext): SaveDataV1 | null {
68
105
  index: Math.max(0, columnFrame.index - 1),
69
106
  };
70
107
  }
108
+ // 列级作用域随档——但只在「存的就是当前列」时才有意义:菜单里存档存的是
109
+ // 菜单前的游戏坐标,那一列的帧早已被 `enterColumn` 换掉,拿不到它的作用域。
110
+ // 此时字段缺席(= 空列级层),与旧档形状一致。
111
+ const columnScope = isReplayable ? columnFrame.scope.ownEntries() : [];
71
112
  return {
72
113
  formatVersion: 1,
73
114
  storyId: ctx.story.id,
74
115
  // 故事版本随档(读档侧按它判定 migrateState 迁移路径;缺省 1)
75
116
  storyVersion: ctx.story.version ?? 1,
76
117
  coord,
77
- state: [...ctx.state.entries()],
118
+ state: saveStateEntries(ctx.state.entries()),
119
+ // 列级作用域(未用到 = 字段缺席,读档垫底据此按空层重建)
120
+ ...(columnScope.length > 0 ? { scope: columnScope } : {}),
78
121
  rngState: ctx.rngState,
79
122
  functions: [...ctx.functions.entries()],
80
123
  cursor: historyCursor,
81
124
  history: historyList.map((cp) => ({
82
125
  coord: { ...cp.coord },
83
- state: cp.snapshot.state,
126
+ state: saveStateEntries(cp.snapshot.state),
84
127
  rngState: cp.snapshot.rngState,
128
+ // 每个检查点各带一份列级作用域:回溯目标可以是档内任何一步,
129
+ // 只存一份档尾的层没法回答「那一步当时的列级变量是多少」。
130
+ ...historyScopeOf(cp.snapshot.frames),
85
131
  })),
86
132
  // 全局层 undef 墓碑(帧层 undef 不记;未用到 = 字段缺席,读档垫底据此挡默认值复活)
87
133
  ...(ctx.deleted.size > 0 ? { deleted: [...ctx.deleted] } : {}),
@@ -2,15 +2,26 @@
2
2
  * 存档导入。
3
3
  */
4
4
  import {
5
+ SESSION_STATE_KEYS,
5
6
  SYS,
6
7
  type ColumnCoordinate,
7
8
  type SaveDataV1,
8
9
  } from "../../contracts/index.ts";
9
- import type { OpContext } from "../internal/index.ts";
10
+ import { parseScopeEntries, type OpContext } from "../internal/index.ts";
11
+
12
+ /** 读档面的状态条目:会话态键一律丢弃(旧档里可能还留着,见 `SESSION_STATE_KEYS`) */
13
+ function loadStateEntries(
14
+ entries: readonly (readonly [string, unknown])[],
15
+ ): [string, unknown][] {
16
+ return entries
17
+ .filter(([key]) => !SESSION_STATE_KEYS.has(key))
18
+ .map(([key, value]) => [key, value]);
19
+ }
10
20
 
11
21
  /**
12
22
  * 读档——恢复全局态与历史,从存档坐标重放重建等待点。
13
- * 帧栈按坐标重建列帧;故事版本不匹配 fail-closed。
23
+ * 帧栈按坐标重建列帧;**列级作用域来自档内的 `scope`**(旧档无该字段 =
24
+ * 空列级层,行为与修复前一致);块级作用域由坐标重放重建。故事版本不匹配 fail-closed。
14
25
  * 返回 false = 已拒绝(engine.error 事件已出站),调用方不得当作成功处理。
15
26
  */
16
27
  export function importSave(ctx: OpContext, data: SaveDataV1): boolean {
@@ -160,18 +171,20 @@ export function importSave(ctx: OpContext, data: SaveDataV1): boolean {
160
171
  pendingSay: ctx.pendingSay,
161
172
  liveCheckpointed: ctx.liveCheckpointed,
162
173
  };
163
- ctx.state = new Map(data.state); // 垫底后的全量状态(扩展恢复 + 玩法系统 + defines 补齐)
174
+ ctx.state = new Map(loadStateEntries(data.state)); // 垫底后的全量状态(扩展恢复 + 玩法系统 + defines 补齐)
164
175
  ctx.rngState = data.rngState;
165
176
  ctx.functions = new Map(data.functions);
166
177
  // 角色表重置为构造期初值(模块层声明「进场即有」):档不携带角色表,
167
178
  // 读档 = 会话重开,列内运行期覆盖不随档继承(与快照回溯面不同,档格式 v1 无此字段)
168
179
  ctx.characters = new Map();
180
+ // 档内检查点各自带一份列级作用域:回溯到档内任意一步时要能恢复那一步的
181
+ // 列级变量(旧档无 `scope` 字段 ⇒ 按空层重建,与修复前一致)。
169
182
  ctx.history = data.history.map((h) => ({
170
183
  coord: { ...h.coord },
171
184
  snapshot: {
172
- state: h.state,
185
+ state: loadStateEntries(h.state),
173
186
  rngState: h.rngState,
174
- frames: [ctx.columnFrameAt(h.coord)],
187
+ frames: [ctx.columnFrameAt(h.coord, parseScopeEntries(h.scope) ?? [])],
175
188
  coord: { ...h.coord },
176
189
  functions: data.functions,
177
190
  // 墓碑随检查点回溯:回滚到 undef 之前的检查点时删除记录同步回退
@@ -188,7 +201,9 @@ export function importSave(ctx: OpContext, data: SaveDataV1): boolean {
188
201
  : Math.min(Math.max(data.cursor, 0), ctx.history.length - 1);
189
202
  ctx.started = true;
190
203
  ctx.pendingSay = null;
191
- ctx.frames = [ctx.columnFrameAt(data.coord)];
204
+ ctx.frames = [
205
+ ctx.columnFrameAt(data.coord, parseScopeEntries(data.scope) ?? []),
206
+ ];
192
207
  ctx.coord = { ...data.coord };
193
208
  ctx.liveCheckpointed = true;
194
209
  if (!ctx.restoreSaveExtensions(data.extensions)) {
@@ -59,6 +59,23 @@ export class Scope {
59
59
  return this.vars.has(name);
60
60
  }
61
61
 
62
+ /**
63
+ * 本层变量导出(存档用):只取这一层,不沿父链。
64
+ *
65
+ * 存档只携带**列级**层——那一层在存档坐标之前就声明完了,重放不会回到那些命令,
66
+ * 只能靠这条通道带过去;块级层由重放重建(见 `columnFrameAt`)。
67
+ */
68
+ ownEntries(): [string, unknown][] {
69
+ return [...this.vars.entries()];
70
+ }
71
+
72
+ /** 用导出的本层变量重建单层作用域(`ownEntries` 的逆操作) */
73
+ static fromEntries(entries: readonly (readonly [string, unknown])[]): Scope {
74
+ const scope = new Scope(null);
75
+ for (const [k, v] of entries) scope.vars.set(k, v);
76
+ return scope;
77
+ }
78
+
62
79
  /** 深拷贝作用域链(03 历史检查点:帧栈快照需独立副本,回溯恢复后互不串扰) */
63
80
  static cloneDeep(source: Scope | null): Scope {
64
81
  if (source === null) return Scope.root();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 会话挂起与等待的出口:引擎级暂停的执行面(`pause.ts`)与等待声明表(`waitingOps.ts`)。
3
+ *
4
+ * **名字消歧(重要)**:本目录的「会话」指**一次执行的挂起与恢复**——暂停时挂起等待定时器、
5
+ * 让出声的媒体收声、记下暂停标记,恢复时按剩余毫秒续上。它与
6
+ * `runtime/internal/session.ts` **不是同一件事**:后者是 op 上下文生命周期
7
+ * (启动、推进、选择、导航、存读档、语言切换与热重载),属运行层内部、不进包出口。
8
+ * 两者不同层,不要合并理解。
9
+ *
10
+ * **暂停标记的读口不在本目录**:`SYS.paused` 的唯一读口是
11
+ * `runtime/internal/waiting.ts` 的 `pausedSession`(该函数注释已写明别的模块不要自己
12
+ * `get` 一次再判)。本目录只是调用方,不再另开一次 `get(SYS.paused)`。
13
+ */
14
+ export { pauseSession, resumeSession } from "./pause.ts";
15
+ export {
16
+ collectWaitSpecs,
17
+ waitSpecOfOp,
18
+ WAITING_OPS,
19
+ waitingStateOfOp,
20
+ type WaitSpec,
21
+ } from "./waitingOps.ts";
@@ -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,6 +11,7 @@
11
11
  */
12
12
  import {
13
13
  SYS,
14
+ sceneTypeOf,
14
15
  type AudioChannel,
15
16
  type AudioChannelState,
16
17
  type ElementInstance,
@@ -18,6 +19,7 @@ import {
18
19
  type LayerId,
19
20
  type LayerZOverrides,
20
21
  type MenuChoice,
22
+ type SceneType,
21
23
  type StateReader,
22
24
  type StateSource,
23
25
  type VideoCommand,
@@ -44,7 +46,7 @@ function inUnitRange(value: unknown, fallback: number): number {
44
46
  }
45
47
 
46
48
  /**
47
- * 音频通道状态收窄:`stop` 只认淡出参数,`play` 缺资源或资源为空串即视为「无媒体」。
49
+ * 音频通道状态收窄:`stop` 只认淡出参数,`pause` 只认序号,`play` 缺资源或资源为空串即视为「无媒体」。
48
50
  *
49
51
  * `fadeMs` 非正数按 0(无淡出)算;`volume` 非有限数按 1 并钳到 0..1;
50
52
  * `autoStop` / `restart` 只在显式为 `true` 时保留(缺字段与 `false` 同义);
@@ -65,6 +67,10 @@ function channelStateOf(value: unknown): AudioChannelState | null {
65
67
  const fade = finiteNumber(state.fadeMs);
66
68
  const fadeMs = fade !== null && fade > 0 ? fade : 0;
67
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
+ }
68
74
  if (state.kind !== "play") return null;
69
75
  if (typeof state.resource !== "string" || state.resource === "") return null;
70
76
  const seq = finiteNumber(state.seq);
@@ -133,6 +139,27 @@ function textOf(value: unknown): string {
133
139
  /** 见契约 `StateReader`:各方法的降级口径写在契约的字段注释里。 */
134
140
  export function createStateReader(source: StateSource): StateReader {
135
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
+
136
163
  return {
137
164
  audioChannel(channel: AudioChannel): AudioChannelState | null {
138
165
  return channelStateOf(get(AUDIO_CHANNEL_KEY[channel]));
@@ -234,5 +261,9 @@ export function createStateReader(source: StateSource): StateReader {
234
261
  }
235
262
  return overrides;
236
263
  },
264
+
265
+ currentColumnId,
266
+
267
+ currentColumnType,
237
268
  };
238
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
+ }