@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.
- package/package.json +1 -1
- package/src/contracts/guard.ts +9 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/media/entities.ts +85 -3
- package/src/contracts/media/ports.ts +48 -0
- package/src/contracts/param-direction.ts +103 -0
- package/src/contracts/runtime/column-summary.ts +24 -0
- package/src/contracts/runtime/controls.ts +49 -0
- package/src/contracts/runtime/entities.ts +21 -0
- package/src/contracts/runtime/index.ts +2 -0
- package/src/contracts/runtime/reader.ts +41 -1
- package/src/contracts/runtime/state-keys.ts +70 -2
- package/src/contracts/runtime/timer.ts +8 -1
- package/src/contracts/save.ts +18 -1
- package/src/contracts/shell.ts +26 -0
- package/src/contracts/story/entities.ts +23 -8
- package/src/data/format/column.ts +33 -4
- package/src/data/format/validate/dialog.ts +32 -3
- package/src/data/index.ts +1 -0
- package/src/data/project/assemble.ts +37 -6
- package/src/data/project/conflict.ts +4 -3
- package/src/data/project/diff.ts +3 -3
- package/src/data/project/index.ts +6 -1
- package/src/data/project/naming.ts +11 -5
- package/src/data/project/serialize.ts +49 -16
- package/src/data/text/entry.ts +17 -9
- package/src/data/text/generate/command/dialog.ts +15 -3
- package/src/data/text/generate/command/index.ts +2 -1
- package/src/data/text/generate/command/media.ts +33 -2
- package/src/data/text/generate/command/ui.ts +29 -0
- package/src/data/text/generate/command.ts +10 -0
- package/src/data/text/literals.ts +140 -0
- package/src/data/text/parse/statement/dialog.ts +19 -0
- package/src/data/text/parse/statement/flow.ts +1 -1
- package/src/data/text/parse/statement/index.ts +2 -1
- package/src/data/text/parse/statement/media.ts +91 -2
- package/src/data/text/parse/statement/ui.ts +47 -0
- package/src/data/text/parse/statement.ts +9 -0
- package/src/index.ts +8 -1
- package/src/runtime/dispatch.ts +24 -0
- package/src/runtime/engine/columns.ts +31 -0
- package/src/runtime/engine/forwarders.ts +7 -4
- package/src/runtime/engine/game-state.ts +29 -0
- package/src/runtime/engine/index.ts +2 -0
- package/src/runtime/engine/state.ts +13 -1
- package/src/runtime/engine.ts +46 -21
- package/src/runtime/expr/builtins.ts +1 -1
- package/src/runtime/expr/evaluate.ts +1 -1
- package/src/runtime/expr/index.ts +1 -1
- package/src/runtime/expr/interpolate.ts +1 -1
- package/src/runtime/expr/parser/cursor.ts +1 -1
- package/src/runtime/history/index.ts +1 -0
- package/src/runtime/history/rollback.ts +23 -8
- package/src/runtime/index.ts +9 -3
- package/src/runtime/internal/context.ts +25 -2
- package/src/runtime/internal/helpers.ts +18 -0
- package/src/runtime/internal/index.ts +9 -1
- package/src/runtime/internal/session.ts +11 -0
- package/src/runtime/internal/waiting.ts +51 -3
- package/src/runtime/opRegistry.ts +7 -0
- package/src/runtime/ops/dialog.ts +2 -11
- package/src/runtime/ops/flow.ts +1 -1
- package/src/runtime/ops/index.ts +11 -1
- package/src/runtime/ops/interaction.ts +14 -1
- package/src/runtime/ops/media/audio.ts +230 -0
- package/src/runtime/ops/media/index.ts +18 -0
- package/src/runtime/ops/media/instances.ts +312 -0
- package/src/runtime/ops/media/notify.ts +32 -0
- package/src/runtime/ops/media/shared.ts +133 -0
- package/src/runtime/ops/media/video.ts +118 -0
- package/src/runtime/ops/ui.ts +95 -0
- package/src/runtime/paramDirection.ts +35 -0
- package/src/runtime/save/export.ts +52 -6
- package/src/runtime/save/import.ts +21 -6
- package/src/runtime/scope.ts +17 -0
- package/src/runtime/session/index.ts +21 -0
- package/src/runtime/session/pause.ts +114 -0
- package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
- package/src/runtime/shell.ts +68 -0
- package/src/runtime/state/index.ts +2 -1
- package/src/runtime/state/reader.ts +47 -1
- package/src/runtime/state/writer.ts +1 -1
- package/src/shared/audio.ts +106 -0
- package/src/shared/guards.ts +38 -4
- package/src/shared/index.ts +3 -0
- package/src/shared/mounts.ts +42 -0
- package/src/shared/ui-name.ts +41 -0
- package/src/runtime/ops/media.ts +0 -274
- /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
- /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 媒体域共用:op 负载白名单与音量 / 淡出解析。
|
|
3
|
+
*
|
|
4
|
+
* 白名单是 fail-closed 的唯一来源——执行器只查这里,未知字段一律拒绝;
|
|
5
|
+
* 音量与淡出是音频与视频两族共用的窄化,口径必须一处(否则同一个
|
|
6
|
+
* `volume` 在两条命令上会有两种边界行为)。
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
SYS,
|
|
10
|
+
type AudioChannel,
|
|
11
|
+
type AudioChannelState,
|
|
12
|
+
} from "../../../contracts/index.ts";
|
|
13
|
+
import type { OpContext } from "../../internal/index.ts";
|
|
14
|
+
|
|
15
|
+
/** 音频 op 已知负载字段(未知字段 fail-closed) */
|
|
16
|
+
export const AUDIO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
17
|
+
bgm: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
18
|
+
se: new Set(["op", "resource", "volume"]),
|
|
19
|
+
ambient: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
20
|
+
stop_bgm: new Set(["op", "fade"]),
|
|
21
|
+
stop_ambient: new Set(["op", "fade"]),
|
|
22
|
+
stop_se: new Set(["op", "fade"]),
|
|
23
|
+
voice: new Set(["op", "resource", "volume", "auto_stop", "restart"]),
|
|
24
|
+
stop_voice: new Set(["op", "fade"]),
|
|
25
|
+
audio_play: new Set([
|
|
26
|
+
"op",
|
|
27
|
+
"inst",
|
|
28
|
+
"channel",
|
|
29
|
+
"resource",
|
|
30
|
+
"volume",
|
|
31
|
+
"loop",
|
|
32
|
+
"fade",
|
|
33
|
+
"restart",
|
|
34
|
+
]),
|
|
35
|
+
audio_stop: new Set(["op", "inst", "fade"]),
|
|
36
|
+
audio_volume: new Set(["op", "inst", "volume"]),
|
|
37
|
+
audio_pause: new Set(["op", "channel"]),
|
|
38
|
+
audio_resume: new Set(["op", "channel"]),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 停止类 op → 目标常驻通道系统键 */
|
|
42
|
+
export const AUDIO_STOP_KEY: Record<string, string> = {
|
|
43
|
+
stop_bgm: SYS.audioBgm,
|
|
44
|
+
stop_ambient: SYS.audioAmbient,
|
|
45
|
+
stop_voice: SYS.audioVoice,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 音频通道 op → 常驻通道系统键(se 为一次性触发,不在表内) */
|
|
49
|
+
export const AUDIO_CHANNEL_KEY: Record<string, string> = {
|
|
50
|
+
bgm: SYS.audioBgm,
|
|
51
|
+
ambient: SYS.audioAmbient,
|
|
52
|
+
voice: SYS.audioVoice,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* volume 三级解析:命令显式值 ?? 工程默认(`shell.audio.volumes[通道]`)?? 1。
|
|
57
|
+
*
|
|
58
|
+
* 顺序不能反:显式值是作者在这一条命令上的意图,工程默认是整部作品的口径,
|
|
59
|
+
* 内建 1 只在前两级都缺席时兜底(与「不指定 = 满音量」的既有语义逐字节一致)。
|
|
60
|
+
* 玩家偏好不在这里——它是渲染层最外层的乘算,混进来会让「换个工程,玩家设置
|
|
61
|
+
* 被这个工程的默认值改掉」。
|
|
62
|
+
*
|
|
63
|
+
* `channel` 缺省(视频族)表示没有工程默认级:视频音量只认命令与内建 1。
|
|
64
|
+
* 非有限数字一律 fail-closed(作者写错不能静默当满音量),越界按物理范围钳制。
|
|
65
|
+
*/
|
|
66
|
+
export function audioVolume(
|
|
67
|
+
ctx: OpContext,
|
|
68
|
+
raw: unknown,
|
|
69
|
+
op: string,
|
|
70
|
+
channel?: AudioChannel,
|
|
71
|
+
): number | null {
|
|
72
|
+
let explicit: number | undefined;
|
|
73
|
+
if (raw !== undefined) {
|
|
74
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
75
|
+
ctx.fail(`${op}-invalid-volume`, `${op} 的 volume 必须是有限数字`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
explicit = raw;
|
|
79
|
+
}
|
|
80
|
+
const fallback =
|
|
81
|
+
channel === undefined ? 1 : (ctx.audioConfig.volumes[channel] ?? 1);
|
|
82
|
+
return Math.min(1, Math.max(0, explicit ?? fallback));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** fade 窄化:缺省 0;负数/非有限数字 fail-closed */
|
|
86
|
+
export function audioFade(
|
|
87
|
+
ctx: OpContext,
|
|
88
|
+
raw: unknown,
|
|
89
|
+
op: string,
|
|
90
|
+
): number | null {
|
|
91
|
+
if (raw === undefined) return 0;
|
|
92
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
93
|
+
ctx.fail(`${op}-invalid-fade`, `${op} 的 fade 必须是非负有限数字(毫秒)`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 播放态判定:暂停记账只记录「有东西可续播」的状态。
|
|
101
|
+
*
|
|
102
|
+
* `pause` 态本身不带资源、`stop` 态已经结束,两者都没有可写回的播放态。判据按契约
|
|
103
|
+
* 形状严格比对——记账表随快照 / 存档随行,读回来的可能是被外部改坏的值,把非 play
|
|
104
|
+
* 态当成 play 态会让恢复写回一条没有资源的播放态。
|
|
105
|
+
*/
|
|
106
|
+
export function isPlayState(value: unknown): value is AudioChannelState {
|
|
107
|
+
if (value === null || typeof value !== "object") return false;
|
|
108
|
+
const state = value as { kind?: unknown; resource?: unknown };
|
|
109
|
+
return state.kind === "play" && typeof state.resource === "string";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 读 `SYS.audioPaused` 暂停记账表:逐项只留 play 态,形状不合按空表处理。
|
|
114
|
+
*
|
|
115
|
+
* 同一张表按两套键空间分区存放:常驻通道项用通道名(`bgm` / `ambient` / `voice`),
|
|
116
|
+
* 音频实例项用 `inst:<实例id>`。这里原样返回全部键,由调用方按自己的键空间取值——
|
|
117
|
+
* 在读函数里按前缀过滤会让另一族读不到自己的项。
|
|
118
|
+
*
|
|
119
|
+
* 外部改坏的值一律静默过滤:一条脏值不该让整条暂停 / 恢复路径失败。
|
|
120
|
+
*/
|
|
121
|
+
export function readPausedPlayStates(
|
|
122
|
+
ctx: OpContext,
|
|
123
|
+
): Record<string, AudioChannelState> {
|
|
124
|
+
const value = ctx.get(SYS.audioPaused);
|
|
125
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
const out: Record<string, AudioChannelState> = {};
|
|
129
|
+
for (const [key, state] of Object.entries(value as Record<string, unknown>)) {
|
|
130
|
+
if (isPlayState(state)) out[key] = state;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 视频族:核心只写命令流(`__video`,seq 单调),渲染器按序执行。
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
SYS,
|
|
6
|
+
type StoryCommand,
|
|
7
|
+
type VideoCommand,
|
|
8
|
+
} from "../../../contracts/index.ts";
|
|
9
|
+
import type { Frame, OpContext } from "../../internal/index.ts";
|
|
10
|
+
import { audioVolume } from "./shared.ts";
|
|
11
|
+
|
|
12
|
+
/** 视频族已知负载字段(未知字段 fail-closed) */
|
|
13
|
+
const VIDEO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
14
|
+
video: new Set(["op", "resource", "volume", "loop", "z"]),
|
|
15
|
+
cutscene: new Set(["op", "resource", "volume", "skipable", "z"]),
|
|
16
|
+
seek_video: new Set(["op", "seconds"]),
|
|
17
|
+
pause_video: new Set(["op"]),
|
|
18
|
+
resume_video: new Set(["op"]),
|
|
19
|
+
stop_video: new Set(["op"]),
|
|
20
|
+
video_skipable: new Set(["op", "value"]),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 视频族:核心只写命令流(`__video`,seq 单调),渲染器按序执行。
|
|
25
|
+
* - `video` = 非阻塞播放(故事继续);`cutscene` = 阻塞过场(等待建立时提交检查点,
|
|
26
|
+
* 同 menu/wait/input;ended/跳过解除)。
|
|
27
|
+
* - `seek_video`/`pause_video`/`resume_video`/`stop_video` 为离散命令;
|
|
28
|
+
* `video_skipable` 设后续 video/cutscene 的缺省 skipable。
|
|
29
|
+
*/
|
|
30
|
+
export function execVideo(
|
|
31
|
+
ctx: OpContext,
|
|
32
|
+
frame: Frame,
|
|
33
|
+
cmd: StoryCommand,
|
|
34
|
+
cutscene = false,
|
|
35
|
+
): boolean {
|
|
36
|
+
const known = VIDEO_FIELDS[cmd.op]!;
|
|
37
|
+
const unknown = Object.keys(cmd).filter((k) => !known.has(k));
|
|
38
|
+
if (unknown.length > 0) {
|
|
39
|
+
ctx.fail(
|
|
40
|
+
`${cmd.op}-unknown-field`,
|
|
41
|
+
`${cmd.op} 未知负载字段:${unknown.join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
ctx.videoSeq += 1;
|
|
46
|
+
const seq = ctx.videoSeq;
|
|
47
|
+
if (cmd.op === "video" || cmd.op === "cutscene") {
|
|
48
|
+
if (ctx.rejectBadInstanceZ(cmd)) return false; // 先拒非法 z(不动状态)
|
|
49
|
+
ctx.setInstanceZ(SYS.videoZ, cmd.z); // 视频层实例 z(宿主解析后交 VideoPort.setZIndex)
|
|
50
|
+
if (typeof cmd.resource !== "string" || cmd.resource === "") {
|
|
51
|
+
ctx.fail(
|
|
52
|
+
`${cmd.op}-invalid-resource`,
|
|
53
|
+
`${cmd.op} 需要非空 resource 字符串`,
|
|
54
|
+
);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const volume = audioVolume(ctx, cmd.volume, cmd.op);
|
|
58
|
+
if (volume === null) return false;
|
|
59
|
+
// skipable:cutscene 显式参数 > video_skipable 持久开关(缺省可跳)
|
|
60
|
+
const skipable =
|
|
61
|
+
cutscene && cmd.skipable !== undefined
|
|
62
|
+
? cmd.skipable === true
|
|
63
|
+
: ctx.get(SYS.videoSkipable) !== false;
|
|
64
|
+
ctx.setSystem(SYS.video, {
|
|
65
|
+
kind: "play",
|
|
66
|
+
resource: cmd.resource,
|
|
67
|
+
volume,
|
|
68
|
+
loop: cmd.loop === true,
|
|
69
|
+
cutscene,
|
|
70
|
+
skipable,
|
|
71
|
+
seq,
|
|
72
|
+
} satisfies VideoCommand);
|
|
73
|
+
if (cutscene) {
|
|
74
|
+
// 过场等待建立时提交检查点(同 menu/wait/input);重放期同坐标原位替换
|
|
75
|
+
ctx.setSystem(SYS.waiting, "video");
|
|
76
|
+
ctx.commitCheckpoint(ctx.takeSnapshot(ctx.checkpointCoord(frame)));
|
|
77
|
+
ctx.liveCheckpointed = true;
|
|
78
|
+
ctx.autoSaveAtCheckpoint(); // cutscene 等待画面建立 = auto_save 消费点
|
|
79
|
+
frame.index += 1;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (cmd.op === "seek_video") {
|
|
84
|
+
const seconds = cmd.seconds;
|
|
85
|
+
if (
|
|
86
|
+
typeof seconds !== "number" ||
|
|
87
|
+
!Number.isFinite(seconds) ||
|
|
88
|
+
seconds < 0
|
|
89
|
+
) {
|
|
90
|
+
ctx.fail(
|
|
91
|
+
"seek_video-invalid-seconds",
|
|
92
|
+
"seek_video 需要非负有限数字(秒)",
|
|
93
|
+
);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
ctx.setSystem(SYS.video, {
|
|
97
|
+
kind: "seek",
|
|
98
|
+
seconds,
|
|
99
|
+
seq,
|
|
100
|
+
} satisfies VideoCommand);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (cmd.op === "pause_video") {
|
|
104
|
+
ctx.setSystem(SYS.video, { kind: "pause", seq } satisfies VideoCommand);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (cmd.op === "resume_video") {
|
|
108
|
+
ctx.setSystem(SYS.video, { kind: "resume", seq } satisfies VideoCommand);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (cmd.op === "stop_video") {
|
|
112
|
+
ctx.setSystem(SYS.video, { kind: "stop", seq } satisfies VideoCommand);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// video_skipable
|
|
116
|
+
ctx.setSystem(SYS.videoSkipable, cmd.value !== false);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 挂载的执行:把「哪个组件该不该出现在渲染树上、带什么入参」写成状态。
|
|
3
|
+
*
|
|
4
|
+
* 核心只写 `SYS.mounts` 这张表,不碰 DOM、不认识组件内部——挂载与卸载是宿主的活。
|
|
5
|
+
* `visible: false` 的语义是**从渲染树移除**(卸载),不是 `display: none`:
|
|
6
|
+
* 隐藏的组件不该继续跑生命周期与计时器。整表随快照 / 存档 / 回溯自动随行。
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
SYS,
|
|
10
|
+
type MountTable,
|
|
11
|
+
type StoryCommand,
|
|
12
|
+
} from "../../contracts/index.ts";
|
|
13
|
+
import { isMountTable, isUiName, UI_NAME_PATTERN } from "../../shared/index.ts";
|
|
14
|
+
import { findJsonValueError } from "../state/stateContract.ts";
|
|
15
|
+
import type { OpContext } from "../internal/index.ts";
|
|
16
|
+
|
|
17
|
+
/** `mount` 已知负载字段(未知字段 fail-closed) */
|
|
18
|
+
const MOUNT_OP_FIELDS: ReadonlySet<string> = new Set([
|
|
19
|
+
"op",
|
|
20
|
+
"name",
|
|
21
|
+
"visible",
|
|
22
|
+
"props",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** 读挂载表:形状不合(外部改坏)按空表处理,不让一条脏值卡死整个挂载域 */
|
|
26
|
+
function mountTableOf(ctx: OpContext): MountTable {
|
|
27
|
+
const value = ctx.get(SYS.mounts);
|
|
28
|
+
return isMountTable(value) ? value : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 挂载 / 卸载一个 UI 组件:`mount(hub, true)`、`mount(hub, false, { scene: "forest" })`。
|
|
33
|
+
*
|
|
34
|
+
* - `visible` 必填布尔:没有「切换」语义,显式写显隐才能让回溯读到确定的终值
|
|
35
|
+
* - `props` 可选:给了就**整份替换**(不是浅合并)——合并语义会让「删掉一个入参」
|
|
36
|
+
* 无法表达,而组件的入参集合是它的契约,得能完整指定
|
|
37
|
+
* - 整表新建后写回:**不得原地改** `table[name]`,否则回溯会拿到被污染的值
|
|
38
|
+
*/
|
|
39
|
+
export function execMount(ctx: OpContext, cmd: StoryCommand): boolean {
|
|
40
|
+
const unknown = Object.keys(cmd).filter((k) => !MOUNT_OP_FIELDS.has(k));
|
|
41
|
+
if (unknown.length > 0) {
|
|
42
|
+
ctx.fail(
|
|
43
|
+
"mount-unknown-field",
|
|
44
|
+
`mount 未知负载字段:${unknown.join(", ")}`,
|
|
45
|
+
);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const { name, visible } = cmd;
|
|
49
|
+
if (!isUiName(name)) {
|
|
50
|
+
ctx.fail(
|
|
51
|
+
"mount-invalid-name",
|
|
52
|
+
`mount 的 name 不合法:${JSON.stringify(name)}(须匹配 ${UI_NAME_PATTERN.source})`,
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (typeof visible !== "boolean") {
|
|
57
|
+
ctx.fail(
|
|
58
|
+
"mount-invalid-visible",
|
|
59
|
+
`mount 的 visible 必须是布尔:${JSON.stringify(visible)}`,
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const rawProps = cmd.props;
|
|
64
|
+
let props: Record<string, unknown> = {};
|
|
65
|
+
if (rawProps !== undefined) {
|
|
66
|
+
if (
|
|
67
|
+
typeof rawProps !== "object" ||
|
|
68
|
+
rawProps === null ||
|
|
69
|
+
Array.isArray(rawProps)
|
|
70
|
+
) {
|
|
71
|
+
ctx.fail(
|
|
72
|
+
"mount-invalid-props",
|
|
73
|
+
`mount 的 props 必须是对象:${JSON.stringify(rawProps)}`,
|
|
74
|
+
);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const unsafe = findJsonValueError(rawProps, "mount.props");
|
|
78
|
+
if (unsafe !== null) {
|
|
79
|
+
ctx.fail(
|
|
80
|
+
"mount-unsafe-props",
|
|
81
|
+
`mount 的 props 不是 JSON 安全的值——${unsafe}`,
|
|
82
|
+
);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
props = rawProps as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
const table = mountTableOf(ctx);
|
|
88
|
+
const next: Record<
|
|
89
|
+
string,
|
|
90
|
+
{ visible: boolean; props: Record<string, unknown> }
|
|
91
|
+
> = { ...table };
|
|
92
|
+
next[name] = { visible, props };
|
|
93
|
+
ctx.setSystem(SYS.mounts, next satisfies MountTable);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
@@ -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 {
|
|
10
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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 = [
|
|
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)) {
|
package/src/runtime/scope.ts
CHANGED
|
@@ -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";
|