@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/dist/ir.d.ts ADDED
@@ -0,0 +1,407 @@
1
+ export type Lang = string;
2
+ /** textKey -> localized string. Values may carry inline markup ({wave:..}, {w:0.5}, {br}). */
3
+ export type TextCatalog = Record<string, string>;
4
+ export interface Project {
5
+ meta: ProjectMeta;
6
+ actors: Record<string, Actor>;
7
+ variables: VariableDef[];
8
+ resources: ResourceRegistry;
9
+ plugins: PluginRef[];
10
+ scenes: Scene[];
11
+ catalogs: Record<Lang, TextCatalog>;
12
+ /** Project-level reusable loop cycles (the loop library). Authoring metadata only — these never serialize into the playable
13
+ * script (a `LoopStartNode` inlines its own body). Optional: older projects and
14
+ * freshly-created ones simply have none. */
15
+ loopClips?: LoopClip[];
16
+ /** Authored A–B replay segments (the replay gallery, schema v7). Each records
17
+ * two node position ids; the serializer emits a start label + an end marker, and
18
+ * the engine unlocks a segment when normal play passes its end. Optional: older
19
+ * projects simply have none. */
20
+ replays?: ReplaySegment[];
21
+ }
22
+ /** A node position id: which scene, which node — the timeline's stable locator
23
+ * (`NodeBase.id` is minted once and survives edits around it). */
24
+ export interface NodeAnchor {
25
+ sceneId: string;
26
+ nodeId: string;
27
+ }
28
+ /** An authored A–B replay segment: plays from the `start` node through the `end`
29
+ * node (inclusive). Unlocked for the player's replay gallery once normal play
30
+ * passes the end marker. The end anchor should be a node normal flow actually
31
+ * passes THROUGH (a dialogue / command) — a choice or jump node leaves before
32
+ * the marker after it would run. */
33
+ export interface ReplaySegment {
34
+ id: string;
35
+ /** Localized gallery title (catalog key, like scene titles). */
36
+ titleKey: string;
37
+ start: NodeAnchor;
38
+ end: NodeAnchor;
39
+ }
40
+ /** Current IR / project format version. Bump when the on-disk IR shape changes
41
+ * in a way that needs a migration. Independent of the studio app version and of
42
+ * `ProjectMeta.version` (the author's own content version).
43
+ *
44
+ * v2: `plugins` became the authoritative enabled-set (empty = all disabled).
45
+ * Pre-v2 projects always ran every bundled plugin, so the migration fills
46
+ * an empty `plugins` with all bundled plugins enabled. See migrateProject.
47
+ * v3: `resources.spritesheets` added (sprite-frame animation).
48
+ * v4: `AnimNode` (recorded keyframe animations) joined the SceneNode union — a
49
+ * pure addition (older projects simply have none), so no data migration; the
50
+ * bump only marks the format so an older reader knows it's newer.
51
+ * v5: the recording animation redesign joined the union:
52
+ * `EventFrameNode` (scene-level event-frame) and `LoopStartNode` /
53
+ * `LoopStopNode` (single-element async loops), plus the project-level
54
+ * `loopClips` loop library. All pure additions (older projects have none), so no
55
+ * data migration; the bump only stamps the format. The whole redesign ships as
56
+ * one unreleased batch, hence one schema version covering every new node kind
57
+ * (and the loop library) rather than a bump per increment.
58
+ * v6: asset provenance metadata — optional `provenance` /
59
+ * `box` / `canonicalName` on `AssetRef`, and optional `origin` on `Actor`. The
60
+ * groundwork for the Web→Pro conversion (where a vector SVG placeholder is the
61
+ * registration template a raster replacement aligns onto). All pure additions
62
+ * (older projects / assets simply have none = unknown source), so no data
63
+ * migration; the bump only stamps the format. Authoring-only metadata — it
64
+ * never serializes into the playable script (the DSL carries only the resolved
65
+ * asset path; see serialize.ts).
66
+ * v7: `Project.replays` — authored A–B replay segments (two node
67
+ * position ids each). A pure optional addition (older projects have none), so
68
+ * no data migration; the bump only stamps the format. Unlike most authoring
69
+ * metadata these DO reach the runtime script: serialize.ts emits a
70
+ * `[replaydef]` preamble, a start label and an end marker per segment.
71
+ * v8–v10: the pluginization backfills (abreplay / animstudio / voicerecord became
72
+ * plugins) — see the migration table.
73
+ * v11: plugin platform v2 — `PluginRef`
74
+ * is `{ id, version?, config? }` keyed by the plugin's reverse-DNS id; the
75
+ * migration maps bundled short names (`textfx` → `app.nilvn.textfx`) and keeps
76
+ * unknown names verbatim (the editor reports them, nothing is dropped). */
77
+ export declare const CURRENT_SCHEMA_VERSION = 11;
78
+ /** Bring a loaded project up to CURRENT_SCHEMA_VERSION in place (then return it).
79
+ * Call once on load, after reading from disk. Runs every table step the project
80
+ * predates, in order, then stamps the current version. */
81
+ export declare function migrateProject(project: Project): Project;
82
+ export interface ProjectMeta {
83
+ id: string;
84
+ title: string;
85
+ /** The author's own content version of their work (not the format/app version). */
86
+ version: string;
87
+ defaultLang: Lang;
88
+ languages: Lang[];
89
+ /** Default typewriter speed in characters per second. */
90
+ textSpeed?: number;
91
+ /** Release resolution imported images are compressed to (default 1440p). */
92
+ shipRes?: '1080p' | '1440p' | '2160p';
93
+ /** Editor-only UI state. It does not affect playback semantics. */
94
+ editor?: EditorProjectMeta;
95
+ /** IR / project format version, for gating future format migrations.
96
+ * Backfilled to CURRENT_SCHEMA_VERSION on load when absent. */
97
+ schemaVersion?: number;
98
+ }
99
+ export interface EditorProjectMeta {
100
+ sceneMap?: {
101
+ positions?: Record<string, {
102
+ x: number;
103
+ y: number;
104
+ }>;
105
+ chapters?: SceneMapChapter[];
106
+ };
107
+ }
108
+ export interface SceneMapChapter {
109
+ id: string;
110
+ title: string;
111
+ sceneIds: string[];
112
+ collapsed?: boolean;
113
+ }
114
+ export interface Actor {
115
+ id: string;
116
+ /** Display name is localizable, hence a catalog key. */
117
+ nameKey: string;
118
+ color?: string;
119
+ /** Sprite URL template; `{face}` is replaced by the current face. */
120
+ sprites: string;
121
+ /** Available faces, for the editor's expression picker. */
122
+ faces: string[];
123
+ defaultFace?: string;
124
+ /** Base pitch (Hz) for the voicefx plugin. */
125
+ voice?: number;
126
+ /** The character's reproducible recipe (provenance):
127
+ * typically `source:'face-creator'`, carrying the whole def so any pose can be
128
+ * re-rendered at conversion time (the expression-set "registration template" a
129
+ * raster sprite later aligns onto). Authoring-only and optional; absent = unknown
130
+ * origin. Never reaches the engine wire format. */
131
+ origin?: AssetProvenance;
132
+ }
133
+ export interface VariableDef {
134
+ name: string;
135
+ type: 'number' | 'boolean' | 'string';
136
+ default: number | boolean | string;
137
+ /** Display name in the editor. */
138
+ label?: string;
139
+ }
140
+ export interface ResourceRegistry {
141
+ backgrounds: AssetRef[];
142
+ audio: AudioRef[];
143
+ /** Sprite-frame animation sheets (single-row strips), imported losslessly.
144
+ * Added at schema v3; older projects backfill an empty list on migrate. */
145
+ spritesheets: AssetRef[];
146
+ }
147
+ /** A face-creator character recipe — the `buildCharacterDef` product
148
+ * (`format:'nilvn-face-character'` + base selection/colors + included poses). Core
149
+ * treats it as an OPAQUE payload: it only carries the recipe so the editor /
150
+ * face-creator can re-render any pose later (`applyPose` derives all 8 expressions),
151
+ * and never interprets its internals. Kept structural (not imported from the
152
+ * face-creator package) so @nilvn/core stays zero-dependency and decoupled — the
153
+ * face-creator is a separate sibling whose consumption (vendor / publish / symlink)
154
+ * is still undecided (STRATEGY §8). */
155
+ export interface FaceRecipe {
156
+ format: 'nilvn-face-character';
157
+ [k: string]: unknown;
158
+ }
159
+ /** Where an asset's bytes came from. A discriminated union so the Web→Pro converter
160
+ * can offer the right replacement lane per source (re-render from a recipe, re-run /
161
+ * condition an AI prompt, swap a library id, or accept a manual raster drop). */
162
+ export type AssetProvenance = {
163
+ source: 'face-creator';
164
+ def: FaceRecipe;
165
+ } | {
166
+ source: 'ai-svg';
167
+ prompt: string;
168
+ model?: string;
169
+ } | {
170
+ source: 'builtin';
171
+ libId: string;
172
+ } | {
173
+ source: 'imported';
174
+ };
175
+ /** An asset's intrinsic render box — its native canvas plus an optional anchor — so a
176
+ * replacement can be scaled and positioned to land exactly where the SVG placeholder
177
+ * sat (alignment is where asset replacement most easily breaks; STRATEGY §6). */
178
+ export interface RenderBox {
179
+ /** Intrinsic canvas size (a character = the face-creator 600×1100). */
180
+ w: number;
181
+ h: number;
182
+ /** Anchor in canvas coords (a character's feet / center; from face-creator ANCHOR). */
183
+ anchor?: {
184
+ x: number;
185
+ y: number;
186
+ };
187
+ }
188
+ export interface AssetRef {
189
+ id: string;
190
+ path: string;
191
+ thumb?: string;
192
+ /** Where this asset came from (provenance,). Optional and
193
+ * authoring-only; absent = unknown source. */
194
+ provenance?: AssetProvenance;
195
+ /** Intrinsic render box, for aligning a replacement onto this asset. */
196
+ box?: RenderBox;
197
+ /** Canonical filename (e.g. `yuki__happy.png`) so a commission pack's files
198
+ * auto-match back to their slots with no manual mapping (STRATEGY §6). */
199
+ canonicalName?: string;
200
+ }
201
+ export interface AudioRef extends AssetRef {
202
+ kind: 'bgm' | 'se';
203
+ }
204
+ /** One enabled plugin (schema v11, plugin platform v2). `id` is the manifest's
205
+ * reverse-DNS id (`app.nilvn.textfx`); the hosts resolve bundled ids from their
206
+ * registries and third-party ones from the project's plugin packages. */
207
+ export interface PluginRef {
208
+ id: string;
209
+ /** The version the project was authored against (informational; the host
210
+ * loads whatever version it has and reports a mismatch). */
211
+ version?: string;
212
+ config?: Record<string, unknown>;
213
+ }
214
+ export interface Scene {
215
+ id: string;
216
+ titleKey: string;
217
+ nodes: SceneNode[];
218
+ }
219
+ export type SceneNode = SayNode | NarrateNode | CommandNode | ChoiceNode | SetNode | JumpNode | LabelNode | RecallNode | AnimNode | EventFrameNode | LoopStartNode | LoopStopNode;
220
+ export type NodeKind = SceneNode['kind'];
221
+ interface NodeBase {
222
+ /** Stable id generated by the editor; jumps/references point at it. */
223
+ id: string;
224
+ /** Optional guard expression over project variables; node runs only when truthy. */
225
+ condition?: string;
226
+ }
227
+ export interface SayNode extends NodeBase {
228
+ kind: 'say';
229
+ actor: string;
230
+ face?: string;
231
+ textKey: string;
232
+ /** Optional per-line voice clip (an AssetPath). Plays as the line appears;
233
+ * serialized to a `[voice <path> offset=<sec>]` just before the dialogue, so
234
+ * the engine plays it alongside the line and mutes the synth typing blip. */
235
+ voice?: string;
236
+ /** Start-time correction (seconds) for the voice clip: playback seeks here to
237
+ * skip leading silence. Defaults to 0. */
238
+ voiceOffset?: number;
239
+ }
240
+ export interface NarrateNode extends NodeBase {
241
+ kind: 'narrate';
242
+ textKey: string;
243
+ }
244
+ export interface CommandNode extends NodeBase {
245
+ kind: 'command';
246
+ cmd: string;
247
+ /** Named values; described and validated by the command schema (see schema.ts). */
248
+ params: Record<string, string | number | boolean>;
249
+ }
250
+ export interface ChoiceNode extends NodeBase {
251
+ kind: 'choice';
252
+ options: ChoiceOption[];
253
+ }
254
+ export interface ChoiceOption {
255
+ labelKey: string;
256
+ target: JumpTarget;
257
+ /** Shown only when truthy. */
258
+ condition?: string;
259
+ }
260
+ export interface SetNode extends NodeBase {
261
+ kind: 'set';
262
+ var: string;
263
+ expr: string;
264
+ }
265
+ export interface JumpNode extends NodeBase {
266
+ kind: 'jump';
267
+ target: JumpTarget;
268
+ }
269
+ export interface LabelNode extends NodeBase {
270
+ kind: 'label';
271
+ name: string;
272
+ }
273
+ /** Recollection / CG-gallery marker; the editor builds the replay list from these. */
274
+ export interface RecallNode extends NodeBase {
275
+ kind: 'recall';
276
+ recallId: string;
277
+ titleKey: string;
278
+ thumb?: string;
279
+ }
280
+ /** One keyframe of an AnimNode. `t` is seconds from the clip start. The transform
281
+ * channels mirror the engine's fixed schema: `x` / `y` are pixel translate offsets
282
+ * from the object's birth position, `scale` / `rotation` / `opacity` are absolute.
283
+ * Every keyframe of a clip carries the same channel set (the recorded tracks), so
284
+ * playback never carries a value forward. `ease` is a short easing code for the
285
+ * segment INTO the next keyframe (expanded by the engine; see builtins `[anim]`). */
286
+ export interface AnimKeyframe {
287
+ t: number;
288
+ x?: number;
289
+ y?: number;
290
+ scale?: number;
291
+ rotation?: number;
292
+ opacity?: number;
293
+ ease?: string;
294
+ }
295
+ /** A recorded keyframe animation played on one stage object. A first-class node with its own editor (not a CommandNode);
296
+ * serializes to an `[anim …]` command for the engine. `hold` persists the end pose
297
+ * into the object's resting transform; absent = transient, snaps back. */
298
+ export interface AnimNode extends NodeBase {
299
+ kind: 'anim';
300
+ /** Target stage object id: `character:<id>` / `sprite:<id>` / `camera`. */
301
+ target: string;
302
+ /** Total clip length in seconds; keyframe `t` values lie in [0, duration]. */
303
+ duration: number;
304
+ keyframes: AnimKeyframe[];
305
+ /** Persist the end pose. Absent / false = transient, snaps back. */
306
+ hold?: boolean;
307
+ }
308
+ /** A recordable channel's value. Continuous channels (x/y/scale/rotation/opacity)
309
+ * carry numbers and are interpolated; discrete channels (visible/face/layer/…)
310
+ * carry strings or booleans and snap at the keyframe time. Which channels a kind
311
+ * exposes is declared by the engine's `ObjectKind.recordable` ("dynamic
312
+ * extraction"), so new object types become keyframable with no IR change. */
313
+ export type ChannelValue = number | string | boolean;
314
+ /** One keyframe of an `ElementTrack`. `t` is seconds from the event-frame start.
315
+ * `ch` is SPARSE — only the channels changed at this keyframe (a missing channel
316
+ * carries the previous keyframe's value). Unlike `[anim]`'s decimation, identity values
317
+ * are kept, so re-editing knows which channels were recorded. `ease` is the
318
+ * easing code for the segment INTO the next keyframe of the SAME channel
319
+ * (continuous channels only; discrete channels snap regardless). */
320
+ export interface Keyframe {
321
+ t: number;
322
+ ch: Record<string, ChannelValue>;
323
+ ease?: string;
324
+ }
325
+ /** One stage object's keyframe track inside an `EventFrameNode`. */
326
+ export interface ElementTrack {
327
+ objId: string;
328
+ keys: Keyframe[];
329
+ }
330
+ /** A scene-level "recording event-frame": a one-shot, blocking, multi-element
331
+ * keyframe choreography over a real time-track. Each
332
+ * track's `keys[0]` is kf0 — the full state snapshot captured when recording
333
+ * began. A first-class node with its own editor (not a CommandNode); serializes
334
+ * to an `[eventframe …]` command the engine plays through a pure-JS rAF clock.
335
+ * The save only records the node position (the event-frame is atomic), so a
336
+ * load jumps to kf0 — mid-play progress is never persisted. */
337
+ export interface EventFrameNode extends NodeBase {
338
+ kind: 'eventframe';
339
+ /** Total clip length in seconds; keyframe `t` values lie in [0, duration]. */
340
+ duration: number;
341
+ tracks: ElementTrack[];
342
+ }
343
+ /** Begin a single-element loop: an async,
344
+ * non-blocking, repeating animation on one stage object that runs across later
345
+ * beats until a matching `LoopStopNode` (or a new loop on the same object, or a
346
+ * load / restart). Three parts, split so the cycle can be reused independently:
347
+ * `entry` (the anchor pose set when the loop begins) + `body` (the reusable cycle,
348
+ * played on repeat) + the `exit` carried by the stop node. Serializes to a
349
+ * `[loopstart …]` command. */
350
+ export interface LoopStartNode extends NodeBase {
351
+ kind: 'loopstart';
352
+ /** Target stage object id: `character:<id>` / `sprite:<id>` / `camera`. */
353
+ objId: string;
354
+ /** Cycle length in seconds; `body` keyframe `t` values lie in [0, duration].
355
+ * Continuous channels wrap seamlessly when `body[0]` equals `body[last]`. */
356
+ duration: number;
357
+ /** The loop's anchor pose — channel values the object is set to when the loop
358
+ * begins (typically equal to `body[0]`, plus any channels the body holds
359
+ * constant). Continuous values are numbers, discrete values strings/booleans. */
360
+ entry: Record<string, ChannelValue>;
361
+ /** The reusable cycle: sparse keyframes (same grammar as an `ElementTrack`),
362
+ * played on repeat. Kept structurally separate from `entry` so the same body can
363
+ * drive a project-level named-loop library in a later increment. */
364
+ body: Keyframe[];
365
+ /** Easing code for the bridge from the current pose into `entry` (reserved for
366
+ * the bridge-in tween; the runtime snaps to `entry` until that lands). */
367
+ intoEase?: string;
368
+ }
369
+ /** Stop the single-element loop running on `objId` and settle it. Serializes to a `[loopstop …]` command. */
370
+ export interface LoopStopNode extends NodeBase {
371
+ kind: 'loopstop';
372
+ /** Which running loop to stop (by target object id). */
373
+ objId: string;
374
+ /** The pose the object settles to when the loop stops; committed to the resting
375
+ * model so it persists. */
376
+ exit: Record<string, ChannelValue>;
377
+ /** Easing code for the bridge from the loop pose out to `exit` (reserved for the
378
+ * bridge-out tween; the runtime snaps to `exit` until that lands). */
379
+ outEase?: string;
380
+ }
381
+ /** A reusable recorded loop cycle (the project-level loop library). Authoring-only metadata — it is NOT part of the engine wire format:
382
+ * a `LoopStartNode` inlines its own absolute `body`, so a clip is just the source the
383
+ * editor materializes that body from. Persists with the project (so the author can
384
+ * pick it on later beats), but never serializes into the playable script.
385
+ *
386
+ * `body` keyframes hold values **relative to the recording's start pose** (deltas, with
387
+ * `body[0]` at delta 0). On insert the editor rebases them onto the target element's
388
+ * current pose — `entry[ch] + delta` — so one clip drives the same motion wherever (and
389
+ * on whichever recordable element) it is dropped (this is what "the current state is the first frame"
390
+ * means in practice). Only continuous channels are captured today (a drag samples
391
+ * x/y/scale/rotation/opacity); discrete loops are a later addition. */
392
+ export interface LoopClip {
393
+ id: string;
394
+ /** Author-facing label, shown in the insert picker (auto "Loop N", editable). */
395
+ name: string;
396
+ /** Cycle length in seconds; `body` keyframe `t` values lie in [0, duration]. */
397
+ duration: number;
398
+ /** Sparse delta keyframes (same grammar as an `ElementTrack`'s keys). */
399
+ body: Keyframe[];
400
+ }
401
+ export interface JumpTarget {
402
+ /** Omitted = current scene; set = cross-scene (cross-file) jump. */
403
+ scene?: string;
404
+ /** Target LabelNode.name. */
405
+ label: string;
406
+ }
407
+ export {};
package/dist/ir.js ADDED
@@ -0,0 +1,167 @@
1
+ // NilVN editor document model (IR) — the single source of truth for the creator studio.
2
+ // Consumed by the editor, AI generation, the engine (via compile) and the bundler.
3
+ // Value import (not type-only): resolvePluginId is used by
4
+ // migrateProject below. plugins.ts imports only *types* from here, so there's no
5
+ // runtime import cycle.
6
+ import { resolvePluginId } from './plugins.js';
7
+ /** Current IR / project format version. Bump when the on-disk IR shape changes
8
+ * in a way that needs a migration. Independent of the studio app version and of
9
+ * `ProjectMeta.version` (the author's own content version).
10
+ *
11
+ * v2: `plugins` became the authoritative enabled-set (empty = all disabled).
12
+ * Pre-v2 projects always ran every bundled plugin, so the migration fills
13
+ * an empty `plugins` with all bundled plugins enabled. See migrateProject.
14
+ * v3: `resources.spritesheets` added (sprite-frame animation).
15
+ * v4: `AnimNode` (recorded keyframe animations) joined the SceneNode union — a
16
+ * pure addition (older projects simply have none), so no data migration; the
17
+ * bump only marks the format so an older reader knows it's newer.
18
+ * v5: the recording animation redesign joined the union:
19
+ * `EventFrameNode` (scene-level event-frame) and `LoopStartNode` /
20
+ * `LoopStopNode` (single-element async loops), plus the project-level
21
+ * `loopClips` loop library. All pure additions (older projects have none), so no
22
+ * data migration; the bump only stamps the format. The whole redesign ships as
23
+ * one unreleased batch, hence one schema version covering every new node kind
24
+ * (and the loop library) rather than a bump per increment.
25
+ * v6: asset provenance metadata — optional `provenance` /
26
+ * `box` / `canonicalName` on `AssetRef`, and optional `origin` on `Actor`. The
27
+ * groundwork for the Web→Pro conversion (where a vector SVG placeholder is the
28
+ * registration template a raster replacement aligns onto). All pure additions
29
+ * (older projects / assets simply have none = unknown source), so no data
30
+ * migration; the bump only stamps the format. Authoring-only metadata — it
31
+ * never serializes into the playable script (the DSL carries only the resolved
32
+ * asset path; see serialize.ts).
33
+ * v7: `Project.replays` — authored A–B replay segments (two node
34
+ * position ids each). A pure optional addition (older projects have none), so
35
+ * no data migration; the bump only stamps the format. Unlike most authoring
36
+ * metadata these DO reach the runtime script: serialize.ts emits a
37
+ * `[replaydef]` preamble, a start label and an end marker per segment.
38
+ * v8–v10: the pluginization backfills (abreplay / animstudio / voicerecord became
39
+ * plugins) — see the migration table.
40
+ * v11: plugin platform v2 — `PluginRef`
41
+ * is `{ id, version?, config? }` keyed by the plugin's reverse-DNS id; the
42
+ * migration maps bundled short names (`textfx` → `app.nilvn.textfx`) and keeps
43
+ * unknown names verbatim (the editor reports them, nothing is dropped). */
44
+ export const CURRENT_SCHEMA_VERSION = 11;
45
+ /** The stable id a ref (of either shape) points at. */
46
+ function refId(r) {
47
+ const raw = r.id ?? r.name;
48
+ return raw ? resolvePluginId(raw) : undefined;
49
+ }
50
+ function hasPlugin(p, id) {
51
+ return p.plugins.some((x) => refId(x) === id);
52
+ }
53
+ /** The migration table, oldest first. Each schema bump adds one entry here (or a
54
+ * comment when it is a pure addition). Table-driven so the
55
+ * steps read as data. */
56
+ /** The content plugins a pre-v2 project (empty enabled set = "everything on") is
57
+ * backfilled with. FROZEN history: this is what "all first-party content
58
+ * plugins" meant when the migration was written, by id — the live first-party
59
+ * inventory lives in @nilvn/plugins and must not steer a migration. */
60
+ const LEGACY_DEFAULT_PLUGINS = [
61
+ 'app.nilvn.textfx',
62
+ 'app.nilvn.charfx',
63
+ 'app.nilvn.choicefx',
64
+ 'app.nilvn.screenfx',
65
+ 'app.nilvn.objectfx',
66
+ 'app.nilvn.spriteanim',
67
+ 'app.nilvn.voicefx',
68
+ 'app.nilvn.voicerecord',
69
+ 'app.nilvn.animstudio',
70
+ 'app.nilvn.abreplay',
71
+ ];
72
+ const MIGRATIONS = [
73
+ // v1 -> v2: `plugins` is now the enabled-set. Pre-v2 always ran every bundled
74
+ // plugin, so an empty list then meant "all on" — fill it to preserve behavior.
75
+ // (At v2+ an empty list legitimately means "all disabled" and is left alone.)
76
+ { to: 2, run: (p) => { if (p.plugins.length === 0)
77
+ p.plugins = LEGACY_DEFAULT_PLUGINS.map((id) => ({ id })); } },
78
+ // v2 -> v3: `resources.spritesheets` is a new asset category (sprite-frame
79
+ // animation). Pre-v3 projects have no field — backfill an empty list so every
80
+ // reader can assume it exists.
81
+ { to: 3, run: (p) => { p.resources.spritesheets ??= []; } },
82
+ // v3 -> v4: `AnimNode` joined the SceneNode union. Pure addition — nothing to backfill.
83
+ // v4 -> v5: the recording animation redesign joined the union (`EventFrameNode`,
84
+ // `LoopStartNode`, `LoopStopNode`). Pure additions.
85
+ // v5 -> v6: asset provenance metadata (provenance/box/canonicalName on AssetRef,
86
+ // origin on Actor). Optional pure additions — a missing field just means "unknown source".
87
+ // v6 -> v7: `Project.replays` (A–B replay segments). Optional pure addition.
88
+ // v7 -> v8: A–B replay became the `abreplay` plugin; before v8 it was
89
+ // always-on. Projects that USE it (authored `replays`) get it enabled
90
+ // unconditionally — their exported gallery/unlocks must keep working. Projects
91
+ // that don't get it only when their enabled set is non-empty (keeping the
92
+ // replay tab they were used to seeing); a deliberately emptied set stays
93
+ // authoritative all-off, as it has been since v2.
94
+ {
95
+ to: 8,
96
+ run: (p, ctx) => {
97
+ if ((p.replays?.length || ctx.hadPlugins) && !hasPlugin(p, 'app.nilvn.abreplay')) {
98
+ p.plugins.push({ id: 'app.nilvn.abreplay' });
99
+ }
100
+ },
101
+ },
102
+ // v8 -> v9: keyframe animation became the `animstudio` plugin, same policy as
103
+ // v7→v8 — usage is "any animation node in any scene".
104
+ {
105
+ to: 9,
106
+ run: (p, ctx) => {
107
+ if (hasPlugin(p, 'app.nilvn.animstudio'))
108
+ return;
109
+ const animKinds = new Set(['anim', 'eventframe', 'loopstart', 'loopstop']);
110
+ const used = p.scenes.some((s) => s.nodes.some((n) => animKinds.has(n.kind)));
111
+ if (used || ctx.hadPlugins)
112
+ p.plugins.push({ id: 'app.nilvn.animstudio' });
113
+ },
114
+ },
115
+ // v9 -> v10: the per-line voice authoring UI became the editor-only
116
+ // `voicerecord` plugin, same policy again. Only the popover is gated —
117
+ // [voice] playback stays core — so the backfill is about keeping the voice
118
+ // button visible where the author was using or could see it.
119
+ {
120
+ to: 10,
121
+ run: (p, ctx) => {
122
+ if (hasPlugin(p, 'app.nilvn.voicerecord'))
123
+ return;
124
+ const used = p.scenes.some((s) => s.nodes.some((n) => n.kind === 'say' && !!n.voice));
125
+ if (used || ctx.hadPlugins)
126
+ p.plugins.push({ id: 'app.nilvn.voicerecord' });
127
+ },
128
+ },
129
+ // v10 -> v11: plugin platform v2. Refs are keyed by reverse-DNS id; a dotless
130
+ // (short) name maps into the first-party namespace through resolvePluginId —
131
+ // a first-party short name becomes its id, anything else a valid id the
132
+ // editor's load-time validation reports as unknown (data is never dropped);
133
+ // an id passes verbatim, `entry` is gone (entries live in the manifest),
134
+ // duplicates collapse.
135
+ {
136
+ to: 11,
137
+ run: (p) => {
138
+ const seen = new Set();
139
+ const next = [];
140
+ for (const raw of p.plugins) {
141
+ const id = refId(raw);
142
+ if (!id || seen.has(id))
143
+ continue;
144
+ seen.add(id);
145
+ const ref = { id };
146
+ if (raw.version !== undefined)
147
+ ref.version = raw.version;
148
+ if (raw.config !== undefined)
149
+ ref.config = raw.config;
150
+ next.push(ref);
151
+ }
152
+ p.plugins = next;
153
+ },
154
+ },
155
+ ];
156
+ /** Bring a loaded project up to CURRENT_SCHEMA_VERSION in place (then return it).
157
+ * Call once on load, after reading from disk. Runs every table step the project
158
+ * predates, in order, then stamps the current version. */
159
+ export function migrateProject(project) {
160
+ const from = project.meta.schemaVersion ?? 1;
161
+ const ctx = { hadPlugins: project.plugins.length > 0 };
162
+ for (const m of MIGRATIONS)
163
+ if (from < m.to)
164
+ m.run(project, ctx);
165
+ project.meta.schemaVersion = CURRENT_SCHEMA_VERSION;
166
+ return project;
167
+ }
@@ -0,0 +1,100 @@
1
+ import type { Project } from './ir.js';
2
+ import { type ChunkManifest, type ManifestAsset } from './chunk.js';
3
+ import { type BuildChunkedOptions, type ChunkFile } from './chunk-build.js';
4
+ /** Script-package format version. Bumped only when nilvn.json's shape changes
5
+ * incompatibly; the embedded chunk manifest keeps its own `format`. */
6
+ export declare const PACKAGE_FORMAT = 1;
7
+ /** The package's single entry file, at the package root. */
8
+ export declare const PACKAGE_MANIFEST_FILE = "nilvn.json";
9
+ /** An actor as the runtime needs it (mirrors the engine's `ActorDef`; core
10
+ * cannot import the engine). `name` is the default-language literal (fallback),
11
+ * `nameKey` lets the engine re-resolve the display name on a language switch. */
12
+ export interface PackageActor {
13
+ name?: string;
14
+ nameKey?: string;
15
+ color?: string;
16
+ /** Sprite URL template; `{face}` is replaced by the current face. */
17
+ sprites?: string;
18
+ defaultFace?: string;
19
+ /** Base pitch (Hz) for the voice blip. */
20
+ voice?: number;
21
+ }
22
+ /** One enabled plugin, by its reverse-DNS id (`app.nilvn.textfx`; the engine
23
+ * also accepts the short names older packages carry).
24
+ * `entry` = a plugin carried inside the package (its `plugin.json`, relative to
25
+ * the package root); `integrity` = its digest (reserved). */
26
+ export interface PackagePlugin {
27
+ id: string;
28
+ version?: string;
29
+ entry?: string;
30
+ integrity?: string;
31
+ }
32
+ /** `nilvn.json` — the package manifest. */
33
+ export interface PackageManifest {
34
+ /** = PACKAGE_FORMAT. */
35
+ format: number;
36
+ /** Work title (window / document title). */
37
+ title: string;
38
+ /** The studio / engine version that produced the package — traceability only;
39
+ * compatibility is decided by `format` + `chunks.format` + `chunks.schemaVersion`. */
40
+ engine: string;
41
+ /** Initial content language (the author's language). */
42
+ lang: string;
43
+ /** Languages the in-game switcher offers (the default first). */
44
+ languages: string[];
45
+ actors: Record<string, PackageActor>;
46
+ /** The enabled plugin set, auto-loaded at start (same entries as `[use …]`). */
47
+ plugins: PackagePlugin[];
48
+ /** Typewriter speed, characters per second. */
49
+ textSpeed: number;
50
+ /** Per-work id the runtime namespaces saves / settings by. */
51
+ saveKey: string;
52
+ /** The chunk manifest (chunk.ts), embedded as-is: chunk / locale-slice / asset
53
+ * index + entry label. Wire files (`chunks/**`, `assets/**`) are unchanged. */
54
+ chunks: ChunkManifest;
55
+ }
56
+ /** Structural check for a parsed nilvn.json — a foreign or truncated file fails
57
+ * cleanly here instead of deep inside playback. Does NOT check `format`
58
+ * numbers (callers gate those so they can word the mismatch message). */
59
+ export declare function isPackageManifest(x: unknown): x is PackageManifest;
60
+ export interface BuildPackageOptions extends BuildChunkedOptions {
61
+ /** Defaults to the project title (or "NilVN"). */
62
+ title?: string;
63
+ /** Defaults to the project's default language. */
64
+ lang?: string;
65
+ /** Defaults to {@link packageLanguages}. */
66
+ languages?: string[];
67
+ /** Defaults to {@link packageActors} (the IR actors as-is; the editor passes
68
+ * its own table so actors without artwork get a placeholder sprite). */
69
+ actors?: Record<string, PackageActor>;
70
+ /** Defaults to the project's enabled plugin ids. The editor appends the
71
+ * finished-game shell (`app.nilvn.menu`) and drops plugins without an engine half. */
72
+ plugins?: PackagePlugin[];
73
+ /** Defaults to the project text speed (40). */
74
+ textSpeed?: number;
75
+ /** Defaults to the project id, else the title. */
76
+ saveKey?: string;
77
+ }
78
+ export interface ScriptPackagePlan {
79
+ /** nilvn.json, with `chunks.assets` still EMPTY — the producer resolves the
80
+ * refs to files and fills it via {@link fillPackageAssets}. */
81
+ manifest: PackageManifest;
82
+ /** chunks/meta.json + chunks/scene/*.json + chunks/locale/<lang>/*.json. */
83
+ files: ChunkFile[];
84
+ /** Deduped asset refs the scenes reference (actor faces are NOT here — they
85
+ * belong to the whole-project universe the producer resolves separately). */
86
+ assetRefs: string[];
87
+ }
88
+ /** Languages the finished work can switch between: the default plus every
89
+ * declared language that ships a non-empty catalog. The default comes first. */
90
+ export declare function packageLanguages(project: Project): string[];
91
+ /** The runtime actor table straight from the IR (`name` = the default-language
92
+ * display name, falling back to the id). */
93
+ export declare function packageActors(project: Project): Record<string, PackageActor>;
94
+ /** Build a script package from a project: the chunked script side (one chunk per
95
+ * scene by default; `groups` merges scenes — a single group = one chunk, the
96
+ * single-file / asset-ZIP shape) plus the manifest fields the shells used to bake
97
+ * into their bootstraps. Pure, zero-I/O. */
98
+ export declare function buildScriptPackage(project: Project, opts: BuildPackageOptions): ScriptPackagePlan;
99
+ /** Fill the by-ref asset table (`chunks.assets`). Returns a new manifest. */
100
+ export declare function fillPackageAssets(manifest: PackageManifest, assets: Record<string, ManifestAsset>): PackageManifest;