@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/catalog.d.ts +54 -0
- package/dist/catalog.js +137 -0
- package/dist/chunk-build.d.ts +37 -0
- package/dist/chunk-build.js +187 -0
- package/dist/chunk.d.ts +106 -0
- package/dist/chunk.js +35 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +181 -0
- package/dist/i18n.d.ts +50 -0
- package/dist/i18n.js +77 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/ir.d.ts +407 -0
- package/dist/ir.js +167 -0
- package/dist/package.d.ts +100 -0
- package/dist/package.js +80 -0
- package/dist/plugin-manifest.d.ts +146 -0
- package/dist/plugin-manifest.js +165 -0
- package/dist/plugins.d.ts +26 -0
- package/dist/plugins.js +42 -0
- package/dist/schema.d.ts +73 -0
- package/dist/schema.js +15 -0
- package/dist/screenplay-format.d.ts +8 -0
- package/dist/screenplay-format.js +96 -0
- package/dist/screenplay.d.ts +99 -0
- package/dist/screenplay.js +161 -0
- package/dist/semver.d.ts +15 -0
- package/dist/semver.js +129 -0
- package/dist/serialize.d.ts +63 -0
- package/dist/serialize.js +365 -0
- package/dist/versions.d.ts +17 -0
- package/dist/versions.js +25 -0
- package/package.json +42 -0
|
@@ -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;
|
package/dist/versions.js
ADDED
|
@@ -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
|
+
}
|