@nilvn/core 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/catalog.d.ts +54 -0
- package/dist/catalog.js +137 -0
- package/dist/chunk-build.d.ts +37 -0
- package/dist/chunk-build.js +187 -0
- package/dist/chunk.d.ts +106 -0
- package/dist/chunk.js +35 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +181 -0
- package/dist/i18n.d.ts +50 -0
- package/dist/i18n.js +77 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/ir.d.ts +407 -0
- package/dist/ir.js +167 -0
- package/dist/package.d.ts +100 -0
- package/dist/package.js +80 -0
- package/dist/plugin-manifest.d.ts +146 -0
- package/dist/plugin-manifest.js +165 -0
- package/dist/plugins.d.ts +26 -0
- package/dist/plugins.js +42 -0
- package/dist/schema.d.ts +73 -0
- package/dist/schema.js +15 -0
- package/dist/screenplay-format.d.ts +8 -0
- package/dist/screenplay-format.js +96 -0
- package/dist/screenplay.d.ts +99 -0
- package/dist/screenplay.js +161 -0
- package/dist/semver.d.ts +15 -0
- package/dist/semver.js +129 -0
- package/dist/serialize.d.ts +63 -0
- package/dist/serialize.js +365 -0
- package/dist/versions.d.ts +17 -0
- package/dist/versions.js +25 -0
- package/package.json +42 -0
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;
|
package/dist/catalog.js
ADDED
|
@@ -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
|
+
}
|
package/dist/chunk.d.ts
ADDED
|
@@ -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
|
+
}
|