@nilvn/core 0.14.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.
@@ -0,0 +1,365 @@
1
+ import { BUILTIN_COMMAND_MAP } from './commands.js';
2
+ export function serializeChunk(project, opts = {}) {
3
+ const lang = opts.lang ?? project.meta.defaultLang;
4
+ const catalog = project.catalogs[lang] ?? {};
5
+ const commands = opts.commands ?? BUILTIN_COMMAND_MAP;
6
+ // keepKeys: ship the catalog key (`@t.hello`) so text is resolved at play time;
7
+ // otherwise inline the resolved literal from the chosen language's catalog.
8
+ const text = opts.keepKeys
9
+ ? (key) => (key ? '@' + key : '')
10
+ : (key) => catalog[key] ?? '';
11
+ const anchorLabel = opts.anchorLabel ?? '__nilvn_here__';
12
+ const scenes = opts.scenes ? project.scenes.filter((s) => opts.scenes.includes(s.id)) : project.scenes;
13
+ // When scoped, any jump/choice target that lands OUTSIDE the scope would be an
14
+ // unknown label at play time (engine.jump throws). Precompute the in-scope sets
15
+ // so dest() can redirect such a target to the graceful unset landing — a scoped
16
+ // preview then ends cleanly at its boundary instead of crashing, and the editor
17
+ // offers to continue into the real target scene. Unscoped = no redirect.
18
+ // Scene ids are kept SEPARATE from the merged label namespace: a cross-scene
19
+ // jump must be judged against the scene set alone, so an out-of-scope scene id
20
+ // that happens to match an in-scope label node's name is still redirected.
21
+ const scope = opts.scenes ? { labels: new Set(), scenes: new Set() } : null;
22
+ if (scope) {
23
+ // Validate targets against just the in-scope scenes (preview: an out-of-scope
24
+ // jump can't play, so redirect it to the unset landing) OR against the whole
25
+ // project (chunked export: a jump to another scene is a valid cross-chunk jump
26
+ // the runtime resolves via labelIndex, so keep it — only a target that names no
27
+ // scene/label anywhere degrades to unset).
28
+ const validation = opts.crossChunk ? project.scenes : scenes;
29
+ for (const scene of validation) {
30
+ scope.scenes.add(scene.id);
31
+ scope.labels.add(scene.id);
32
+ for (const node of scene.nodes)
33
+ if (node.kind === 'label')
34
+ scope.labels.add(node.name);
35
+ }
36
+ }
37
+ const out = [];
38
+ const labels = [];
39
+ const assetRefs = new Set();
40
+ // A–B replay segments (Project.replays, schema v7): only segments whose BOTH
41
+ // anchors resolve to live nodes are emitted — a dangling anchor (its node was
42
+ // deleted) degrades to "segment not in this build" rather than a broken label.
43
+ // Start anchors become a `[label __replay_<id>]` right before their node; end
44
+ // anchors a `[replayend <id>]` right after theirs (segment inclusive of the end
45
+ // node). The `[replaydef]` preamble — the gallery list the runtime shows — is
46
+ // emitted only into the scope holding the project's FIRST scene, so a chunked
47
+ // export declares each segment once (in the entry chunk).
48
+ const replayStarts = new Map();
49
+ const replayEnds = new Map();
50
+ const validReplays = (project.replays ?? []).filter((r) => hasNode(project, r.start) && hasNode(project, r.end));
51
+ for (const r of validReplays) {
52
+ push(replayStarts, r.start.nodeId, r.id);
53
+ push(replayEnds, r.end.nodeId, r.id);
54
+ }
55
+ const holdsEntry = !opts.scenes || (project.scenes[0] !== undefined && opts.scenes.includes(project.scenes[0].id));
56
+ if (holdsEntry) {
57
+ for (const r of validReplays) {
58
+ out.push(`[replaydef id=${token(r.id)} title=${token(text(r.titleKey))} label=${REPLAY_LABEL_PREFIX}${r.id}]`);
59
+ // The start label is DEFINED in whatever chunk holds the start scene; the
60
+ // preamble only references it (labelIndex maps it to its defining chunk).
61
+ }
62
+ if (validReplays.length)
63
+ out.push('');
64
+ }
65
+ for (const scene of scenes) {
66
+ // Scene boundary doubles as a jump label so the engine can start(sceneId).
67
+ out.push(`[label ${scene.id}]`);
68
+ labels.push(scene.id);
69
+ for (const node of scene.nodes) {
70
+ if (opts.anchorNodeId && node.id === opts.anchorNodeId)
71
+ out.push(`[label ${anchorLabel}]`);
72
+ for (const segId of replayStarts.get(node.id) ?? []) {
73
+ out.push(`[label ${REPLAY_LABEL_PREFIX}${segId}]`);
74
+ labels.push(`${REPLAY_LABEL_PREFIX}${segId}`); // a cross-chunk start target → into labelIndex
75
+ }
76
+ if (node.kind === 'label')
77
+ labels.push(node.name);
78
+ collectNodeAssets(node, assetRefs);
79
+ out.push(serializeNode(node, text, commands, scope));
80
+ for (const segId of replayEnds.get(node.id) ?? [])
81
+ out.push(`[replayend ${token(segId)}]`);
82
+ }
83
+ out.push('');
84
+ }
85
+ // A landing spot for not-yet-connected branches/jumps. Reaching it just ends
86
+ // the script (nothing follows), so an unset target can't break parsing/preview.
87
+ out.push(`[label ${UNSET_LABEL}]`);
88
+ return { body: out.join('\n').trimEnd() + '\n', labels, assetRefs: [...assetRefs] };
89
+ }
90
+ /** Serialize to the `.nvn` DSL string (the common case: preview / interchange
91
+ * export). Thin wrapper over serializeChunk, so the scoped and full paths are
92
+ * identical and a caller that only wants the script stays unchanged. */
93
+ export function serializeProject(project, opts = {}) {
94
+ return serializeChunk(project, opts).body;
95
+ }
96
+ /** Matches the asset extensions the pipeline ships. Kept beside isAssetRef so the
97
+ * predicate is one place (the editor imports it too — no second copy to drift). */
98
+ const ASSET_EXT = /\.(svg|png|jpe?g|gif|webp|avif|mp3|ogg|opus|wav|m4a|aac|flac|mp4|webm)$/i;
99
+ /** True when a value looks like an asset reference — used to pick asset paths out
100
+ * of arbitrary command params. Zero-dep, so it's the single shared predicate. */
101
+ export function isAssetRef(v) {
102
+ return (typeof v === 'string' &&
103
+ v !== '' &&
104
+ (v.startsWith('/') || v.startsWith('./') || v.startsWith('asset:') || ASSET_EXT.test(v)));
105
+ }
106
+ /** Scene-scoped asset refs: command params that look like assets + a line's voice
107
+ * clip. (Project-level resources / actor faces are collected elsewhere.) */
108
+ function collectNodeAssets(node, into) {
109
+ if (node.kind === 'command') {
110
+ for (const v of Object.values(node.params ?? {}))
111
+ if (isAssetRef(v))
112
+ into.add(v);
113
+ }
114
+ else if (node.kind === 'say' && node.voice) {
115
+ into.add(node.voice);
116
+ }
117
+ }
118
+ // Where a jump/choice with no chosen target points until the author connects it.
119
+ const UNSET_LABEL = '__nilvn_unset__';
120
+ /** Label prefix for A–B replay segment start anchors (`__replay_<segId>`). Shared
121
+ * with the engine's `playReplay` (which starts at this label) via the wire form
122
+ * the `[replaydef]` preamble carries — the engine never re-derives it. */
123
+ export const REPLAY_LABEL_PREFIX = '__replay_';
124
+ /** Whether an anchor's node still exists (in its scene, or anywhere if the scene
125
+ * itself was renamed — node ids are globally unique, so an id match suffices). */
126
+ function hasNode(project, anchor) {
127
+ const scene = project.scenes.find((s) => s.id === anchor.sceneId);
128
+ if (scene?.nodes.some((n) => n.id === anchor.nodeId))
129
+ return true;
130
+ return project.scenes.some((s) => s.nodes.some((n) => n.id === anchor.nodeId));
131
+ }
132
+ function push(map, key, value) {
133
+ const arr = map.get(key);
134
+ if (arr)
135
+ arr.push(value);
136
+ else
137
+ map.set(key, [value]);
138
+ }
139
+ // Cross-scene jumps target the scene id (each scene emits `[label sceneId]`),
140
+ // otherwise the in-scene label. The conditional jump path also routes through here.
141
+ // An empty target routes to the unset-landing label so the DSL still parses. When
142
+ // `scope` is given (scoped serialization), a target outside the scope is likewise
143
+ // routed to the unset landing — see the scope comment in serializeChunk.
144
+ function dest(target, scope) {
145
+ const d = target.scene || target.label || UNSET_LABEL;
146
+ if (!scope)
147
+ return d;
148
+ // A cross-scene jump is in-scope only if its SCENE is in scope (independent of
149
+ // any label-name collision); a label-only target is judged by the label set.
150
+ if (target.scene)
151
+ return scope.scenes.has(target.scene) ? target.scene : UNSET_LABEL;
152
+ return scope.labels.has(d) ? d : UNSET_LABEL;
153
+ }
154
+ function serializeNode(node, text, commands, scope = null) {
155
+ switch (node.kind) {
156
+ case 'label':
157
+ return `[label ${node.name}]`;
158
+ case 'say': {
159
+ const face = node.face ? `(${node.face})` : '';
160
+ const line = `${node.actor}${face}: ${text(node.textKey)}`;
161
+ // A per-line voice clip is queued by [voice ...] right before the dialogue;
162
+ // the engine plays it alongside the line and mutes the synth typing blip.
163
+ // The start-offset (correction) skips leading silence; emitted only if set.
164
+ if (!node.voice)
165
+ return line;
166
+ const off = node.voiceOffset;
167
+ const offPart = off && off > 0 ? ` offset=${off}` : '';
168
+ return `[voice ${token(node.voice)}${offPart}]\n${line}`;
169
+ }
170
+ case 'narrate':
171
+ return `|${text(node.textKey)}`;
172
+ case 'choice':
173
+ return node.options
174
+ .map((o) => {
175
+ const cond = o.condition ? ` if=${token(o.condition)}` : '';
176
+ return `[choice ${token(text(o.labelKey))} -> ${dest(o.target, scope)}${cond}]`;
177
+ })
178
+ .join('\n');
179
+ case 'jump':
180
+ // v1 DSL expresses a conditional jump as [if cond -> label].
181
+ return node.condition
182
+ ? `[if ${node.condition} -> ${dest(node.target, scope)}]`
183
+ : `[jump ${dest(node.target, scope)}]`;
184
+ case 'set':
185
+ return `[set ${node.var} = ${node.expr}]`;
186
+ case 'recall':
187
+ // v1 DSL has no recall command; emit a comment the engine ignores. The recall
188
+ // gallery is built from the IR directly, not from the runtime script.
189
+ return `; recall ${node.recallId}`;
190
+ case 'command':
191
+ return serializeCommand(node, commands[node.cmd]);
192
+ case 'anim':
193
+ return serializeAnim(node);
194
+ case 'eventframe':
195
+ return serializeEventFrame(node);
196
+ case 'loopstart':
197
+ return serializeLoopStart(node);
198
+ case 'loopstop':
199
+ return serializeLoopStop(node);
200
+ default: {
201
+ const _exhaustive = node;
202
+ return _exhaustive;
203
+ }
204
+ }
205
+ }
206
+ function serializeCommand(node, schema) {
207
+ const parts = [node.cmd];
208
+ const params = node.params;
209
+ const used = new Set();
210
+ if (schema) {
211
+ const positionals = schema.params
212
+ .filter((p) => p.positional !== undefined)
213
+ .sort((a, b) => a.positional - b.positional);
214
+ // Emit positionals contiguously up to the last one actually provided,
215
+ // filling any gaps with their defaults so the argument order stays intact.
216
+ let last = -1;
217
+ positionals.forEach((p, i) => {
218
+ if (params[p.key] !== undefined)
219
+ last = i;
220
+ });
221
+ for (let i = 0; i <= last; i++) {
222
+ const p = positionals[i];
223
+ used.add(p.key);
224
+ parts.push(token(params[p.key] ?? p.default ?? ''));
225
+ }
226
+ for (const p of schema.params) {
227
+ if (p.positional !== undefined)
228
+ continue;
229
+ used.add(p.key);
230
+ const val = params[p.key];
231
+ if (val === undefined)
232
+ continue;
233
+ if (p.default !== undefined && val === p.default)
234
+ continue;
235
+ parts.push(`${p.key}=${token(val)}`);
236
+ }
237
+ }
238
+ // Params not described by the schema (e.g. unknown plugin command) -> key=value.
239
+ for (const [k, v] of Object.entries(params)) {
240
+ if (used.has(k))
241
+ continue;
242
+ parts.push(`${k}=${token(v)}`);
243
+ }
244
+ return `[${parts.join(' ')}]`;
245
+ }
246
+ // A recorded keyframe animation (AnimNode) serializes to an `[anim …]` command the
247
+ // engine's built-in `anim` handler plays. The whole track rides one compact `kf`
248
+ // token (no whitespace / quotes / brackets, so the tokenizer carries it whole):
249
+ // frames joined by `;`, each `<t>:<code><n>,…[,e<easeCode>]` (bare `<t>` = empty),
250
+ // channel codes x / y / s(scale) / r(rotation) / o(opacity); `t` is seconds.
251
+ // The format mirrors the engine's parseAnimFrames (packages/engine/src/builtins.ts).
252
+ function serializeAnim(node) {
253
+ const parts = ['anim', `obj=${node.target}`, `dur=${compactNum(node.duration)}`];
254
+ if (node.hold)
255
+ parts.push('hold=1');
256
+ const kf = encodeAnimFrames(node.keyframes);
257
+ if (kf)
258
+ parts.push(`kf=${kf}`);
259
+ return `[${parts.join(' ')}]`;
260
+ }
261
+ const ANIM_CODE = {
262
+ x: 'x',
263
+ y: 'y',
264
+ scale: 's',
265
+ rotation: 'r',
266
+ opacity: 'o',
267
+ };
268
+ function encodeAnimFrames(kfs) {
269
+ return kfs
270
+ .map((k) => {
271
+ const ch = [];
272
+ for (const key of ['x', 'y', 'scale', 'rotation', 'opacity']) {
273
+ const v = k[key];
274
+ if (typeof v === 'number')
275
+ ch.push(`${ANIM_CODE[key]}${compactNum(v)}`);
276
+ }
277
+ if (k.ease)
278
+ ch.push(`e${k.ease}`);
279
+ return ch.length ? `${compactNum(k.t)}:${ch.join(',')}` : compactNum(k.t);
280
+ })
281
+ .join(';');
282
+ }
283
+ // A recording event-frame (EventFrameNode) serializes to an `[eventframe …]`
284
+ // command played by the engine's pure-JS rAF clock. The whole choreography rides
285
+ // one compact `kf` token (no whitespace / quotes / brackets, so the tokenizer
286
+ // carries it whole). The grammar — decoded by the engine's `decodeTracks`
287
+ // (packages/engine/src/keyframes.ts), keep the two in sync:
288
+ // kf = TRACK ("|" TRACK)*
289
+ // TRACK = <objId> "#" FRAME (";" FRAME)*
290
+ // FRAME = <t> ["~" <easeCode>] [":" CH ("," CH)*]
291
+ // CH = <chId> "=" <value> (number | string | "1"/"0" for booleans)
292
+ // Unlike `[anim]`, channels are addressed by their full recordable id (`scale`,
293
+ // `face`) rather than a single-letter code — the id is data, so a plugin channel
294
+ // round-trips with no shared code table (the "dynamic extraction" model).
295
+ function serializeEventFrame(node) {
296
+ const parts = ['eventframe', `dur=${compactNum(node.duration)}`];
297
+ const kf = encodeTracks(node.tracks);
298
+ if (kf)
299
+ parts.push(`kf=${kf}`);
300
+ return `[${parts.join(' ')}]`;
301
+ }
302
+ function encodeTracks(tracks) {
303
+ return tracks
304
+ .filter((tr) => tr.keys.length > 0)
305
+ .map((tr) => `${tr.objId}#${tr.keys.map(encodeKeyframe).join(';')}`)
306
+ .join('|');
307
+ }
308
+ function encodeKeyframe(k) {
309
+ const head = k.ease ? `${compactNum(k.t)}~${k.ease}` : compactNum(k.t);
310
+ const chs = Object.entries(k.ch).map(([id, v]) => `${id}=${encodeChannelValue(v)}`);
311
+ return chs.length ? `${head}:${chs.join(',')}` : head;
312
+ }
313
+ function encodeChannelValue(v) {
314
+ if (typeof v === 'number')
315
+ return compactNum(v);
316
+ if (typeof v === 'boolean')
317
+ return v ? '1' : '0';
318
+ return v;
319
+ }
320
+ // A single-element loop (LoopStart/LoopStop) serializes to `[loopstart …]` /
321
+ // `[loopstop …]` commands played by the engine's loop runtime. The cycle `body`
322
+ // reuses the same compact frame encoding as one event-frame track (the objId rides
323
+ // `obj=` instead of an inline prefix); the anchor `entry` / settle `exit` poses are
324
+ // a bare channel set (`chId=val,…`). Each compound value is a single whitespace-
325
+ // and quote-free token, so the DSL tokenizer carries it whole (decoded by the
326
+ // engine's decodeFrames / decodeChannelSet — keep the two in sync).
327
+ function serializeLoopStart(node) {
328
+ const parts = ['loopstart', `obj=${node.objId}`, `dur=${compactNum(node.duration)}`];
329
+ if (node.intoEase)
330
+ parts.push(`into=${node.intoEase}`);
331
+ const entry = encodeChannelSet(node.entry);
332
+ if (entry)
333
+ parts.push(`entry=${entry}`);
334
+ const body = node.body.map(encodeKeyframe).join(';');
335
+ if (body)
336
+ parts.push(`body=${body}`);
337
+ return `[${parts.join(' ')}]`;
338
+ }
339
+ function serializeLoopStop(node) {
340
+ const parts = ['loopstop', `obj=${node.objId}`];
341
+ if (node.outEase)
342
+ parts.push(`out=${node.outEase}`);
343
+ const exit = encodeChannelSet(node.exit);
344
+ if (exit)
345
+ parts.push(`exit=${exit}`);
346
+ return `[${parts.join(' ')}]`;
347
+ }
348
+ /** Encode a bare channel set (`entry` / `exit`) as `chId=val,chId=val`. */
349
+ function encodeChannelSet(ch) {
350
+ return Object.entries(ch)
351
+ .map(([id, v]) => `${id}=${encodeChannelValue(v)}`)
352
+ .join(',');
353
+ }
354
+ /** A number as a compact token: round to 3 decimals, no trailing zeros. */
355
+ function compactNum(n) {
356
+ return String(Math.round(n * 1000) / 1000);
357
+ }
358
+ // Quote a token when it would otherwise break tokenization (whitespace, `]`, quotes, empty).
359
+ // The tokenizer has no escape syntax, so embedded double quotes are dropped.
360
+ function token(v) {
361
+ const s = String(v);
362
+ if (s === '' || /[\s"\]]/.test(s))
363
+ return `"${s.replace(/"/g, '')}"`;
364
+ return s;
365
+ }
@@ -0,0 +1,17 @@
1
+ export declare const FORMAT_VERSIONS: Readonly<{
2
+ /** Project / IR document (`ProjectMeta.schemaVersion`) — `migrateProject` brings
3
+ * older projects forward, one table step per bump. */
4
+ irSchema: 11;
5
+ /** Chunked-streaming manifest (`ChunkManifest.format`) — the loaders gate on it. */
6
+ chunkManifest: 1;
7
+ /** Script package (`nilvn.json` `format`) — `checkPackageManifest` gates on it. */
8
+ package: 1;
9
+ /** Engine `SaveState.v` — `restoreState` returns false for any other value. */
10
+ saveState: 2;
11
+ /** The menu plugin's save-slot wrapper (`SlotPayload.v`). */
12
+ saveSlot: 1;
13
+ /** The plugin manifest / runtime contract (`PluginManifest.apiVersion`) —
14
+ * `validatePluginManifest` rejects a newer one. */
15
+ pluginApi: 2;
16
+ }>;
17
+ export type FormatVersions = typeof FORMAT_VERSIONS;
@@ -0,0 +1,25 @@
1
+ // The compatibility table:
2
+ // every independently-versioned on-disk / on-wire shape in one place, so a
3
+ // reader can see at a glance which numbers exist and which code gates them.
4
+ // Each is bumped ONLY when its own shape changes incompatibly; none is tied to
5
+ // a package's SemVer.
6
+ import { CURRENT_SCHEMA_VERSION } from './ir.js';
7
+ import { CHUNK_MANIFEST_FORMAT } from './chunk.js';
8
+ import { PACKAGE_FORMAT } from './package.js';
9
+ import { PLUGIN_API_VERSION } from './plugin-manifest.js';
10
+ export const FORMAT_VERSIONS = Object.freeze({
11
+ /** Project / IR document (`ProjectMeta.schemaVersion`) — `migrateProject` brings
12
+ * older projects forward, one table step per bump. */
13
+ irSchema: CURRENT_SCHEMA_VERSION,
14
+ /** Chunked-streaming manifest (`ChunkManifest.format`) — the loaders gate on it. */
15
+ chunkManifest: CHUNK_MANIFEST_FORMAT,
16
+ /** Script package (`nilvn.json` `format`) — `checkPackageManifest` gates on it. */
17
+ package: PACKAGE_FORMAT,
18
+ /** Engine `SaveState.v` — `restoreState` returns false for any other value. */
19
+ saveState: 2,
20
+ /** The menu plugin's save-slot wrapper (`SlotPayload.v`). */
21
+ saveSlot: 1,
22
+ /** The plugin manifest / runtime contract (`PluginManifest.apiVersion`) —
23
+ * `validatePluginManifest` rejects a newer one. */
24
+ pluginApi: PLUGIN_API_VERSION,
25
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@nilvn/core",
3
+ "version": "0.14.0",
4
+ "type": "module",
5
+ "description": "NilVN shared core — the project document model (IR), command / plugin-manifest schemas, script-package format and serializers",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/nilzx/nilvn-engine.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "keywords": [
13
+ "nilvn",
14
+ "visual-novel",
15
+ "adv",
16
+ "galgame"
17
+ ],
18
+ "main": "dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "files": [
21
+ "dist",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
25
+ "sideEffects": false,
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.9.0"
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "tsc -p tsconfig.lib.json"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js"
40
+ }
41
+ }
42
+ }