@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nilzx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @nilvn/core
2
+
3
+ The contract layer of [NilVN](https://github.com/nilzx/nilvn-engine), a small
4
+ visual-novel engine. No DOM, no dependencies. Every other NilVN piece — the
5
+ runtime, the studio that authors games, plugins and tooling — agrees on the
6
+ shapes defined here, so they can be versioned and shipped separately.
7
+
8
+ ```bash
9
+ pnpm add @nilvn/core
10
+ ```
11
+
12
+ ## What is in it
13
+
14
+ | Module | Exports | Use it to |
15
+ |---|---|---|
16
+ | **IR** | `Project`, `Scene`, `SceneNode` (`SayNode`, `NarrateNode`, `CommandNode`, `ChoiceNode`, `SetNode`, `JumpNode`, `LabelNode`, …), `Actor`, `VariableDef`, `ResourceRegistry`, `PluginRef`, `CURRENT_SCHEMA_VERSION`, `migrateProject` | Read, generate or transform a NilVN project: the structured document the studio edits. Text lives in per-language catalogs and nodes hold keys. `migrateProject` brings an older project to the current schema. |
17
+ | **Command schema** | `CommandSchema`, `ParamSchema`, `BUILTIN_COMMANDS`, `BUILTIN_COMMAND_MAP`, `getCommandSchema` | Know every built-in script command's parameters, types and defaults — the same data the studio builds its forms from and a linter checks against. |
18
+ | **Plugin manifest** | `PluginManifest`, `PluginContributions`, `validatePluginManifest`, `manifestErrors`, `isPluginId`, `PLUGIN_API_VERSION`, `PLUGIN_MANIFEST_FILE`, `EXTENSION_POINTS`, `PERMISSIONS`, `matchPermission`, `hasEngineHalf`, `hasEditorHalf` | Validate a `plugin.json` and introspect the extension-point and permission catalogs. |
19
+ | **Plugin ids** | `FIRST_PARTY_ID_PREFIX`, `resolvePluginId`, `isFirstPartyId`, `pluginSlug`, `manifestCommandMap`, `commandRegistry` | The id conventions (`textfx` → `app.nilvn.textfx`, the reserved first-party namespace) and manifest-derived command registries; `commandRegistry(manifests)` is built-ins plus the commands those manifests contribute, what the serializer needs. The first-party plugins themselves live in `@nilvn/plugins`. |
20
+ | **Script package** | `PackageManifest`, `PackageActor`, `PackagePlugin`, `PACKAGE_FORMAT`, `PACKAGE_MANIFEST_FILE`, `isPackageManifest`, `buildScriptPackage`, `fillPackageAssets`, `packageLanguages`, `packageActors` | Produce a `nilvn.json` package from a project (pure: returns the manifest and file contents; you write the files). |
21
+ | **Chunks** | `ChunkManifest`, `ScriptChunk`, `TextCatalogSlice`, `ContentLoader`, `CHUNK_MANIFEST_FORMAT`, `isChunkManifest`, `buildChunkedExport`, `sceneTextKeys` | The streaming wire format and the loader interface a custom host implements. |
22
+ | **Serialization** | `serializeProject`, `serializeChunk`, `SerializeOptions`, `isAssetRef` | IR → the `.nvn` script the engine plays (`keepKeys` emits `@key` references for runtime language switching; `scenes` scopes the output to a chunk). |
23
+ | **Catalogs** | `exportCatalog`, `importCatalog`, `catalogToText`, `catalogFromText`, `catalogCompleteness`, `authoredKeys`, `nativeLangName` | Round-trip a project's text through a plain-text translation file. |
24
+ | **Screenplay** | `parseScreenplay`, `formatScreenplay`, `stripScreenplayMarkup` | A human-readable markdown screenplay grammar and its normalizer, for writing tools. |
25
+ | **Chrome i18n** | `createI18n`, `applyCatalogs`, `registerEnabledPluginCatalogs` | Stable-id UI string lookup shared by hosts and plugins (a plugin's own `messages` register into it). |
26
+ | **Versions** | `FORMAT_VERSIONS`, `parseSemVer`, `compareSemVer`, `isValidRange`, `satisfiesRange` | Every independently-versioned format in one table, and the semver subset plugin manifests use. |
27
+
28
+ ## Examples
29
+
30
+ Validate a plugin manifest against an engine version:
31
+
32
+ ```ts
33
+ import { validatePluginManifest } from '@nilvn/core'
34
+
35
+ const { errors, warnings } = validatePluginManifest(manifest, { engineVersion: '0.14.0' })
36
+ if (errors.length) throw new Error(errors.join('\n'))
37
+ ```
38
+
39
+ Turn a project into a playable package:
40
+
41
+ ```ts
42
+ import { buildScriptPackage, fillPackageAssets, serializeProject } from '@nilvn/core'
43
+
44
+ const plan = buildScriptPackage(project, { engine: '0.14.0' })
45
+ // plan.manifest → nilvn.json (chunks.assets still empty)
46
+ // plan.files → chunk + locale JSON files to write
47
+ // plan.assetRefs → asset references to resolve, then:
48
+ const manifest = fillPackageAssets(plan.manifest, { 'assets/bg/street.svg': { url: 'assets/bg/street.svg', bytes: 12034, kind: 'bg' } })
49
+
50
+ const script = serializeProject(project) // or a single .nvn for the whole project
51
+ ```
52
+
53
+ Walk a project:
54
+
55
+ ```ts
56
+ for (const scene of project.scenes) {
57
+ for (const node of scene.nodes) {
58
+ if (node.kind === 'say') console.log(node.actor, project.catalogs[project.meta.defaultLang][node.textKey])
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## Versioning
64
+
65
+ `FORMAT_VERSIONS` lists the on-disk and on-wire shapes that version independently
66
+ of the package: the IR schema (`migrateProject` steps older projects forward),
67
+ the chunk manifest, the script package, the engine save state and the plugin API.
68
+ Each bumps only when its own shape changes incompatibly.
69
+
70
+ The runtime that plays what this package describes is
71
+ [`@nilvn/engine`](https://www.npmjs.com/package/@nilvn/engine); plugin authors
72
+ start from [`@nilvn/plugin-sdk`](https://www.npmjs.com/package/@nilvn/plugin-sdk).
73
+
74
+ MIT.
@@ -0,0 +1,54 @@
1
+ import type { Lang, Project } from './ir.js';
2
+ /** Native display names for the language switcher / translation UI. Unknown codes
3
+ * fall back to the code itself. Shared by the editor (engine keeps its own copy
4
+ * to stay dependency-free). */
5
+ export declare const LANG_NATIVE_NAMES: Record<string, string>;
6
+ export declare function nativeLangName(code: Lang): string;
7
+ /** One translatable string: its key, the source-language text (reference), and
8
+ * the target-language text ('' when not yet translated). */
9
+ export interface CatalogEntry {
10
+ key: string;
11
+ source: string;
12
+ target: string;
13
+ }
14
+ export interface CatalogExport {
15
+ sourceLang: Lang;
16
+ targetLang: Lang;
17
+ entries: CatalogEntry[];
18
+ }
19
+ /** Per-language translation coverage against the authored (source) catalog. */
20
+ export interface LangCompleteness {
21
+ lang: Lang;
22
+ total: number;
23
+ translated: number;
24
+ /** Authored keys with no (or empty) translation in this language. */
25
+ missing: string[];
26
+ }
27
+ export interface ImportResult {
28
+ /** Keys written with a non-empty translation. */
29
+ applied: number;
30
+ /** Entries left blank in the file (existing translations are kept untouched). */
31
+ skipped: number;
32
+ /** Keys in the file that aren't authored in the source catalog (ignored). */
33
+ unknown: string[];
34
+ }
35
+ /** Keys that carry authored text: non-empty entries in the source catalog. Actor
36
+ * names (`actor.*`) and scene titles (`scene.*`) live here too, so they localize
37
+ * alongside dialogue. Empty placeholders (seeded by the editor for new nodes) are
38
+ * skipped — there's nothing to translate yet. */
39
+ export declare function authoredKeys(project: Project, sourceLang: Lang): string[];
40
+ /** Pair every authored key with its source text and current target text, ready to
41
+ * serialize for a translator. `sourceLang` defaults to the work's default lang. */
42
+ export declare function exportCatalog(project: Project, targetLang: Lang, sourceLang?: Lang): CatalogExport;
43
+ /** Merge a translated export back into project.catalogs[targetLang] (in place).
44
+ * Only authored keys are written; blank targets leave any existing translation
45
+ * alone. Registers the target language in meta.languages so it ships. */
46
+ export declare function importCatalog(project: Project, data: CatalogExport): ImportResult;
47
+ /** How completely `lang` translates the authored source. The source language is
48
+ * trivially 100%. */
49
+ export declare function catalogCompleteness(project: Project, lang: Lang, sourceLang?: Lang): LangCompleteness;
50
+ /** Serialize an export to the line-based translation file (see file header). */
51
+ export declare function catalogToText(data: CatalogExport): string;
52
+ /** Parse a translation file back into an export. Throws on a missing/garbled
53
+ * header so the caller can surface a clear error rather than importing nothing. */
54
+ export declare function catalogFromText(text: string): CatalogExport;
@@ -0,0 +1,137 @@
1
+ // Catalog localization round-trip: export the authored text as a plain-text
2
+ // translation file, hand it to a human or an LLM, then import the translations
3
+ // back into the project's per-language catalogs. Logic and text stay separate
4
+ // (see ir.ts): only the TextCatalog values move, never the scene/node structure.
5
+ //
6
+ // The file format is line-based and language-neutral so any editor / LLM can fill
7
+ // it in without special tooling:
8
+ //
9
+ // # nilvn-i18n source=en target=ja
10
+ // # <human instructions, trilingual>
11
+ //
12
+ // @scene.intro
13
+ // en: Prologue
14
+ // ja: プロローグ
15
+ //
16
+ // @t_a1b2c3d4
17
+ // en: Hello, nice to meet you.
18
+ // ja:
19
+ //
20
+ // `@<key>` opens an entry; `<sourceLang>:` is the read-only reference; the line
21
+ // prefixed with the target language code is what the translator fills in. Catalog
22
+ // values are single-line by construction (the DSL puts each line of dialogue on
23
+ // one line; breaks are `{br}` markers, not real newlines), so a line-based format
24
+ // round-trips losslessly.
25
+ /** Native display names for the language switcher / translation UI. Unknown codes
26
+ * fall back to the code itself. Shared by the editor (engine keeps its own copy
27
+ * to stay dependency-free). */
28
+ export const LANG_NATIVE_NAMES = { zh: '中文', ja: '日本語', en: 'English' };
29
+ export function nativeLangName(code) {
30
+ return LANG_NATIVE_NAMES[code] ?? code;
31
+ }
32
+ /** Keys that carry authored text: non-empty entries in the source catalog. Actor
33
+ * names (`actor.*`) and scene titles (`scene.*`) live here too, so they localize
34
+ * alongside dialogue. Empty placeholders (seeded by the editor for new nodes) are
35
+ * skipped — there's nothing to translate yet. */
36
+ export function authoredKeys(project, sourceLang) {
37
+ const src = project.catalogs[sourceLang] ?? {};
38
+ return Object.keys(src)
39
+ .filter((k) => src[k]?.trim())
40
+ .sort();
41
+ }
42
+ /** Pair every authored key with its source text and current target text, ready to
43
+ * serialize for a translator. `sourceLang` defaults to the work's default lang. */
44
+ export function exportCatalog(project, targetLang, sourceLang) {
45
+ const sLang = sourceLang ?? project.meta.defaultLang;
46
+ const src = project.catalogs[sLang] ?? {};
47
+ const tgt = project.catalogs[targetLang] ?? {};
48
+ const entries = authoredKeys(project, sLang).map((key) => ({
49
+ key,
50
+ source: src[key] ?? '',
51
+ target: tgt[key] ?? '',
52
+ }));
53
+ return { sourceLang: sLang, targetLang, entries };
54
+ }
55
+ /** Merge a translated export back into project.catalogs[targetLang] (in place).
56
+ * Only authored keys are written; blank targets leave any existing translation
57
+ * alone. Registers the target language in meta.languages so it ships. */
58
+ export function importCatalog(project, data) {
59
+ const authored = new Set(authoredKeys(project, data.sourceLang));
60
+ const catalog = (project.catalogs[data.targetLang] ??= {});
61
+ const result = { applied: 0, skipped: 0, unknown: [] };
62
+ for (const { key, target } of data.entries) {
63
+ if (!authored.has(key)) {
64
+ result.unknown.push(key);
65
+ continue;
66
+ }
67
+ const value = target.trim();
68
+ if (!value) {
69
+ result.skipped++;
70
+ continue;
71
+ }
72
+ catalog[key] = value;
73
+ result.applied++;
74
+ }
75
+ if (result.applied > 0 && !project.meta.languages.includes(data.targetLang)) {
76
+ project.meta.languages.push(data.targetLang);
77
+ }
78
+ return result;
79
+ }
80
+ /** How completely `lang` translates the authored source. The source language is
81
+ * trivially 100%. */
82
+ export function catalogCompleteness(project, lang, sourceLang) {
83
+ const sLang = sourceLang ?? project.meta.defaultLang;
84
+ const keys = authoredKeys(project, sLang);
85
+ if (lang === sLang)
86
+ return { lang, total: keys.length, translated: keys.length, missing: [] };
87
+ const cat = project.catalogs[lang] ?? {};
88
+ const missing = keys.filter((k) => !cat[k]?.trim());
89
+ return { lang, total: keys.length, translated: keys.length - missing.length, missing };
90
+ }
91
+ // ---------- plain-text serialization ----------
92
+ const HEADER = '# nilvn-i18n';
93
+ /** Serialize an export to the line-based translation file (see file header). */
94
+ export function catalogToText(data) {
95
+ const { sourceLang: s, targetLang: t } = data;
96
+ const out = [
97
+ `${HEADER} source=${s} target=${t}`,
98
+ `# Fill each「${t}:」line with the ${nativeLangName(t)} translation of the「${s}:」line above it.`,
99
+ `# 在每个「${t}:」行后填写上面「${s}:」行的译文;请勿改动「@」开头的 key 行与「${s}:」行。`,
100
+ `# 各「${t}:」行に、上の「${s}:」行の訳を記入してください(「@」の key 行と「${s}:」行は変更しない)。`,
101
+ '',
102
+ ];
103
+ for (const e of data.entries) {
104
+ out.push(`@${e.key}`, `${s}: ${e.source}`, `${t}: ${e.target}`, '');
105
+ }
106
+ return out.join('\n').trimEnd() + '\n';
107
+ }
108
+ /** Parse a translation file back into an export. Throws on a missing/garbled
109
+ * header so the caller can surface a clear error rather than importing nothing. */
110
+ export function catalogFromText(text) {
111
+ const lines = text.split(/\r?\n/);
112
+ const head = lines.find((l) => l.startsWith(HEADER));
113
+ const sourceLang = head?.match(/\bsource=(\S+)/)?.[1];
114
+ const targetLang = head?.match(/\btarget=(\S+)/)?.[1];
115
+ if (!sourceLang || !targetLang) {
116
+ throw new Error('Not a NilVN translation file (missing "# nilvn-i18n source=… target=…" header).');
117
+ }
118
+ const srcPrefix = sourceLang + ':';
119
+ const tgtPrefix = targetLang + ':';
120
+ const entries = [];
121
+ let cur = null;
122
+ for (const line of lines) {
123
+ if (line.startsWith('@')) {
124
+ cur = { key: line.slice(1).trim(), source: '', target: '' };
125
+ if (cur.key)
126
+ entries.push(cur);
127
+ continue;
128
+ }
129
+ if (!cur)
130
+ continue;
131
+ if (line.startsWith(srcPrefix))
132
+ cur.source = line.slice(srcPrefix.length).trim();
133
+ else if (line.startsWith(tgtPrefix))
134
+ cur.target = line.slice(tgtPrefix.length).trim();
135
+ }
136
+ return { sourceLang, targetLang, entries };
137
+ }
@@ -0,0 +1,37 @@
1
+ import type { Project, SceneNode } from './ir.js';
2
+ import type { CommandSchema } from './schema.js';
3
+ import { type ChunkManifest } from './chunk.js';
4
+ /** A JSON file the producer must write into the export, with its byte size
5
+ * already reflected in the manifest. `text` is the exact file content. */
6
+ export interface ChunkFile {
7
+ path: string;
8
+ text: string;
9
+ bytes: number;
10
+ }
11
+ export interface ChunkedExportPlan {
12
+ /** The manifest, with `assets` left EMPTY — the editor fills ref→{url,bytes,kind}
13
+ * from its AssetStore, then writes manifest.json. Everything else is final. */
14
+ manifest: ChunkManifest;
15
+ /** chunks/meta.json + chunks/scene/*.json + chunks/locale/<lang>/*.json, ready
16
+ * to write verbatim (bytes already counted into the manifest). */
17
+ files: ChunkFile[];
18
+ /** Deduped union of every chunk's asset refs — the editor resolves these to
19
+ * files under assets/ and fills manifest.assets. */
20
+ assetRefs: string[];
21
+ }
22
+ export interface BuildChunkedOptions {
23
+ /** Target engine version stamped into the manifest (mismatch → clean load error). */
24
+ engine: string;
25
+ /** Scene-id groups, in play order, one per chunk. Defaults to one scene per
26
+ * chunk; pass story-map chapter groupings to merge (a chunk = many scenes). */
27
+ groups?: string[][];
28
+ /** Command schema registry for serialization (`commandRegistry(manifests)`);
29
+ * built-ins only when omitted — see SerializeOptions.commands. */
30
+ commands?: Record<string, CommandSchema>;
31
+ }
32
+ /** Catalog keys a scene's runtime body references (say/narrate text + choice
33
+ * option labels) — i.e. the text that belongs in this scene's locale slice.
34
+ * Exported: the editor's shard partition (per-scene locale slices) claims keys
35
+ * with the same walker, so export slicing and persistence slicing cannot drift. */
36
+ export declare function sceneTextKeys(nodes: SceneNode[], into: Set<string>): void;
37
+ export declare function buildChunkedExport(project: Project, opts: BuildChunkedOptions): ChunkedExportPlan;
@@ -0,0 +1,187 @@
1
+ // Chunked streaming export — the producer half.
2
+ //
3
+ // buildChunkedExport(project) turns a Project into the script side of a chunked
4
+ // export: the manifest (minus per-file asset metadata, which the editor fills from
5
+ // its AssetStore) + the chunk/locale file contents. Pure, zero-I/O, zero-dep —
6
+ // the editor (export-html.ts) writes the files, fetches assets, and zips.
7
+ //
8
+ // Full single-file export does NOT use this — it stays one inlined script
9
+ // (serializeProject). This builder is for the chunked ZIP/Tauri product only.
10
+ import { serializeChunk } from './serialize.js';
11
+ import { CHUNK_MANIFEST_FORMAT, } from './chunk.js';
12
+ /** UTF-8 byte length of a string — pure, so @nilvn/core stays zero-dep and
13
+ * environment-agnostic (no TextEncoder, which isn't in core's lib). Matches what
14
+ * TextEncoder().encode(s).length would return; used for the manifest byte hints. */
15
+ function utf8Len(s) {
16
+ let n = 0;
17
+ for (let i = 0; i < s.length; i++) {
18
+ const c = s.charCodeAt(i);
19
+ if (c < 0x80)
20
+ n += 1;
21
+ else if (c < 0x800)
22
+ n += 2;
23
+ else if (c >= 0xd800 && c <= 0xdbff) {
24
+ n += 4; // a surrogate pair encodes one 4-byte code point
25
+ i++;
26
+ }
27
+ else
28
+ n += 3;
29
+ }
30
+ return n;
31
+ }
32
+ /** Resolve a jump/choice target to the SCENE id it lands in, or undefined for an
33
+ * in-scene label / dangling target (no cross-chunk edge). Scene ids double as
34
+ * labels, so a `label` that names a scene is a cross-scene jump. */
35
+ function targetSceneId(target, sceneIds) {
36
+ if (target.scene)
37
+ return sceneIds.has(target.scene) ? target.scene : undefined;
38
+ if (target.label && sceneIds.has(target.label))
39
+ return target.label;
40
+ return undefined;
41
+ }
42
+ /** Catalog keys a scene's runtime body references (say/narrate text + choice
43
+ * option labels) — i.e. the text that belongs in this scene's locale slice.
44
+ * Exported: the editor's shard partition (per-scene locale slices) claims keys
45
+ * with the same walker, so export slicing and persistence slicing cannot drift. */
46
+ export function sceneTextKeys(nodes, into) {
47
+ for (const n of nodes) {
48
+ if (n.kind === 'say' || n.kind === 'narrate') {
49
+ if (n.textKey)
50
+ into.add(n.textKey);
51
+ }
52
+ else if (n.kind === 'choice') {
53
+ for (const o of n.options)
54
+ if (o.labelKey)
55
+ into.add(o.labelKey);
56
+ }
57
+ }
58
+ }
59
+ export function buildChunkedExport(project, opts) {
60
+ const sceneOrder = project.scenes.map((s) => s.id);
61
+ const sceneIds = new Set(sceneOrder);
62
+ const byId = new Map(project.scenes.map((s) => [s.id, s]));
63
+ const groups = opts.groups ?? sceneOrder.map((id) => [id]);
64
+ // Each group → one chunk. Chunk id = its first scene id (stable, unique).
65
+ const chunks = [];
66
+ const files = [];
67
+ const labelIndex = {};
68
+ const sceneToChunk = new Map();
69
+ const allAssetRefs = new Set();
70
+ // Per-language, per-chunk key sets (for slicing) + the union of all scene keys
71
+ // (so base = catalog − sceneKeys).
72
+ const sceneKeysByChunk = new Map();
73
+ const allSceneKeys = new Set();
74
+ for (const group of groups) {
75
+ const ids = group.filter((id) => sceneIds.has(id));
76
+ if (!ids.length)
77
+ continue;
78
+ const chunkId = ids[0];
79
+ const { body, labels, assetRefs } = serializeChunk(project, { scenes: ids, keepKeys: true, crossChunk: true, ...(opts.commands ? { commands: opts.commands } : {}) });
80
+ const scriptChunk = { id: chunkId, body, labels };
81
+ const text = JSON.stringify(scriptChunk);
82
+ const bytes = utf8Len(text);
83
+ files.push({ path: `chunks/scene/${chunkId}.json`, text, bytes });
84
+ for (const label of labels) {
85
+ if (label in labelIndex) {
86
+ throw new Error(`Duplicate jump label "${label}" across chunks (${labelIndex[label]} and ${chunkId}); ` +
87
+ `labels must be globally unique to chunk a project. Rename one.`);
88
+ }
89
+ labelIndex[label] = chunkId;
90
+ }
91
+ for (const id of ids)
92
+ sceneToChunk.set(id, chunkId);
93
+ for (const r of assetRefs)
94
+ allAssetRefs.add(r);
95
+ const keys = new Set();
96
+ for (const id of ids)
97
+ sceneTextKeys(byId.get(id).nodes, keys);
98
+ sceneKeysByChunk.set(chunkId, keys);
99
+ for (const k of keys)
100
+ allSceneKeys.add(k);
101
+ chunks.push({
102
+ id: chunkId, scenes: ids, url: `chunks/scene/${chunkId}.json`, bytes,
103
+ labels, assets: assetRefs, next: [], branchTargets: [],
104
+ });
105
+ }
106
+ // Prefetch hints: linear fall-through (next scene after the chunk's LAST scene,
107
+ // unless that scene ends in an unconditional jump) + explicit branch targets.
108
+ for (const chunk of chunks) {
109
+ const next = new Set();
110
+ const branch = new Set();
111
+ for (const sceneId of chunk.scenes) {
112
+ const scene = byId.get(sceneId);
113
+ for (const n of scene.nodes) {
114
+ if (n.kind === 'jump')
115
+ addEdge(targetSceneId(n.target, sceneIds));
116
+ else if (n.kind === 'choice')
117
+ for (const o of n.options)
118
+ addEdge(targetSceneId(o.target, sceneIds));
119
+ }
120
+ }
121
+ // Fall-through from the chunk's last scene to the next scene in project order.
122
+ const lastSceneId = chunk.scenes[chunk.scenes.length - 1];
123
+ const lastScene = byId.get(lastSceneId);
124
+ const last = lastScene.nodes[lastScene.nodes.length - 1];
125
+ const fallsThrough = !(last && last.kind === 'jump' && !last.condition);
126
+ if (fallsThrough) {
127
+ const after = sceneOrder[sceneOrder.indexOf(lastSceneId) + 1];
128
+ const c = after && sceneToChunk.get(after);
129
+ if (c && c !== chunk.id)
130
+ next.add(c);
131
+ }
132
+ chunk.next = [...next];
133
+ chunk.branchTargets = [...branch];
134
+ function addEdge(sid) {
135
+ const c = sid && sceneToChunk.get(sid);
136
+ if (c && c !== chunk.id)
137
+ branch.add(c);
138
+ }
139
+ }
140
+ // Locale slices: a `base` slice per language (project-level keys — actor names,
141
+ // scene titles, anything not in a scene body, loaded with meta and always warm)
142
+ // + one slice per chunk (its scenes' dialogue, loaded when the chunk loads).
143
+ const langs = project.meta.languages?.length ? project.meta.languages : [project.meta.defaultLang];
144
+ const locales = {};
145
+ for (const lang of langs) {
146
+ const catalog = project.catalogs[lang] ?? {};
147
+ const slices = [];
148
+ // base = catalog keys not referenced by any scene body.
149
+ const base = {};
150
+ for (const k of Object.keys(catalog))
151
+ if (!allSceneKeys.has(k))
152
+ base[k] = catalog[k];
153
+ pushSlice(lang, 'base', [], base, slices);
154
+ for (const chunk of chunks) {
155
+ const slice = {};
156
+ for (const k of sceneKeysByChunk.get(chunk.id))
157
+ if (k in catalog)
158
+ slice[k] = catalog[k];
159
+ pushSlice(lang, chunk.id, chunk.scenes, slice, slices);
160
+ }
161
+ locales[lang] = slices;
162
+ }
163
+ function pushSlice(lang, id, scenes, slice, out) {
164
+ const text = JSON.stringify(slice);
165
+ const bytes = utf8Len(text);
166
+ files.push({ path: `chunks/locale/${lang}/${id}.json`, text, bytes });
167
+ out.push({ id, scenes, url: `chunks/locale/${lang}/${id}.json`, bytes });
168
+ }
169
+ // meta.json: the whole project MINUS scenes + catalogs (the always-warm base —
170
+ // actors / variables / resources / plugins / loopClips / meta).
171
+ const { scenes: _scenes, catalogs: _catalogs, ...metaJson } = project;
172
+ const metaText = JSON.stringify(metaJson);
173
+ files.push({ path: 'chunks/meta.json', text: metaText, bytes: utf8Len(metaText) });
174
+ const manifest = {
175
+ format: CHUNK_MANIFEST_FORMAT,
176
+ engine: opts.engine,
177
+ schemaVersion: project.meta.schemaVersion ?? 0,
178
+ entry: { label: sceneOrder[0] ?? '' },
179
+ defaultLang: project.meta.defaultLang,
180
+ sceneOrder,
181
+ chunks,
182
+ labelIndex,
183
+ locales,
184
+ assets: {}, // editor fills ref → {url, bytes, kind}
185
+ };
186
+ return { manifest, files, assetRefs: [...allAssetRefs] };
187
+ }
@@ -0,0 +1,106 @@
1
+ import type { Lang } from './ir.js';
2
+ /** Streaming-export format version (independent of the IR `schemaVersion`).
3
+ * Bumped only when the manifest/chunk wire shape changes incompatibly. */
4
+ export declare const CHUNK_MANIFEST_FORMAT = 1;
5
+ /** One playable script chunk's manifest entry — a group of scenes compiled
6
+ * together (default: one scene per chunk; story-map chapters may merge several). */
7
+ export interface ManifestChunk {
8
+ /** Stable chunk id (also the filename stem under `chunks/scene/`). */
9
+ id: string;
10
+ /** Scene ids this chunk contains, in project order. */
11
+ scenes: string[];
12
+ /** Where to fetch the chunk body, relative to the manifest. */
13
+ url: string;
14
+ /** Decoded-or-encoded byte size, for prefetch budgeting / progress. */
15
+ bytes: number;
16
+ /** Every jump-target label this chunk DEFINES (scene ids + in-scene labels).
17
+ * Feeds the manifest `labelIndex`; globally unique across chunks. */
18
+ labels: string[];
19
+ /** Asset refs this chunk references (command params + per-line voice). */
20
+ assets: string[];
21
+ /** Prefetch hint: the chunk(s) reachable by linear fall-through. */
22
+ next: string[];
23
+ /** Prefetch hint: chunk(s) reachable by an explicit jump/choice target. */
24
+ branchTargets: string[];
25
+ }
26
+ /** One language's catalog slice — the text for a group of scenes, mirroring the
27
+ * scene chunks so only the current language's resident-scene slices need loading. */
28
+ export interface ManifestLocaleSlice {
29
+ id: string;
30
+ /** Scene ids whose text this slice carries (parallels a scene chunk). */
31
+ scenes: string[];
32
+ url: string;
33
+ bytes: number;
34
+ }
35
+ /** Discrete asset file entry. The ref→file map lets any asset be fetched BY REF
36
+ * regardless of whether its owning chunk is resident (backlog voice replay,
37
+ * eventual window eviction) — seam constraint 2. */
38
+ export interface ManifestAsset {
39
+ url: string;
40
+ bytes: number;
41
+ /** bg / char / audio / voice / sprite — for warm/release policy. */
42
+ kind: string;
43
+ }
44
+ /** The chunked export's directory/index (manifest.json) — the contract core
45
+ * and the engine share. */
46
+ export interface ChunkManifest {
47
+ /** Streaming-export format version; see CHUNK_MANIFEST_FORMAT. */
48
+ format: number;
49
+ /** Target engine version the chunks were compiled for (mismatch → clean error). */
50
+ engine: string;
51
+ /** IR schema version (mirrors ProjectMeta.schemaVersion). */
52
+ schemaVersion: number;
53
+ /** Where playback opens. */
54
+ entry: {
55
+ label: string;
56
+ };
57
+ defaultLang: Lang;
58
+ /** Linear scene order (scene-to-scene fall-through). */
59
+ sceneOrder: string[];
60
+ chunks: ManifestChunk[];
61
+ /** Any label (jump target / save resume point) → the chunk that defines it.
62
+ * Denormalized for O(1) resolution: jump/restore = labelIndex[label] →
63
+ * ensure that chunk loaded → base+offset. Seam constraint 1. */
64
+ labelIndex: Record<string, string>;
65
+ /** Per-language catalog slice lists, parallel to the scene chunks. */
66
+ locales: Record<string, ManifestLocaleSlice[]>;
67
+ /** Asset ref → file. Seam constraint 2 (by-ref, residency-independent). */
68
+ assets: Record<string, ManifestAsset>;
69
+ }
70
+ /** A decoded script chunk — what `ContentLoader.loadChunk` yields: the playable
71
+ * `.nvn` body for its scenes plus the labels it defines (for the residency merge). */
72
+ export interface ScriptChunk {
73
+ id: string;
74
+ /** The `.nvn` DSL the engine parser consumes (serializeChunk's `body`). */
75
+ body: string;
76
+ /** Labels this chunk defines (serializeChunk's `labels`). */
77
+ labels: string[];
78
+ }
79
+ /** A decoded locale slice — catalog key → resolved text, for the slice's scenes
80
+ * in one language. Merged into the engine's catalog for that language. */
81
+ export type TextCatalogSlice = Record<string, string>;
82
+ /** The runtime seam the engine resolves content through (docs/.../streaming §3).
83
+ * Bytes are pluggable: Web loader fetches; a future Tauri loader reads natively
84
+ * and decrypts in the closed host, handing the engine plaintext to decode — the
85
+ * engine never touches ciphertext or keys. */
86
+ export interface ContentLoader {
87
+ /** Fetch + decode one script chunk by id (internally: read bytes → optional
88
+ * decrypt → decode). */
89
+ loadChunk(chunkId: string): Promise<ScriptChunk>;
90
+ /** Fetch + decode one locale slice (current language only). */
91
+ loadLocale(lang: string, sliceId: string): Promise<TextCatalogSlice>;
92
+ /** Resolve an asset's playable URL by canonical ref — independent of whether
93
+ * the owning chunk is resident (seam constraint 2). */
94
+ assetUrl(ref: string): Promise<string>;
95
+ /** Release a chunk's heavy bytes WITHOUT losing the ability to re-resolve its
96
+ * assets by ref (seam constraint 3). The engine calls this when it evicts a
97
+ * chunk under its opt-in residency ceiling, and for every resident chunk on
98
+ * destroy(). An implementation that hands out revocable URLs owns its own
99
+ * in-use protection; the bundled loaders never revoke (Web: static URLs,
100
+ * no-op — desktop player: stable nvpk:// URLs, drops native plaintext cache). */
101
+ releaseChunk(chunkId: string): void;
102
+ }
103
+ /** Cheap structural sanity check for a fetched manifest — guards against a
104
+ * truncated/foreign/garbage manifest with a clean error rather than a silent
105
+ * misparse deep in playback (the version-mismatch gate; producer/loader both use it). */
106
+ export declare function isChunkManifest(x: unknown): x is ChunkManifest;
package/dist/chunk.js ADDED
@@ -0,0 +1,35 @@
1
+ // Chunked streaming export — shared contract types.
2
+ //
3
+ // The on-the-wire shape of a chunked/streaming export
4
+ // plus the runtime ContentLoader interface the engine resolves chunks/assets through.
5
+ // Pure types + tiny guards, zero-dep (core stays dependency-free). Producer
6
+ // (editor) and consumer (engine) both import from here so the format can't drift.
7
+ //
8
+ // A full single-file export is the degenerate one-chunk case: it never builds a
9
+ // manifest at all (the engine just loadSource()s the whole script), so these
10
+ // types describe ONLY the chunked product.
11
+ /** Streaming-export format version (independent of the IR `schemaVersion`).
12
+ * Bumped only when the manifest/chunk wire shape changes incompatibly. */
13
+ export const CHUNK_MANIFEST_FORMAT = 1;
14
+ /** Cheap structural sanity check for a fetched manifest — guards against a
15
+ * truncated/foreign/garbage manifest with a clean error rather than a silent
16
+ * misparse deep in playback (the version-mismatch gate; producer/loader both use it). */
17
+ export function isChunkManifest(x) {
18
+ if (!x || typeof x !== 'object')
19
+ return false;
20
+ const m = x;
21
+ return (typeof m.format === 'number' &&
22
+ typeof m.engine === 'string' &&
23
+ typeof m.schemaVersion === 'number' &&
24
+ !!m.entry &&
25
+ typeof m.entry.label === 'string' &&
26
+ typeof m.defaultLang === 'string' &&
27
+ Array.isArray(m.sceneOrder) &&
28
+ Array.isArray(m.chunks) &&
29
+ !!m.labelIndex &&
30
+ typeof m.labelIndex === 'object' &&
31
+ !!m.locales &&
32
+ typeof m.locales === 'object' &&
33
+ !!m.assets &&
34
+ typeof m.assets === 'object');
35
+ }
@@ -0,0 +1,4 @@
1
+ import type { CommandSchema } from './schema.js';
2
+ export declare const BUILTIN_COMMANDS: CommandSchema[];
3
+ export declare const BUILTIN_COMMAND_MAP: Record<string, CommandSchema>;
4
+ export declare function getCommandSchema(name: string): CommandSchema | undefined;