@nilvn/core 0.17.0 → 0.17.1

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.
@@ -113,7 +113,7 @@ export function buildChunkedExport(project, opts) {
113
113
  for (const sceneId of chunk.scenes) {
114
114
  const scene = byId.get(sceneId);
115
115
  for (const n of scene.nodes) {
116
- if (n.kind === 'jump')
116
+ if (n.kind === 'jump' || n.kind === 'call')
117
117
  addEdge(targetSceneId(n.target, sceneIds));
118
118
  else if (n.kind === 'choice')
119
119
  for (const o of n.options)
package/dist/commands.js CHANGED
@@ -3,7 +3,11 @@
3
3
  //
4
4
  // Excluded on purpose:
5
5
  // - use / alias / actor -> project setup (Project.plugins / alias / actors), not nodes
6
- // - jump / if / set -> first-class node kinds (JumpNode / JumpNode+condition / SetNode)
6
+ // - jump / if / set / call / return -> first-class node kinds (JumpNode / JumpNode+condition / SetNode / CallNode / ReturnNode)
7
+ // - choices / persist -> fields of ChoiceNode / VariableDef (serialize.ts emits them)
8
+ /** The scene transitions `[trans]` and `[bg trans=]` know (the engine's TransitionKind). */
9
+ const TRANSITION_OPTIONS = ['fade', 'crossfade', 'wipe', 'slide', 'circle', 'blinds', 'rule'].map((value) => ({ value, label: `cmd.trans.kind.${value}` }));
10
+ const DIR_OPTIONS = ['left', 'right', 'up', 'down'].map((value) => ({ value, label: `cmd.trans.dir.${value}` }));
7
11
  export const BUILTIN_COMMANDS = [
8
12
  {
9
13
  name: 'bg',
@@ -15,6 +19,93 @@ export const BUILTIN_COMMANDS = [
15
19
  { key: 'bg', label: 'cmd.bg.bg', type: 'asset:bg', positional: 0 },
16
20
  { key: 'color', label: 'cmd.bg.color', type: 'color' },
17
21
  { key: 'fade', label: 'cmd.bg.fade', type: 'number', default: 0 },
22
+ // `trans=` swaps through a scene transition instead of a cross-fade.
23
+ { key: 'trans', label: 'cmd.bg.trans', type: 'enum', options: TRANSITION_OPTIONS, advanced: true },
24
+ { key: 'dir', label: 'cmd.trans.dir', type: 'enum', options: DIR_OPTIONS, advanced: true },
25
+ { key: 'duration', label: 'cmd.trans.duration', type: 'number', advanced: true },
26
+ { key: 'mask', label: 'cmd.trans.mask', type: 'asset:bg', advanced: true },
27
+ ],
28
+ },
29
+ {
30
+ name: 'trans',
31
+ label: 'cmd.trans.label',
32
+ category: 'fx',
33
+ icon: '🎞',
34
+ hint: 'cmd.trans.hint',
35
+ params: [
36
+ { key: 'kind', label: 'cmd.trans.kind', type: 'enum', required: true, positional: 0, default: 'fade', options: [...TRANSITION_OPTIONS, { value: 'end', label: 'cmd.trans.kind.end' }] },
37
+ { key: 'duration', label: 'cmd.trans.duration', type: 'number', default: 0.6 },
38
+ { key: 'dir', label: 'cmd.trans.dir', type: 'enum', options: DIR_OPTIONS },
39
+ { key: 'color', label: 'cmd.trans.color', type: 'color', advanced: true },
40
+ { key: 'mask', label: 'cmd.trans.mask', type: 'asset:bg', advanced: true },
41
+ { key: 'softness', label: 'cmd.trans.softness', type: 'number', default: 0.1, advanced: true },
42
+ ],
43
+ },
44
+ {
45
+ name: 'preload',
46
+ label: 'cmd.preload.label',
47
+ category: 'stage',
48
+ icon: '⏳',
49
+ hint: 'cmd.preload.hint',
50
+ params: [
51
+ { key: 'assets', label: 'cmd.preload.assets', type: 'string', required: true, positional: 0, list: true },
52
+ { key: 'wait', label: 'cmd.preload.wait', type: 'boolean', default: false },
53
+ ],
54
+ },
55
+ {
56
+ name: 'ui',
57
+ label: 'cmd.ui.label',
58
+ category: 'stage',
59
+ icon: '🪟',
60
+ hint: 'cmd.ui.hint',
61
+ params: [
62
+ {
63
+ key: 'op',
64
+ label: 'cmd.ui.op',
65
+ type: 'enum',
66
+ required: true,
67
+ positional: 0,
68
+ default: 'show',
69
+ options: [
70
+ { value: 'show', label: 'cmd.ui.op.show' },
71
+ { value: 'hide', label: 'cmd.ui.op.hide' },
72
+ { value: 'toggle', label: 'cmd.ui.op.toggle' },
73
+ ],
74
+ },
75
+ { key: 'id', label: 'cmd.ui.id', type: 'panel', required: true, positional: 1 },
76
+ ],
77
+ },
78
+ {
79
+ name: 'hotspot',
80
+ label: 'cmd.hotspot.label',
81
+ category: 'stage',
82
+ icon: '🎯',
83
+ hint: 'cmd.hotspot.hint',
84
+ params: [
85
+ // `[hotspot clear]` / `[hotspot remove <id>]` reuse the id slot with a keyword.
86
+ { key: 'id', label: 'cmd.hotspot.id', type: 'string', required: true, positional: 0 },
87
+ { key: 'target', label: 'cmd.hotspot.target', type: 'string', positional: 1, advanced: true },
88
+ { key: 'x', label: 'cmd.hotspot.x', type: 'number', default: 0 },
89
+ { key: 'y', label: 'cmd.hotspot.y', type: 'number', default: 0 },
90
+ { key: 'w', label: 'cmd.hotspot.w', type: 'number', default: 10 },
91
+ { key: 'h', label: 'cmd.hotspot.h', type: 'number', default: 10 },
92
+ { key: 'onclick', label: 'cmd.hotspot.onclick', type: 'script' },
93
+ { key: 'if', label: 'cmd.hotspot.if', type: 'expr' },
94
+ ],
95
+ },
96
+ {
97
+ name: 'input',
98
+ label: 'cmd.input.label',
99
+ category: 'flow',
100
+ icon: '⌨️',
101
+ hint: 'cmd.input.hint',
102
+ params: [
103
+ { key: 'var', label: 'cmd.input.var', type: 'variable', required: true, positional: 0 },
104
+ { key: 'prompt', label: 'cmd.input.prompt', type: 'key' },
105
+ { key: 'default', label: 'cmd.input.default', type: 'string' },
106
+ { key: 'persist', label: 'cmd.input.persist', type: 'boolean', default: false },
107
+ { key: 'maxlength', label: 'cmd.input.maxlength', type: 'number', advanced: true },
108
+ { key: 'pattern', label: 'cmd.input.pattern', type: 'string', advanced: true },
18
109
  ],
19
110
  },
20
111
  {
package/dist/ir.d.ts CHANGED
@@ -91,8 +91,13 @@ export interface ReplaySegment {
91
91
  * migration maps bundled short names (`textfx` → `app.nilvn.textfx`) and keeps
92
92
  * unknown names verbatim (the editor reports them, nothing is dropped).
93
93
  * v12: `Project.config` — the work's engine configuration (the JSON form of
94
- * nilvn.config.toml), exported as the package's `config`. Pure addition. */
95
- export declare const CURRENT_SCHEMA_VERSION = 12;
94
+ * nilvn.config.toml), exported as the package's `config`. Pure addition.
95
+ * v13: layered sprites and the actor's plugin fields — `Actor.textColor` /
96
+ * `canvas` / `layers` / `ext` (pure additions) and `Actor.voice` moved under
97
+ * `ext['app.nilvn.voicefx']` (migrated); `ChoiceNode.timer` / `timerDefault`,
98
+ * `ChoiceOption.disabled`, `VariableDef.persist`, `CallNode` / `ReturnNode`
99
+ * (pure additions). */
100
+ export declare const CURRENT_SCHEMA_VERSION = 13;
96
101
  /** Bring a loaded project up to CURRENT_SCHEMA_VERSION in place (then return it).
97
102
  * Call once on load, after reading from disk. Runs every table step the project
98
103
  * predates, in order, then stamps the current version. */
@@ -129,18 +134,41 @@ export interface SceneMapChapter {
129
134
  sceneIds: string[];
130
135
  collapsed?: boolean;
131
136
  }
137
+ /** One layer of a layered sprite. `src` is a path template with `{<layer>}`
138
+ * for the value (`char/yuki/body-{body}.webp`); `values` is the editor's
139
+ * picker list — the `face` layer's values are the actor's `faces`. */
140
+ export interface ActorLayer {
141
+ src: string;
142
+ /** The value shown until a command sets one. */
143
+ default?: string;
144
+ /** Where a cropped layer image sits on the canvas, in canvas pixels. */
145
+ offset?: [number, number];
146
+ /** May be unset (`none` clears it). */
147
+ optional?: boolean;
148
+ values?: string[];
149
+ }
132
150
  export interface Actor {
133
151
  id: string;
134
152
  /** Display name is localizable, hence a catalog key. */
135
153
  nameKey: string;
154
+ /** Name-tag BACKGROUND colour. */
136
155
  color?: string;
156
+ /** Name-tag TEXT colour (the theme's `name-color` when absent). */
157
+ textColor?: string;
137
158
  /** Sprite URL template; `{face}` is replaced by the current face. */
138
159
  sprites: string;
139
160
  /** Available faces, for the editor's expression picker. */
140
161
  faces: string[];
141
162
  defaultFace?: string;
142
- /** Base pitch (Hz) for the voicefx plugin. */
143
- voice?: number;
163
+ /** Layered sprite: the shared canvas in image pixels the layers align on. */
164
+ canvas?: [number, number];
165
+ /** Layered sprite: named layers composed bottom to top in declaration order.
166
+ * `face` is the layer `say.face` / `[char id face]` drive; the others change
167
+ * through `[char id body=casual]`. Wins over `sprites` when present. */
168
+ layers?: Record<string, ActorLayer>;
169
+ /** Plugin-declared fields by plugin id (`contributes.actorFields`) — the
170
+ * voicefx pitch lives at `ext['app.nilvn.voicefx'].voice` (v13; was `voice`). */
171
+ ext?: Record<string, Record<string, unknown>>;
144
172
  /** The character's reproducible recipe (provenance):
145
173
  * typically `source:'face-creator'`, carrying the whole def so any pose can be
146
174
  * re-rendered at conversion time (the expression-set "registration template" a
@@ -154,6 +182,10 @@ export interface VariableDef {
154
182
  default: number | boolean | string;
155
183
  /** Display name in the editor. */
156
184
  label?: string;
185
+ /** Kept across saves, restarts and runs (the engine's `[persist]` table: the
186
+ * stored value wins, `default` seeds the first run). Exported into the
187
+ * package config's `persist` section, never as a script command. */
188
+ persist?: boolean;
157
189
  }
158
190
  export interface ResourceRegistry {
159
191
  backgrounds: AssetRef[];
@@ -234,7 +266,7 @@ export interface Scene {
234
266
  titleKey: string;
235
267
  nodes: SceneNode[];
236
268
  }
237
- export type SceneNode = SayNode | NarrateNode | CommandNode | ChoiceNode | SetNode | JumpNode | LabelNode | RecallNode | AnimNode | EventFrameNode | LoopStartNode | LoopStopNode;
269
+ export type SceneNode = SayNode | NarrateNode | CommandNode | ChoiceNode | SetNode | JumpNode | CallNode | ReturnNode | LabelNode | RecallNode | AnimNode | EventFrameNode | LoopStartNode | LoopStopNode;
238
270
  export type NodeKind = SceneNode['kind'];
239
271
  interface NodeBase {
240
272
  /** Stable id generated by the editor; jumps/references point at it. */
@@ -268,12 +300,19 @@ export interface CommandNode extends NodeBase {
268
300
  export interface ChoiceNode extends NodeBase {
269
301
  kind: 'choice';
270
302
  options: ChoiceOption[];
303
+ /** Seconds the prompt waits before picking `timerDefault` by itself (the
304
+ * engine's `[choices timer=]`); absent = what the `[choices]` config says. */
305
+ timer?: number;
306
+ /** The option a timeout picks, counted from 1 among the shown options. */
307
+ timerDefault?: number;
271
308
  }
272
309
  export interface ChoiceOption {
273
310
  labelKey: string;
274
311
  target: JumpTarget;
275
312
  /** Shown only when truthy. */
276
313
  condition?: string;
314
+ /** Shown greyed and unpickable when truthy (`disabled=`). */
315
+ disabled?: string;
277
316
  }
278
317
  export interface SetNode extends NodeBase {
279
318
  kind: 'set';
@@ -284,6 +323,15 @@ export interface JumpNode extends NodeBase {
284
323
  kind: 'jump';
285
324
  target: JumpTarget;
286
325
  }
326
+ /** `[call label]`: jump there and come back at the next `[return]` (calls nest). */
327
+ export interface CallNode extends NodeBase {
328
+ kind: 'call';
329
+ target: JumpTarget;
330
+ }
331
+ /** `[return]`: back to the line after the last `[call]`. */
332
+ export interface ReturnNode extends NodeBase {
333
+ kind: 'return';
334
+ }
287
335
  export interface LabelNode extends NodeBase {
288
336
  kind: 'label';
289
337
  name: string;
package/dist/ir.js CHANGED
@@ -42,8 +42,13 @@ import { resolvePluginId } from './plugins.js';
42
42
  * migration maps bundled short names (`textfx` → `app.nilvn.textfx`) and keeps
43
43
  * unknown names verbatim (the editor reports them, nothing is dropped).
44
44
  * v12: `Project.config` — the work's engine configuration (the JSON form of
45
- * nilvn.config.toml), exported as the package's `config`. Pure addition. */
46
- export const CURRENT_SCHEMA_VERSION = 12;
45
+ * nilvn.config.toml), exported as the package's `config`. Pure addition.
46
+ * v13: layered sprites and the actor's plugin fields — `Actor.textColor` /
47
+ * `canvas` / `layers` / `ext` (pure additions) and `Actor.voice` moved under
48
+ * `ext['app.nilvn.voicefx']` (migrated); `ChoiceNode.timer` / `timerDefault`,
49
+ * `ChoiceOption.disabled`, `VariableDef.persist`, `CallNode` / `ReturnNode`
50
+ * (pure additions). */
51
+ export const CURRENT_SCHEMA_VERSION = 13;
47
52
  /** The stable id a ref (of either shape) points at. */
48
53
  function refId(r) {
49
54
  const raw = r.id ?? r.name;
@@ -156,6 +161,21 @@ const MIGRATIONS = [
156
161
  },
157
162
  // v11 -> v12: `Project.config` (the work's engine configuration). Pure addition
158
163
  // — a project without one plays with the engine's defaults.
164
+ // v12 -> v13: the voicefx pitch (`Actor.voice`) moves under `ext['app.nilvn.voicefx']`
165
+ // like every plugin actor field; the layered-sprite fields are pure additions.
166
+ {
167
+ to: 13,
168
+ run: (p) => {
169
+ for (const a of Object.values(p.actors)) {
170
+ const legacy = a;
171
+ if (legacy.voice === undefined)
172
+ continue;
173
+ const ext = (a.ext ??= {});
174
+ ext['app.nilvn.voicefx'] = { ...ext['app.nilvn.voicefx'], voice: legacy.voice };
175
+ delete legacy.voice;
176
+ }
177
+ },
178
+ },
159
179
  ];
160
180
  /** Bring a loaded project up to CURRENT_SCHEMA_VERSION in place (then return it).
161
181
  * Call once on load, after reading from disk. Runs every table step the project
package/dist/package.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Project, WorkConfig } from './ir.js';
1
+ import type { Actor, Project, WorkConfig } from './ir.js';
2
2
  import { type ChunkManifest, type ManifestAsset } from './chunk.js';
3
3
  import { type BuildChunkedOptions, type ChunkFile } from './chunk-build.js';
4
4
  /** Script-package format version. Bumped only when nilvn.json's shape changes
@@ -12,13 +12,32 @@ export declare const PACKAGE_MANIFEST_FILE = "nilvn.json";
12
12
  export interface PackageActor {
13
13
  name?: string;
14
14
  nameKey?: string;
15
+ /** Name-tag background colour. */
15
16
  color?: string;
17
+ /** Name-tag text colour. */
18
+ textColor?: string;
16
19
  /** Sprite URL template; `{face}` is replaced by the current face. */
17
20
  sprites?: string;
18
21
  defaultFace?: string;
19
- /** Base pitch (Hz) for the voice blip. */
22
+ /** Layered sprite: the shared canvas in image pixels (`[600, 1100]`). */
23
+ canvas?: [number, number];
24
+ /** Layered sprite: named layers composed bottom to top (`face` follows the
25
+ * face; the others change through `[char id body=casual]`). Wins over `sprites`. */
26
+ layers?: Record<string, PackageActorLayer>;
27
+ /** Plugin actor fields by plugin id (`contributes.actorFields`), e.g.
28
+ * `ext['app.nilvn.voicefx'].voice`. */
29
+ ext?: Record<string, Record<string, unknown>>;
30
+ /** @deprecated — the voicefx pitch; the engine moves it under `ext`. */
20
31
  voice?: number;
21
32
  }
33
+ /** One layer of a layered sprite in the package (the engine's `ActorLayerDef`). */
34
+ export interface PackageActorLayer {
35
+ /** Path template; `{<layer>}` is replaced by the layer's value. */
36
+ src: string;
37
+ default?: string;
38
+ offset?: [number, number];
39
+ optional?: boolean;
40
+ }
22
41
  /** One enabled plugin, by its reverse-DNS id (`app.nilvn.textfx`; the engine
23
42
  * also accepts the short names older packages carry).
24
43
  * `entry` = a plugin carried inside the package (its `plugin.json`, relative to
@@ -98,6 +117,8 @@ export interface ScriptPackagePlan {
98
117
  /** Languages the finished work can switch between: the default plus every
99
118
  * declared language that ships a non-empty catalog. The default comes first. */
100
119
  export declare function packageLanguages(project: Project): string[];
120
+ /** The layer table minus the editor's picker lists. */
121
+ export declare function packageLayers(layers: NonNullable<Actor['layers']>): Record<string, PackageActorLayer>;
101
122
  /** The runtime actor table straight from the IR (`name` = the default-language
102
123
  * display name, falling back to the id). */
103
124
  export declare function packageActors(project: Project): Record<string, PackageActor>;
package/dist/package.js CHANGED
@@ -43,13 +43,31 @@ export function packageLanguages(project) {
43
43
  const extra = declared.filter((l) => l !== def && Object.keys(project.catalogs[l] ?? {}).length > 0);
44
44
  return [def, ...extra];
45
45
  }
46
+ /** The layer table minus the editor's picker lists. */
47
+ export function packageLayers(layers) {
48
+ const out = {};
49
+ for (const [name, l] of Object.entries(layers)) {
50
+ out[name] = { src: l.src, ...(l.default ? { default: l.default } : {}), ...(l.offset ? { offset: l.offset } : {}), ...(l.optional ? { optional: true } : {}) };
51
+ }
52
+ return out;
53
+ }
46
54
  /** The runtime actor table straight from the IR (`name` = the default-language
47
55
  * display name, falling back to the id). */
48
56
  export function packageActors(project) {
49
57
  const cat = project.catalogs[project.meta.defaultLang] ?? {};
50
58
  const out = {};
51
59
  for (const [id, a] of Object.entries(project.actors)) {
52
- out[id] = { name: cat[a.nameKey] ?? id, nameKey: a.nameKey, color: a.color, sprites: a.sprites, defaultFace: a.defaultFace, voice: a.voice };
60
+ out[id] = {
61
+ name: cat[a.nameKey] ?? id,
62
+ nameKey: a.nameKey,
63
+ color: a.color,
64
+ sprites: a.sprites,
65
+ defaultFace: a.defaultFace,
66
+ ...(a.textColor ? { textColor: a.textColor } : {}),
67
+ ...(a.canvas ? { canvas: a.canvas } : {}),
68
+ ...(a.layers ? { layers: packageLayers(a.layers) } : {}),
69
+ ...(a.ext && Object.keys(a.ext).length ? { ext: a.ext } : {}),
70
+ };
53
71
  }
54
72
  return out;
55
73
  }
package/dist/schema.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ParamType = 'string' | 'number' | 'boolean' | 'enum' | 'asset:bg' | 'asset:bgm' | 'asset:se' | 'asset:sprite' | 'actor' | 'face' | 'color' | 'expr';
1
+ export type ParamType = 'string' | 'number' | 'boolean' | 'enum' | 'asset:bg' | 'asset:bgm' | 'asset:se' | 'asset:sprite' | 'actor' | 'face' | 'color' | 'expr' | 'variable' | 'panel' | 'key' | 'script';
2
2
  export interface ParamOption {
3
3
  value: string;
4
4
  label: string;
@@ -18,6 +18,9 @@ export interface ParamSchema {
18
18
  positional?: number;
19
19
  /** Tuck behind an "advanced" disclosure in the editor. */
20
20
  advanced?: boolean;
21
+ /** A positional that holds several values: the value is whitespace-separated
22
+ * and serializes as that many positional tokens (`[preload a.png b.wav]`). */
23
+ list?: boolean;
21
24
  }
22
25
  export type CommandCategory = 'stage' | 'audio' | 'fx' | 'flow' | 'text';
23
26
  export interface CommandSchema {
package/dist/serialize.js CHANGED
@@ -169,13 +169,28 @@ function serializeNode(node, text, commands, scope = null) {
169
169
  }
170
170
  case 'narrate':
171
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');
172
+ case 'choice': {
173
+ // `if=` / `disabled=` are read to the tag's end and unquoted by the engine
174
+ // (they carry spaces), so they go last and raw. A timer rides a `[choices]`
175
+ // line right before the prompt.
176
+ const lines = node.options.map((o) => {
177
+ const guards = [o.condition ? `if=${o.condition}` : '', o.disabled ? `disabled=${o.disabled}` : ''].filter(Boolean);
178
+ return `[choice ${token(text(o.labelKey))} -> ${dest(o.target, scope)}${guards.length ? ' ' + guards.join(' ') : ''}]`;
179
+ });
180
+ if (node.timer !== undefined || node.timerDefault !== undefined) {
181
+ const parts = ['choices'];
182
+ if (node.timer !== undefined)
183
+ parts.push(`timer=${node.timer}`);
184
+ if (node.timerDefault !== undefined)
185
+ parts.push(`default=${node.timerDefault}`);
186
+ lines.unshift(`[${parts.join(' ')}]`);
187
+ }
188
+ return lines.join('\n');
189
+ }
190
+ case 'call':
191
+ return `[call ${dest(node.target, scope)}]`;
192
+ case 'return':
193
+ return '[return]';
179
194
  case 'jump':
180
195
  // v1 DSL expresses a conditional jump as [if cond -> label].
181
196
  return node.condition
@@ -188,7 +203,7 @@ function serializeNode(node, text, commands, scope = null) {
188
203
  // gallery is built from the IR directly, not from the runtime script.
189
204
  return `; recall ${node.recallId}`;
190
205
  case 'command':
191
- return serializeCommand(node, commands[node.cmd]);
206
+ return serializeCommand(node, commands[node.cmd], text);
192
207
  case 'anim':
193
208
  return serializeAnim(node);
194
209
  case 'eventframe':
@@ -203,10 +218,18 @@ function serializeNode(node, text, commands, scope = null) {
203
218
  }
204
219
  }
205
220
  }
206
- function serializeCommand(node, schema) {
221
+ function serializeCommand(node, schema, text) {
207
222
  const parts = [node.cmd];
208
223
  const params = node.params;
209
224
  const used = new Set();
225
+ // A `key` param holds a catalog `@key`; under keepKeys the engine resolves it,
226
+ // otherwise the literal text goes out like a dialogue line's.
227
+ const value = (p, v) => {
228
+ if (p.type === 'key' && text && typeof v === 'string' && v.startsWith('@'))
229
+ return text(v.slice(1));
230
+ return v;
231
+ };
232
+ const raw = [];
210
233
  if (schema) {
211
234
  const positionals = schema.params
212
235
  .filter((p) => p.positional !== undefined)
@@ -221,7 +244,12 @@ function serializeCommand(node, schema) {
221
244
  for (let i = 0; i <= last; i++) {
222
245
  const p = positionals[i];
223
246
  used.add(p.key);
224
- parts.push(token(params[p.key] ?? p.default ?? ''));
247
+ const v = value(p, params[p.key] ?? p.default ?? '');
248
+ if (p.list)
249
+ for (const item of String(v).split(/\s+/).filter(Boolean))
250
+ parts.push(token(item));
251
+ else
252
+ parts.push(token(v));
225
253
  }
226
254
  for (const p of schema.params) {
227
255
  if (p.positional !== undefined)
@@ -232,7 +260,11 @@ function serializeCommand(node, schema) {
232
260
  continue;
233
261
  if (p.default !== undefined && val === p.default)
234
262
  continue;
235
- parts.push(`${p.key}=${token(val)}`);
263
+ // A condition is read to the tag's end unquoted: it goes last, raw.
264
+ if (p.type === 'expr')
265
+ raw.push(`${p.key}=${String(val)}`);
266
+ else
267
+ parts.push(`${p.key}=${token(value(p, val))}`);
236
268
  }
237
269
  }
238
270
  // Params not described by the schema (e.g. unknown plugin command) -> key=value.
@@ -241,7 +273,7 @@ function serializeCommand(node, schema) {
241
273
  continue;
242
274
  parts.push(`${k}=${token(v)}`);
243
275
  }
244
- return `[${parts.join(' ')}]`;
276
+ return `[${[...parts, ...raw].join(' ')}]`;
245
277
  }
246
278
  // A recorded keyframe animation (AnimNode) serializes to an `[anim …]` command the
247
279
  // engine's built-in `anim` handler plays. The whole track rides one compact `kf`
@@ -1,7 +1,7 @@
1
1
  export declare const FORMAT_VERSIONS: Readonly<{
2
2
  /** Project / IR document (`ProjectMeta.schemaVersion`) — `migrateProject` brings
3
3
  * older projects forward, one table step per bump. */
4
- irSchema: 12;
4
+ irSchema: 13;
5
5
  /** Chunked-streaming manifest (`ChunkManifest.format`) — the loaders gate on it. */
6
6
  chunkManifest: 1;
7
7
  /** Script package (`nilvn.json` `format`) — `checkPackageManifest` gates on it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nilvn/core",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "NilVN shared core — the project document model (IR), command / plugin-manifest schemas, script-package format and serializers",
6
6
  "license": "MIT",