@patterkit/ops 0.1.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/index.js ADDED
@@ -0,0 +1,3317 @@
1
+ // src/load.ts
2
+ import { readdirSync, readFileSync, statSync, existsSync } from "fs";
3
+ import { join, dirname, resolve, basename } from "path";
4
+ import { parseSource } from "@patterkit/core";
5
+ function readDirSafe(dir) {
6
+ try {
7
+ return readdirSync(dir, { withFileTypes: true });
8
+ } catch {
9
+ return [];
10
+ }
11
+ }
12
+ function walkFiles(dir, ext) {
13
+ const out = [];
14
+ for (const e of readDirSafe(dir)) {
15
+ const p = join(dir, e.name);
16
+ if (e.isDirectory()) out.push(...walkFiles(p, ext));
17
+ else if (e.isFile() && e.name.endsWith(ext)) out.push(p);
18
+ }
19
+ return out.sort();
20
+ }
21
+ function findProjectFile(startPath) {
22
+ let dir = resolve(startPath);
23
+ try {
24
+ if (statSync(dir).isFile()) {
25
+ if (dir.endsWith(".patterproj")) return dir;
26
+ dir = dirname(dir);
27
+ }
28
+ } catch {
29
+ }
30
+ for (; ; ) {
31
+ const here = readDirSafe(dir).filter((e) => e.isFile() && e.name.endsWith(".patterproj")).map((e) => join(dir, e.name)).sort();
32
+ if (here.length > 1) throw new Error(`multiple project files in ${dir}: ${here.map((p) => p.slice(dir.length + 1)).join(", ")}`);
33
+ if (here.length === 1) return here[0];
34
+ const parent = dirname(dir);
35
+ if (parent === dir) throw new Error(`no .patterproj found at or above ${startPath}`);
36
+ dir = parent;
37
+ }
38
+ }
39
+ function parseFile(file, expectSchema, shapeKey) {
40
+ let parsed;
41
+ try {
42
+ parsed = parseSource(readFileSync(file, "utf8"));
43
+ } catch (e) {
44
+ throw new Error(`${file}: ${e instanceof Error ? e.message : String(e)}`);
45
+ }
46
+ const obj = parsed;
47
+ if (typeof obj !== "object" || obj === null || !(shapeKey in obj)) {
48
+ throw new Error(`${file}: not a ${expectSchema} file (missing '${shapeKey}')`);
49
+ }
50
+ const schema = obj["schema"];
51
+ if (typeof schema !== "string" || !schema.startsWith(expectSchema)) {
52
+ throw new Error(`${file}: schema is '${String(schema)}', expected '${expectSchema}@...'`);
53
+ }
54
+ migrateLegacyPropertyTypes(parsed, expectSchema);
55
+ return parsed;
56
+ }
57
+ function migrateLegacyPropertyTypes(parsed, expectSchema) {
58
+ const fixDecls = (decls) => {
59
+ if (!Array.isArray(decls)) return;
60
+ for (const d of decls) {
61
+ if (d && typeof d === "object" && d.type === "bool") d.type = "boolean";
62
+ }
63
+ };
64
+ if (!parsed || typeof parsed !== "object") return;
65
+ const obj = parsed;
66
+ if (expectSchema === "patter/project") {
67
+ fixDecls(obj["properties"]);
68
+ const reg = obj["scopeRegistry"];
69
+ if (reg && Array.isArray(reg.scopes)) for (const s of reg.scopes) fixDecls(s.declarations);
70
+ const recs = obj["recordingStatuses"];
71
+ if (Array.isArray(recs)) obj["recordingStatuses"] = recs.map((r) => typeof r === "string" ? { name: r } : r);
72
+ } else if (expectSchema === "patter/flow") {
73
+ const scene = obj["scene"];
74
+ fixDecls(scene?.sceneProps);
75
+ }
76
+ }
77
+ function sceneIdForShard(shardPath) {
78
+ const p = resolve(shardPath);
79
+ try {
80
+ if (p.endsWith(".patterflow")) return parseSource(readFileSync(p, "utf8")).scene?.id;
81
+ if (p.endsWith(".patterloc")) return parseSource(readFileSync(p, "utf8")).scene;
82
+ if (p.endsWith(".patterx")) {
83
+ const projectFile = findProjectFile(p);
84
+ const project = parseFile(projectFile, "patter/project", "project");
85
+ const flowDir = join(dirname(projectFile), project.layout?.flow ?? "scenes/");
86
+ const flowPath = join(flowDir, basename(p).replace(/\.patterx$/, ".patterflow"));
87
+ return parseSource(readFileSync(flowPath, "utf8")).scene?.id;
88
+ }
89
+ } catch {
90
+ }
91
+ return void 0;
92
+ }
93
+ function loadProjectLanding(startPath, opts) {
94
+ const projectFile = findProjectFile(startPath);
95
+ const root = dirname(projectFile);
96
+ const project = parseFile(projectFile, "patter/project", "project");
97
+ const layout = { flow: "scenes/", strings: "loc/", authoring: "authoring/", ...project.layout };
98
+ const flowFiles = walkFiles(join(root, layout.flow), ".patterflow");
99
+ const launchPath = opts?.launchPath ? resolve(opts.launchPath) : void 0;
100
+ let landingFile;
101
+ if (launchPath?.endsWith(".patterflow")) landingFile = flowFiles.find((f) => resolve(f) === launchPath);
102
+ if (!landingFile) {
103
+ const targetId = (opts?.launchPath ? sceneIdForShard(opts.launchPath) : void 0) ?? opts?.preferId;
104
+ if (targetId) {
105
+ for (const f of flowFiles) {
106
+ if (parseFile(f, "patter/flow", "scene").scene.id === targetId) {
107
+ landingFile = f;
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ landingFile ??= flowFiles[0];
114
+ const landingScene = landingFile ? parseFile(landingFile, "patter/flow", "scene").scene : void 0;
115
+ const scenes = [];
116
+ const sceneFiles = {};
117
+ if (landingScene && landingFile) {
118
+ scenes.push(landingScene);
119
+ sceneFiles[landingScene.id] = landingFile;
120
+ }
121
+ const locales = [];
122
+ const localeFiles = [];
123
+ if (landingScene && landingFile) {
124
+ const defaultLocale = project.locales.default;
125
+ const strings = join(root, layout.strings);
126
+ const guess = join(strings, defaultLocale, `${basename(landingFile).replace(/\.patterflow$/, "")}.patterloc`);
127
+ let found = false;
128
+ if (existsSync(guess)) {
129
+ try {
130
+ const loc = parseFile(guess, "patter/strings", "strings");
131
+ if (loc.scene === landingScene.id) {
132
+ locales.push(loc);
133
+ localeFiles.push(guess);
134
+ found = true;
135
+ }
136
+ } catch {
137
+ }
138
+ }
139
+ if (!found) {
140
+ for (const f of walkFiles(strings, ".patterloc")) {
141
+ const loc = parseFile(f, "patter/strings", "strings");
142
+ if (loc.scene !== landingScene.id) continue;
143
+ locales.push(loc);
144
+ localeFiles.push(f);
145
+ if (loc.locale === defaultLocale || loc.default) break;
146
+ }
147
+ }
148
+ }
149
+ return { root, projectFile, project, scenes, locales, sceneFiles, localeFiles, authoring: [], authoringFiles: [] };
150
+ }
151
+ function applySceneOrder(scenes, order) {
152
+ if (!order?.length) return;
153
+ const rank = new Map(order.map((id, i) => [id, i]));
154
+ const fileRank = new Map(scenes.map((s, i) => [s.id, i]));
155
+ scenes.sort((a, b) => {
156
+ const ra = rank.get(a.id), rb = rank.get(b.id);
157
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
158
+ if (ra !== void 0) return -1;
159
+ if (rb !== void 0) return 1;
160
+ return (fileRank.get(a.id) ?? 0) - (fileRank.get(b.id) ?? 0);
161
+ });
162
+ }
163
+ function loadProject(startPath) {
164
+ const projectFile = findProjectFile(startPath);
165
+ const root = dirname(projectFile);
166
+ const project = parseFile(projectFile, "patter/project", "project");
167
+ const layout = { flow: "scenes/", strings: "loc/", authoring: "authoring/", ...project.layout };
168
+ const scenes = [];
169
+ const sceneFiles = {};
170
+ for (const f of walkFiles(join(root, layout.flow), ".patterflow")) {
171
+ const scene = parseFile(f, "patter/flow", "scene").scene;
172
+ const existing = sceneFiles[scene.id];
173
+ if (existing !== void 0) {
174
+ throw new Error(`scene id '${scene.id}' is declared by two files: ${existing} and ${f}`);
175
+ }
176
+ scenes.push(scene);
177
+ sceneFiles[scene.id] = f;
178
+ }
179
+ applySceneOrder(scenes, project.sceneOrder);
180
+ const locales = [];
181
+ const localeFiles = [];
182
+ for (const f of walkFiles(join(root, layout.strings), ".patterloc")) {
183
+ locales.push(parseFile(f, "patter/strings", "strings"));
184
+ localeFiles.push(f);
185
+ }
186
+ const authoring = [];
187
+ const authoringFiles = [];
188
+ for (const f of walkFiles(join(root, layout.authoring), ".patterx")) {
189
+ authoring.push(parseFile(f, "patter/authoring", "schema"));
190
+ authoringFiles.push(f);
191
+ }
192
+ return { root, projectFile, project, scenes, locales, sceneFiles, localeFiles, authoring, authoringFiles };
193
+ }
194
+
195
+ // src/write.ts
196
+ import { writeFileSync, mkdirSync } from "fs";
197
+ import { dirname as dirname2 } from "path";
198
+ function applyWrites(writes) {
199
+ for (const w of writes) {
200
+ mkdirSync(dirname2(w.path), { recursive: true });
201
+ writeFileSync(w.path, w.content, "utf8");
202
+ }
203
+ }
204
+
205
+ // src/validate.ts
206
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
207
+ import { validateProject, parseSource as parseSource2 } from "@patterkit/core";
208
+ import { validateConditions, validateInterpolation, exportBundle, hostScopesToSpec } from "@patterkit/compiler";
209
+ function runValidate(loaded) {
210
+ const { project, scenes, locales } = loaded;
211
+ const structural = validateProject({ project, scenes, authoring: loaded.authoring });
212
+ const foreignScopes = hostScopesToSpec(project.scopeRegistry);
213
+ const conditions = validateConditions({ project, scenes }, { foreignScopes });
214
+ const interpolation = validateInterpolation({ project, scenes, locales }, { foreignScopes });
215
+ const hygiene = checkHygiene([loaded.projectFile, ...Object.values(loaded.sceneFiles), ...loaded.localeFiles, ...loaded.authoringFiles]);
216
+ const staleBundles = checkBundles(loaded);
217
+ const unresolvedMerges = walkFiles(loaded.root, ".patterconflict").map((file) => ({
218
+ file,
219
+ message: "unresolved merge conflict - resolve it and delete the .patterconflict sidecar before committing"
220
+ }));
221
+ return {
222
+ structural,
223
+ conditions,
224
+ interpolation,
225
+ hygiene,
226
+ staleBundles,
227
+ unresolvedMerges,
228
+ ok: structural.length === 0 && conditions.length === 0 && interpolation.length === 0 && hygiene.length === 0 && staleBundles.length === 0 && unresolvedMerges.length === 0
229
+ };
230
+ }
231
+ function checkBundles(loaded) {
232
+ const issues = [];
233
+ const bundles = walkFiles(loaded.root, ".patterc");
234
+ if (bundles.length === 0) return issues;
235
+ let fresh;
236
+ try {
237
+ fresh = exportBundle({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales }).content.hash;
238
+ } catch {
239
+ return issues;
240
+ }
241
+ for (const file of bundles) {
242
+ let hash;
243
+ try {
244
+ const parsed = parseSource2(readFileSync2(file, "utf8"));
245
+ hash = parsed?.content?.hash;
246
+ } catch {
247
+ issues.push({ file, message: "compiled bundle is unparseable - run `patter export`" });
248
+ continue;
249
+ }
250
+ if (hash !== fresh) {
251
+ issues.push({ file, message: "compiled bundle is stale (does not match current source) - run `patter export`" });
252
+ }
253
+ }
254
+ return issues;
255
+ }
256
+ var hygieneCache = /* @__PURE__ */ new Map();
257
+ function checkHygiene(files) {
258
+ const issues = [];
259
+ for (const file of files) {
260
+ let mtimeMs;
261
+ try {
262
+ mtimeMs = statSync2(file).mtimeMs;
263
+ } catch {
264
+ continue;
265
+ }
266
+ const hit = hygieneCache.get(file);
267
+ if (hit && hit.mtimeMs === mtimeMs) {
268
+ issues.push(...hit.issues);
269
+ continue;
270
+ }
271
+ const fileIssues = [];
272
+ try {
273
+ const text = readFileSync2(file, "utf8");
274
+ if (text.charCodeAt(0) === 65279) {
275
+ fileIssues.push({ file, message: "file starts with a UTF-8 BOM (canonical form is UTF-8 without BOM - run `patter format`)" });
276
+ }
277
+ if (text.includes("\r")) {
278
+ fileIssues.push({ file, message: "file contains CRLF line endings (canonical form is LF - run `patter format`)" });
279
+ }
280
+ } catch {
281
+ continue;
282
+ }
283
+ hygieneCache.set(file, { mtimeMs, issues: fileIssues });
284
+ issues.push(...fileIssues);
285
+ }
286
+ return issues;
287
+ }
288
+
289
+ // src/export.ts
290
+ import { basename as basename2, isAbsolute, join as join2 } from "path";
291
+ import { exportBundle as exportBundle2 } from "@patterkit/compiler";
292
+ import { isContentlessBeat } from "@patterkit/model";
293
+ function compileScenes(loaded) {
294
+ const def = loaded.project.locales.default;
295
+ const written = /* @__PURE__ */ new Set();
296
+ for (const l of loaded.locales) if (l.locale === def) {
297
+ for (const [k, v] of Object.entries(l.strings)) if (v) written.add(k);
298
+ }
299
+ const clean = (n) => {
300
+ if (n.type === "group") return { ...n, children: (n.children ?? []).map(clean) };
301
+ if (!n.beats) return n;
302
+ const kept = n.beats.filter((b) => !isContentlessBeat(b, written.has(b.id)));
303
+ if (kept.length === n.beats.length) return n;
304
+ const { beats, ...rest } = n;
305
+ return kept.length ? { ...rest, beats: kept } : rest;
306
+ };
307
+ return loaded.scenes.map((s) => ({
308
+ ...s,
309
+ blocks: s.blocks.map((b) => ({ ...b, children: b.children.map(clean) }))
310
+ }));
311
+ }
312
+ function runExport(loaded) {
313
+ const { project, locales } = loaded;
314
+ const full = exportBundle2({ project, scenes: compileScenes(loaded), locales });
315
+ const loc = project.export?.localisation;
316
+ if (!loc || loc.mode === "embedded") return full;
317
+ const strings = loc.sourceDebug ? { [full.locales.default]: full.strings[full.locales.default] ?? {} } : {};
318
+ return { ...full, strings, localisation: { mode: "ids", ...loc.sourceDebug ? { sourceDebug: true } : {} } };
319
+ }
320
+ function runExportFull(loaded) {
321
+ const { project, locales } = loaded;
322
+ return exportBundle2({ project, scenes: compileScenes(loaded), locales });
323
+ }
324
+ function bundleOutputPath(loaded) {
325
+ const override = loaded.project.export?.bundle;
326
+ if (override) return isAbsolute(override) ? override : join2(loaded.root, override);
327
+ const stem = basename2(loaded.projectFile).replace(/\.patterproj$/, "");
328
+ return join2(loaded.root, "dist", `${stem}.patterc`);
329
+ }
330
+
331
+ // src/export-html.ts
332
+ import { canonicalStringify } from "@patterkit/core";
333
+
334
+ // src/playable-runtime.ts
335
+ var PLAYABLE_RUNTIME_JS = '"use strict";var Patterplay=(()=>{var N=Object.defineProperty;var oe=Object.getOwnPropertyDescriptor;var ae=Object.getOwnPropertyNames;var ce=Object.prototype.hasOwnProperty;var le=(s,e)=>{for(var t in e)N(s,t,{get:e[t],enumerable:!0})},pe=(s,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of ae(e))!ce.call(s,n)&&n!==t&&N(s,n,{get:()=>e[n],enumerable:!(r=oe(e,n))||r.enumerable});return s};var de=s=>pe(N({},"__esModule",{value:!0}),s);var xe={};le(xe,{Engine:()=>R,Flow:()=>b,buildTagIndex:()=>I,effectiveGameData:()=>ne,gameDataFields:()=>re,gameDataValue:()=>G});function m(s){switch(s[0]){case"b":return{kind:"bool",value:s[1]};case"n":return{kind:"number",value:s[1]};case"s":return{kind:"string",value:s[1]};case"sv":return{kind:"scopedvar",scope:s[1],name:s[2]};case"u":return{kind:"unary",op:s[1],operand:m(s[2])};case"bin":return{kind:"binary",op:s[1],left:m(s[2]),right:m(s[3])};case"call":{let e=s.slice(2).map(m);return{kind:"call",name:s[1],args:e}}case"fd":return{kind:"flagdelta",sign:s[1],name:s[2]}}}var c=class extends Error{constructor(e){super(e),this.name="EvalError"}};function P(s,e,t){let r=new Map(t.scopes.map(i=>[i.token,i.missing??"false"])),n=i=>{switch(i.kind){case"bool":return i.value;case"number":return i.value;case"string":return i.value;case"scopedvar":{let o=e.scopes[i.scope];if(o===void 0)return!1;let a=typeof o.get=="function"?o.get(i.name):o[i.name];if(a===void 0){if(r.get(i.scope)==="throw")throw new c(`@${i.scope}.${i.name} is not declared on the current ${i.scope}.`);return!1}return a}case"call":{let o=t.functions[i.name];if(!o)throw new c(`unknown function \'${i.name}\'`);return o.eval(i.args,{evaluate:n,ctx:e})}case"flagdelta":throw new c("flagdelta node is only valid as an argument to a flag-delta function");case"unary":{if(i.op==="not"){let a=n(i.operand);if(typeof a!="boolean")throw new c(`\'not\' requires a boolean operand, got ${typeof a}`);return!a}let o=n(i.operand);if(typeof o!="number")throw new c(`unary \'-\' requires a numeric operand, got ${typeof o}`);return-o}case"binary":{if(i.op==="and"){let p=n(i.left);if(typeof p!="boolean")throw new c(`\'and\' requires boolean operands, left is ${typeof p}`);if(!p)return!1;let d=n(i.right);if(typeof d!="boolean")throw new c(`\'and\' requires boolean operands, right is ${typeof d}`);return d}if(i.op==="or"){let p=n(i.left);if(typeof p!="boolean")throw new c(`\'or\' requires boolean operands, left is ${typeof p}`);if(p)return!0;let d=n(i.right);if(typeof d!="boolean")throw new c(`\'or\' requires boolean operands, right is ${typeof d}`);return d}let o=n(i.left),a=n(i.right);switch(i.op){case"==":return q(o,a);case"!=":return!q(o,a);case">":return S(o,a,">"),o>a;case">=":return S(o,a,">="),o>=a;case"<":return S(o,a,"<"),o<a;case"<=":return S(o,a,"<="),o<=a;case"+":if(typeof o=="number"&&typeof a=="number"||typeof o=="string"&&typeof a=="string")return o+a;throw new c(`\'+\' requires two numbers or two strings, got ${typeof o} and ${typeof a}`);case"-":return S(o,a,"-"),o-a;case"*":return S(o,a,"*"),o*a;case"/":if(S(o,a,"/"),a===0)throw new c("division by zero");return o/a}}}};return n(s)}function q(s,e){if(Array.isArray(s)||Array.isArray(e)){if(!Array.isArray(s)||!Array.isArray(e)||s.length!==e.length)return!1;for(let t=0;t<s.length;t++)if(s[t]!==e[t])return!1;return!0}return s===e}function S(s,e,t){if(typeof s!="number"||typeof e!="number")throw new c(`\'${t}\' requires numeric operands, got ${typeof s} and ${typeof e}`)}var w=class{scopes=new Map;defineOwned(e,t){this.assertFree(e);let r={},n=new Map;for(let i of t){let o=i.name.toLowerCase();n.set(o,i),r[o]=i.default??_(i)}return this.scopes.set(e,{kind:"owned",bag:r,decls:n}),this}reseedOwned(e,t){let r=this.scopes.get(e);if(!r||r.kind!=="owned")throw new Error(`\'@${e}\' is not an owned scope`);for(let n of Object.keys(r.bag))delete r.bag[n];r.decls.clear();for(let n of t){let i=n.name.toLowerCase();r.decls.set(i,n),r.bag[i]=n.default??_(n)}return this}defineForeign(e,t,r=[],n=!0){this.assertFree(e);let i=new Map;for(let o of r)i.set(o.name.toLowerCase(),o);return this.scopes.set(e,{kind:"foreign",resolver:t,decls:i,scopeWritable:n}),this}has(e){return this.scopes.has(e)}get(e,t){let r=this.scopes.get(e);if(!r)return;let n=t.toLowerCase();return r.kind==="owned"?r.bag[n]:r.resolver.get(n)}set(e,t,r){let n=this.scopes.get(e);if(!n)throw new Error(`unknown scope \'@${e}\'`);let i=t.toLowerCase();if(!this.writable(n,i))throw new Error(`\'@${e}.${t}\' is read-only`);n.kind==="owned"?n.bag[i]=r:n.resolver.set(i,r)}writable(e,t){return e.kind==="owned"?e.decls.get(t)?.writable??!0:e.resolver.set?e.decls.get(t)?.writable??e.scopeWritable:!1}toEvalContext(e){let t={};for(let[r,n]of this.scopes)t[r]=n.kind==="owned"?n.bag:n.resolver;return{scopes:t,host:e}}toSchema(){let e=new Map;for(let[t,r]of this.scopes){if(r.decls.size===0)continue;let n=new Map;for(let[i,o]of r.decls)n.set(i,{type:o.type,enumValues:o.values});e.set(t,n)}return{properties:e}}save(){let e={};for(let[t,r]of this.scopes)r.kind==="owned"&&(e[t]={...r.bag});return e}load(e){for(let[t,r]of Object.entries(e)){let n=this.scopes.get(t);n?.kind==="owned"&&Object.assign(n.bag,r)}}assertFree(e){if(this.scopes.has(e))throw new Error(`scope \'@${e}\' is already registered`)}};function _(s){if(s.default!==void 0)return s.default;switch(s.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"enum":return s.values?.[0]??"";case"flags":return[]}}function x(s){return s.ctx.host??{}}var J={defaultScope:"patter",scopes:[{token:"patter"},{token:"scene"}],functions:{random:{minArgs:2,maxArgs:2,returnType:"number",eval(s,e){if(s.length!==2)throw new c("random(a, b) requires exactly 2 arguments");let t=x(e).nextRandom;if(!t)throw new c("random() called without a PRNG in context");let r=e.evaluate(s[0]),n=e.evaluate(s[1]);if(typeof r!="number"||typeof n!="number")throw new c("random(a, b) arguments must be numbers");if(!Number.isInteger(r)||!Number.isInteger(n))throw new c("random(a, b) arguments must be integers");let i=Math.min(r,n),o=Math.max(r,n);return Math.floor(t()*(o-i+1))+i}},check_flags:{minArgs:1,returnType:"boolean",flagDeltaArgs:!0,validate:W("check_flags"),eval(s,e){let t=U(s[0],e,"check_flags");for(let r=1;r<s.length;r++){let n=s[r];if(n.kind!=="flagdelta")throw new c("check_flags() flag args must be +flagName or -flagName");if(n.sign==="+"?!t.includes(n.name):t.includes(n.name))return!1}return!0}},set_flags:{minArgs:1,returnType:"flags",flagDeltaArgs:!0,validate:W("set_flags"),eval(s,e){let t=[...U(s[0],e,"set_flags")];for(let r=1;r<s.length;r++){let n=s[r];if(n.kind!=="flagdelta")throw new c("set_flags() flag args must be +flagName or -flagName");if(n.sign==="+")t.includes(n.name)||t.push(n.name);else{let i=t.indexOf(n.name);i>=0&&t.splice(i,1)}}return t}},visits:{minArgs:1,maxArgs:1,returnType:"number",validate:C("visits"),eval:(s,e)=>x(e).visits?.(E(s,e,"visits"))??0},seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:C("seen"),eval:(s,e)=>(x(e).visits?.(E(s,e,"seen"))??0)>0},patter_visits:{minArgs:1,maxArgs:1,returnType:"number",validate:C("patter_visits"),eval:(s,e)=>x(e).patterVisits?.(E(s,e,"patter_visits"))??0},patter_seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:C("patter_seen"),eval:(s,e)=>(x(e).patterVisits?.(E(s,e,"patter_seen"))??0)>0}}};function A(s,e){let t=s.replace(/^@/,"").split(".");return t.length===2&&e(t[0])?{scope:t[0],name:t[1].toLowerCase()}:{scope:"patter",name:t.join(".").toLowerCase()}}function E(s,e,t){let r=e.evaluate(s[0]);if(typeof r!="string")throw new c(`${t}(id) requires a string node id`);return r}function C(s){return(e,t)=>{let r=e[0];r&&r.kind!=="string"&&t.report({path:[...t.path,"args",0],kind:"wrong-arg-type",severity:"error",message:`${s}(id): the argument must be a string id literal (a scene / block / node id)`})}}function U(s,e,t){if(!s)throw new c(`${t}() requires at least one argument (the flags variable)`);let r=e.evaluate(s);if(Array.isArray(r))return r;if(r===!1||r===null||r===void 0)return[];throw new c(`${t}() first argument must be a flags property`)}function W(s){return(e,t)=>{if(e.length===0)return;let r=e[0];if(r.kind!=="scopedvar"){t.report({path:[...t.path,"args",0],kind:"wrong-arg-type",severity:"error",message:`${s}(): first argument must be a flags property reference (@name or @scope.name)`});return}let n=t.schema.properties.get(r.scope)?.get(r.name);if(n&&n.type!=="flags"){let i=r.scope===t.defaultScope?r.name:`${r.scope}.${r.name}`;t.report({path:[...t.path,"args",0],kind:"wrong-arg-type",severity:"error",message:`${s}(): \'@${i}\' is not a flags property (got ${n.type})`});return}for(let i=1;i<e.length;i++){let o=e[i];o.kind!=="flagdelta"?t.report({path:[...t.path,"args",i],kind:"wrong-arg-type",severity:"error",message:`${s}(): argument ${i+1} must be +flagName or -flagName`}):n?.type==="flags"&&n.enumValues&&!n.enumValues.includes(o.name)&&t.report({path:[...t.path,"args",i],kind:"unknown-flag-name",severity:"error",message:`${s}(): unknown flag \'${o.name}\'`,reference:o.name})}}}var ge=/^@[A-Za-z0-9_.]+$/;function*ue(s){let e="",t=0;for(;t<s.length;){let r=s[t];if(r==="{"&&s[t+1]==="{"){e+="{",t+=2;continue}if(r==="}"&&s[t+1]==="}"){e+="}",t+=2;continue}if(r==="{"){let n=s.indexOf("}",t+1);if(n!==-1){let i=s.slice(t,n+1),o=s.slice(t+1,n).trim();if(o.startsWith("@")){e&&(yield{kind:"text",value:e},e=""),yield{kind:"slot",raw:i,inner:o,ref:ge.test(o)?o:void 0},t=n+1;continue}e+=i,t=n+1;continue}}e+=r,t+=1}e&&(yield{kind:"text",value:e})}function fe(s){return Array.isArray(s)?s.join(", "):typeof s=="boolean"?s?"true":"false":String(s)}function he(s){return s===" "||s===" "||s===`\n`||s==="\\r"||s==="\\f"||s==="\\v"}function me(s){let e="",t=!1;for(let r of s){if(he(r)){t=!0;continue}t&&e.length>0&&(e+=" "),t=!1,e+=r}return e}function K(s,e,t){if(e.length===0||s.indexOf(e)<0)return s;let r="",n=0,i=!1;for(;n<s.length;){if(s.startsWith(e,n)){let o=s.indexOf(t,n+e.length);if(o>=0){n=o+t.length,i=!0;continue}r+=s.slice(n);break}r+=s[n],n+=1}return i?me(r):s}function z(s,e){if(s.indexOf("{")<0)return s;let t="";for(let r of ue(s)){if(r.kind==="text"){t+=r.value;continue}if(!r.ref){t+=r.raw;continue}let n=e(r.ref);t+=n===void 0?"":fe(n)}return t}function k(s,e){for(let t of s){e(t);let r=t.children;r&&k(r,e)}}function Se(s){return s.toLowerCase().replace(/[\'\u2019]/g,"").replace(/[^a-z0-9-]+/g,"-").replace(/-+/g,"-").replace(/^-+|-+$/g,"")}function f(s){let e=s.gameId?.trim();return e||Se(s.name)}function V(s){return`cast:${s}`}var F={open:"[",close:"]"},X="SFX";function D(s){let e=new Set,t=[];for(let r of s)e.has(r)||(e.add(r),t.push(r));return t}function I(s){let e=new Map,t=(r,n)=>{let i=D([...n,...r.tags??[]]);if(e.set(r.id,i),r.type==="group")for(let o of r.children)t(o,i);else for(let o of r.beats??[])e.set(o.id,D([...i,...o.tags??[]]))};for(let r of Object.values(s.scenes)){let n=D(r.tags??[]);e.set(r.id,n);for(let i of r.blocks){let o=D([...n,...i.tags??[]]);e.set(i.id,o);for(let a of i.children)t(a,o)}}return e}var Z=new WeakMap,R=class s{host;defaultSeed;flowsById=new Map;allStrings;currentLocale;sourceDebug;sceneGameIdToId=new Map;blockGameIdToId=new Map;creationOptions;constructor(e,t={}){this.creationOptions=t;let r=t.locale??e.locales.default,n=e.strings;this.allStrings=n,this.currentLocale=r;let i=n[r]??{},o=n[e.locales.default]??{},a=e.localisation,p=a?.mode==="ids"&&!a.sourceDebug;this.sourceDebug=a?.mode==="ids"&&!!a.sourceDebug,this.sourceDebug&&typeof console<"u"&&console.warn("[Patterplay] source-only DEBUG build: strings are the source language for debugging, not a shippable localised build.");let d=new Map;for(let l of e.cast??[])l.displayName&&d.set(l.name,l.displayName);this.defaultSeed=(t.seed??2654435769)>>>0;let v=new Map,u=new Map,M=new Map;for(let[l,h]of Object.entries(e.scenes)){this.sceneGameIdToId.set(f(h),l);let y=new Map;for(let g of h.blocks)u.set(g.id,{sceneId:l}),M.set(g.id,g),y.set(f(g),g.id),k(g.children,H=>v.set(H.id,H));this.blockGameIdToId.set(l,y)}let j=e.properties??[],O=j.filter(l=>l.shared??!0).map(Q),se=j.filter(l=>!(l.shared??!0)).map(Q),ie=new Set(O.map(l=>l.name.toLowerCase())),B=new w().defineOwned("patter",O),L=new Set;if(t.world){let l=e.scopeRegistry?.scopes.find(y=>y.token==="world"),h=(l?.declarations??[]).map(Y);B.defineForeign("world",t.world,h,l?.writable??!0),L.add("world")}for(let l of e.scopeRegistry?.scopes??[]){if(L.has(l.token))continue;let h=(l.declarations??[]).map(Y);B.defineForeign(l.token,ve(l.declarations??[]),h,l.writable??!0)}let $=new Map;for(let[l,h]of Object.entries(e.scenes)){let y=new Set((h.sceneProps??[]).filter(g=>g.shared??!1).map(g=>g.name.toLowerCase()));$.set(l,y)}this.host={bundle:e,emitIds:p,strings:i,defaultStrings:o,castDisplay:d,nodeIndex:v,blockIndex:u,blockById:M,tagIndex:I(e),shared:B,patterSharedDecls:O,patterLocalDecls:se,patterSharedNames:ie,sceneSharedNames:$,sharedVisits:new Map,sharedSelectors:new Map,stageBags:new Map,customRng:t.rng,onDryChoice:t.onDryChoice,replayPromptOnChoose:t.replayPromptOnChoose??!1,captionsOn:t.closedCaptions??!0,captionOpen:(e.closedCaptions??F).open,captionClose:(e.closedCaptions??F).close,captionCharacter:e.closedCaptions?.character||X,refSplitCache:new Map}}get locale(){return this.currentLocale}get isSourceDebug(){return this.sourceDebug}setLocale(e){this.currentLocale=e,this.host.strings=this.allStrings[e]??{}}replaceStrings(e){this.allStrings=e.strings,this.host.strings=this.allStrings[this.currentLocale]??{},this.host.defaultStrings=this.allStrings[this.host.bundle.locales.default]??{}}hotSwap(e){let t=this.saveGame(),r=i=>(i.setLocale(this.currentLocale),i.setClosedCaptions(this.host.captionsOn),i),n=new s(e,this.creationOptions);try{return n.loadGame(t),r(n)}catch{let i=new s(e,this.creationOptions);for(let[o,a]of Object.entries(t.flows)){let p=a.cursor.currentSceneId;try{i.openFlow(o,p!==null?{scene:p}:{})}catch{}}return r(i)}}get closedCaptions(){return this.host.captionsOn}setClosedCaptions(e){this.host.captionsOn=e}openFlow(e,t={}){let r=this.resolveSceneRef(t.scene),n=this.resolveBlockRef(r,t.block),i=new b(e,this.host,t.seed??this.defaultSeed);return this.flowsById.set(e,i),i.start(r,n),i}resolveSceneRef(e){if(e!=null)return this.host.bundle.scenes[e]?e:this.sceneGameIdToId.get(e)??e}resolveBlockRef(e,t){if(t!=null){if(this.host.blockById.has(t))return t;if(e!=null){let r=this.blockGameIdToId.get(e)?.get(t);if(r)return r}return t}}sceneAddress(e){let t=this.host.bundle.scenes[e];return t?f(t):void 0}blockAddress(e){let t=this.host.blockById.get(e);return t?f(t):void 0}tagsForBeat(e){return this.host.tagIndex.get(e)??[]}tagsForScene(e){let t=this.resolveSceneRef(e);return(t!=null?this.host.tagIndex.get(t):void 0)??[]}tagsForBlock(e,t){let r=this.resolveSceneRef(e),n=this.resolveBlockRef(r,t);return(n!=null?this.host.tagIndex.get(n):void 0)??[]}getOutline(){return Object.values(this.host.bundle.scenes).map(e=>({id:e.id,...f(e)?{gameId:f(e)}:{},name:e.name,...this.tagsField(e.id),blocks:e.blocks.map(t=>({id:t.id,...f(t)?{gameId:f(t)}:{},name:t.name,...this.tagsField(t.id),children:t.children.map(r=>this.outlineNode(r))}))}))}getBeatSequence(){let e=[];for(let t of Object.values(this.host.bundle.scenes))for(let r of t.blocks)k(r.children,n=>{if(n.type==="snippet")for(let i of n.beats??[])e.push({sceneId:t.id,blockId:r.id,snippetId:n.id,beat:this.beatInfo(i)})});return e}outlineNode(e){return e.type==="group"?{type:"group",id:e.id,...this.tagsField(e.id),...e.selector?{selector:e.selector}:{},...e.prompt?{prompt:this.beatInfo(e.prompt)}:{},children:e.children.map(t=>this.outlineNode(t))}:{type:"snippet",id:e.id,...this.tagsField(e.id),beats:(e.beats??[]).map(t=>this.beatInfo(t)),...e.jump?{jumpTo:e.jump.to,...e.jump.mode?{jumpMode:e.jump.mode}:{}}:{}}}beatInfo(e){let t=this.host.tagIndex.get(e.id),r={id:e.id,kind:e.kind};if(e.kind==="line"){if(e.character!==void 0){r.character=e.character;let n=this.host.defaultStrings[V(e.character)]??this.host.castDisplay.get(e.character);n!==void 0&&(r.characterName=n)}e.direction!==void 0&&(r.direction=e.direction)}if(e.kind==="line"||e.kind==="text"){let n=this.host.defaultStrings[e.id];n!==void 0&&(r.text=n)}return e.gameData&&Object.keys(e.gameData).length&&(r.gameData=e.gameData),t&&t.length&&(r.tags=t),r}tagsField(e){let t=this.host.tagIndex.get(e);return t&&t.length?{tags:t}:{}}getFlow(e){return this.flowsById.get(e)}flows(){return[...this.flowsById.values()]}closeFlow(e){this.flowsById.delete(e)}reset(){this.flowsById.clear(),this.host.shared.reseedOwned("patter",this.host.patterSharedDecls),this.host.sharedVisits.clear(),this.host.sharedSelectors.clear(),this.host.stageBags.clear()}getProperty(e){let{scope:t,name:r}=this.splitShared(e);return this.host.shared.get(t,r)}setProperty(e,t){let{scope:r,name:n}=this.splitShared(e);this.host.shared.set(r,n,t)}listProperties(){return this.host.patterSharedDecls.map(e=>({ref:`@${e.name}`,type:e.type,values:e.values,value:this.getProperty(`@${e.name}`),default:ye(e)}))}splitShared(e){let t=this.host.refSplitCache.get(e);if(t||(t=A(e,r=>r==="scene"||this.host.shared.has(r)),this.host.refSplitCache.set(e,t)),t.scope==="scene")throw new Error(`\'${e}\': @scene properties are scene-scoped - read/write them on a Flow, not the Engine`);return t}save(){return this.host.shared.save()}load(e){this.host.shared.load(e)}saveGame(){let e={};for(let[t,r]of this.flowsById)e[t]=r.snapshot();return{version:2,shared:this.host.shared.save(),sharedVisits:Object.fromEntries(this.host.sharedVisits),sharedSelectors:ee(this.host.sharedSelectors),stageBags:Object.fromEntries([...this.host.stageBags].map(([t,r])=>[t,{...r}])),flows:e}}loadGame(e){if(e.version!==2)throw new Error(`unsupported save version: ${e.version}`);this.host.shared.load(e.shared),this.host.sharedVisits.clear();for(let[t,r]of Object.entries(e.sharedVisits??{}))this.host.sharedVisits.set(t,r);this.host.sharedSelectors.clear();for(let[t,r]of te(e.sharedSelectors))this.host.sharedSelectors.set(t,r);this.host.stageBags.clear();for(let[t,r]of Object.entries(e.stageBags??{}))this.host.stageBags.set(t,{...r});this.flowsById.clear();for(let[t,r]of Object.entries(e.flows)){let n=new b(t,this.host,this.defaultSeed);n.restore(r),this.flowsById.set(t,n)}}},b=class{id;host;local;rngState;started=!1;flowEnded=!1;currentSceneId=null;stack=[];activeSnippet=null;beatIndex=0;pendingChoice=null;pendingPromptBeat=null;pendingPromptOwnerId=null;selectors=new Map;visitCounts=new Map;sceneBags=new Map;patterResolver={get:e=>this.host.patterSharedNames.has(e)?this.host.shared.get("patter",e):this.local.get("patter",e),set:(e,t)=>{this.host.patterSharedNames.has(e)?this.host.shared.set("patter",e,t):this.local.set("patter",e,t)}};sceneResolver={get:e=>{let t=this.currentSceneId;return t===null?void 0:(this.host.sceneSharedNames.get(t)?.has(e)?this.host.stageBags.get(t):this.sceneBags.get(t))?.[e]},set:(e,t)=>{let r=this.currentSceneId;if(r===null)return;let n=this.host.sceneSharedNames.get(r)?.has(e)?this.host.stageBags.get(r):this.sceneBags.get(r);n&&(n[e]=t)}};evalCtx;constructor(e,t,r){this.id=e,this.host=t,this.rngState=r>>>0,this.local=this.freshLocal();let n={...t.shared.toEvalContext().scopes};n.patter=this.patterResolver,n.scene=this.sceneResolver,this.evalCtx={scopes:n,host:{nextRandom:this.rng,visits:i=>this.visitCounts.get(i)??0,patterVisits:i=>this.host.sharedVisits.get(i)??0}}}start(e,t){if(this.sceneBags.clear(),this.local=this.freshLocal(),this.selectors.clear(),this.visitCounts.clear(),this.stack=[],this.currentSceneId=null,this.flowEnded=!1,this.activeSnippet=null,this.beatIndex=0,this.pendingChoice=null,this.started=!0,t){let r=this.host.blockIndex.get(t);if(!r)throw new Error(`unknown block: ${t}`);this.enterSceneSetup(r.sceneId),this.stack=[{sceneId:r.sceneId,containerId:t,index:0}],this.enter(t)}else{let r=e??Object.keys(this.host.bundle.scenes)[0],n=r?this.host.bundle.scenes[r]:void 0;if(!n)throw new Error(r?`unknown scene: ${r}`:"no scenes in bundle");this.enterSceneSetup(r);let i=n.blocks[0];i&&(this.stack=[{sceneId:r,containerId:i.id,index:0}],this.enter(i.id))}this.settle()}reset(e,t){this.start(e,t)}get currentScene(){return this.currentSceneId}advance(){if(!this.started)throw new Error("flow has not been started");if(this.pendingPromptBeat){let e=this.pendingPromptBeat;return this.pendingPromptBeat=null,this.pendingPromptOwnerId=null,this.beatResult(e)}return this.settle(),this.flowEnded?{type:"end"}:this.pendingChoice?{type:"choice",groupId:this.pendingChoice.groupId,options:this.pendingChoice.options}:this.activeSnippet?this.beatResult(this.activeSnippet.beats[this.beatIndex++]):(this.flowEnded=!0,{type:"end"})}advanceToStop(){let e=[];for(;;){let t=this.advance();if(t.type==="choice"||t.type==="end")return{played:e,stop:t};e.push(t)}}settle(){let e=0;for(;;){if(++e>1e4)throw new Error("flow did not settle after 10000 transitions - likely a jump cycle with no deliverable content");if(this.flowEnded||this.pendingChoice)return;if(this.activeSnippet){if(this.beatIndex<(this.activeSnippet.beats?.length??0))return;this.runEffects(this.activeSnippet.onExit);let n=this.activeSnippet.jump;this.activeSnippet=null,this.beatIndex=0,this.resolveJump(n);continue}let t=this.stack[this.stack.length-1];if(!t){this.flowEnded=!0;return}t.sceneId!==this.currentSceneId&&(this.currentSceneId=t.sceneId);let r=this.childrenOf(t.containerId);if(!r){this.stack.pop();continue}for(;t.index<r.length&&!this.eligible(r[t.index]);)t.index++;if(t.index>=r.length){this.stack.pop();continue}this.enterChild(r[t.index++])}}getChoices(){return this.pendingChoice?.options??[]}choose(e){let t=this.pendingChoice;if(!t)throw new Error("no choice is pending");let r=t.options.find(i=>i.id===e);if(!r)throw new Error(`unknown choice option: ${e}`);if(!r.eligible)throw new Error(`choice option is not eligible: ${e}`);let n=t.byId.get(e);this.pendingChoice=null,this.pendingPromptBeat=this.host.replayPromptOnChoose?this.promptBeatOf(n)??null:null,this.pendingPromptOwnerId=this.pendingPromptBeat?n.id:null,this.enterChild(n)}isEnded(){return this.flowEnded}getProperty(e){let{scope:t,name:r}=this.splitRef(e);return t==="patter"?this.patterResolver.get(r):t==="scene"?this.sceneResolver.get(r):this.host.shared.get(t,r)}setProperty(e,t){let{scope:r,name:n}=this.splitRef(e);if(r==="patter")this.patterResolver.set(n,t);else if(r==="scene"){if(this.currentSceneId===null)throw new Error(`\'${e}\': the flow has not entered a scene yet`);this.sceneResolver.set(n,t)}else this.host.shared.set(r,n,t)}snapshot(){return{scopes:this.local.save(),sceneBags:Object.fromEntries([...this.sceneBags].map(([e,t])=>[e,{...t}])),rngState:this.rngState,visits:Object.fromEntries(this.visitCounts),cursor:{flowEnded:this.flowEnded,currentSceneId:this.currentSceneId,stack:this.stack.map(e=>{let t=this.childrenOf(e.containerId)?.[e.index];return t?{...e,nextId:t.id}:{...e}}),activeSnippetId:this.activeSnippet?.id??null,beatIndex:this.beatIndex,pendingChoice:this.pendingChoice?{groupId:this.pendingChoice.groupId,options:this.pendingChoice.options.map(e=>({...e}))}:null,pendingPromptOwnerId:this.pendingPromptOwnerId,selectors:ee(this.selectors)}}}restore(e){this.rngState=e.rngState>>>0,this.visitCounts=new Map(Object.entries(e.visits??{}));let t=e.cursor;if(this.started=!0,this.flowEnded=t.flowEnded,this.beatIndex=t.beatIndex,this.currentSceneId=t.currentSceneId,this.stack=t.stack.map(r=>{let{nextId:n,...i}=r;if(n!==void 0){let o=this.childrenOf(i.containerId)?.findIndex(a=>a.id===n)??-1;if(o>=0)return{...i,index:o}}return{...i}}),this.sceneBags=new Map(Object.entries(e.sceneBags??{}).map(([r,n])=>[r,{...n}])),this.local=this.freshLocal(),this.local.load(e.scopes),this.activeSnippet=null,t.activeSnippetId!==null){let r=this.host.nodeIndex.get(t.activeSnippetId);r&&r.type==="snippet"&&(this.activeSnippet=r)}if(this.selectors=te(t.selectors),this.pendingChoice=null,t.pendingChoice!==null){let r=new Map,n=[];for(let i of t.pendingChoice.options){let o=this.host.nodeIndex.get(i.id);o&&(r.set(i.id,o),n.push({...i}))}n.length>0&&(this.pendingChoice={groupId:t.pendingChoice.groupId,options:n,byId:r})}if(this.pendingPromptBeat=null,this.pendingPromptOwnerId=t.pendingPromptOwnerId??null,this.pendingPromptOwnerId){let r=this.host.nodeIndex.get(this.pendingPromptOwnerId);this.pendingPromptBeat=r?this.promptBeatOf(r)??null:null,this.pendingPromptBeat||(this.pendingPromptOwnerId=null)}}enterSceneSetup(e){let t=this.host.bundle.scenes[e];if(!t)throw new Error(`unknown scene: ${e}`);this.currentSceneId=e,this.enter(e),this.seedScene(t),this.runEffects(t.onEntry)}enterChild(e){if(this.enter(e.id),e.type==="snippet"){this.beginSnippet(e);return}let t=e.selector??"run";if(t==="run"){this.stack.push({sceneId:this.currentSceneId,containerId:e.id,index:0});return}if(t==="choice"){this.setupChoice(e);return}let r=this.selectChild(e);r&&this.enterChild(r)}childrenOf(e){let t=this.host.blockById.get(e);if(t)return t.children;let r=this.host.nodeIndex.get(e);if(r&&r.type==="group")return r.children}beginSnippet(e){this.runEffects(e.onEnter),this.activeSnippet=e,this.beatIndex=0}setupChoice(e){let t=[],r=new Map,n=[];for(let o of e.children){if(o.fallback===!0){n.push(o);continue}if(o.sticky!==!0&&(this.visitCounts.get(o.id)??0)>=1)continue;let a=this.eligible(o),p=o.secretUntilEligible===!0;!a&&p||(t.push({id:o.id,prompt:this.promptFor(o),eligible:a,gameData:o.gameData}),r.set(o.id,o))}if(t.length>0){this.pendingChoice={groupId:e.id,options:t,byId:r};return}let i=n.find(o=>this.eligible(o));if(i){this.enterChild(i);return}this.host.onDryChoice?.(e.id)}resolveJump(e){e&&this.enterTarget(e.to,e.mode==="call"?"call":"jump")}enterTarget(e,t){if(e==="END"){this.flowEnded=!0,this.stack=[];return}let r,n,i=this.host.bundle.scenes[e];if(i){this.enterSceneSetup(e);let a=i.blocks[0];if(!a){t==="jump"&&(this.stack=[]);return}r=e,n=a.id}else{let a=this.host.blockIndex.get(e);if(!a)throw new Error(`jump target not found: ${e}`);a.sceneId!==this.currentSceneId&&this.enterSceneSetup(a.sceneId),r=a.sceneId,n=e}this.enter(n);let o={sceneId:r,containerId:n,index:0};t==="call"?this.stack.push(o):this.stack=[o]}selectChild(e){let t=e.children.filter(n=>this.eligible(n));if(t.length===0)return null;let r=this.selectorState(e);switch(e.selector){case"branch":return t[0];case"sequence":{let n=e.options?.order??"sequential",i=e.options?.exhaust??"once";return n==="shuffle"?this.pickShuffle(t,i,r):this.pickSequential(t,i,r)}default:return null}}pickSequential(e,t,r){let n=e.length,i=r.seq??0;return r.seq=i+1,t==="repeat"?e[i%n]:i<n?e[i]:t==="stick"?e[n-1]:null}pickShuffle(e,t,r){let n=e.length,i=t==="stick",o=()=>(i?e.slice(0,n-1):e).map(u=>u.id);if(r.bag===void 0&&(r.bag=o()),r.bag.length===0){if(t==="once")return null;if(i){let u=e[n-1];return r.last=u.id,u}r.bag=o()}let a=r.bag,p=r.last!==void 0&&a.length>1?a.indexOf(r.last):-1,d=Math.floor(this.rng()*(p>=0?a.length-1:a.length));p>=0&&d>=p&&d++;let v=a[d];return a.splice(d,1),r.last=v,e.find(u=>u.id===v)}selectorState(e){let t=e.shared?this.host.sharedSelectors:this.selectors,r=t.get(e.id);return r||(r={},t.set(e.id,r)),r}runEffects(e){for(let t of e??[])this.setProperty(t.target,this.evalExpr(t.value))}eligible(e){return e.condition?we(this.evalExpr(e.condition)):!0}evalExpr(e){let t=Z.get(e);return t||(t=m(e.ast),Z.set(e,t)),P(t,this.evalCtx,J)}enter(e){this.visitCounts.set(e,(this.visitCounts.get(e)??0)+1),this.host.sharedVisits.set(e,(this.host.sharedVisits.get(e)??0)+1)}rng=()=>{if(this.host.customRng)return this.host.customRng();let e=this.rngState+1831565813|0;this.rngState=e;let t=Math.imul(e^e>>>15,1|e);return t=t+Math.imul(t^t>>>7,61|t)^t,((t^t>>>14)>>>0)/4294967296};beatResult(e){let t=this.host.tagIndex.get(e.id),r=t&&t.length?{tags:t}:{};switch(e.kind){case"gameEvent":return{type:"gameEvent",id:e.id,gameData:e.gameData,...r};case"text":return{type:"text",id:e.id,text:this.interpolate(this.resolveString(e.id)),gameData:e.gameData,...r};case"line":{let n=this.resolveString(e.id),i=!this.host.captionsOn,a=i&&e.character===this.host.captionCharacter?"":this.captionLine(this.host.bundle.voiced?n:this.interpolate(n)),p=i&&a.length===0;return{type:"line",id:e.id,text:a,character:p?void 0:e.character,characterName:p?void 0:this.resolveCharacterName(e.character),direction:p?void 0:e.direction,gameData:e.gameData,...r}}}}interpolate(e){return z(e,t=>this.getProperty(t))}stripCaptions(e){return K(e,this.host.captionOpen,this.host.captionClose)}captionLine(e){return this.host.captionsOn?e:this.stripCaptions(e)}promptFor(e){let t=this.promptBeatOf(e);if(!t)return;let r=this.interpolate(this.resolveString(t.id));return t.kind==="line"?{kind:"line",text:this.captionLine(r),character:t.character,characterName:this.resolveCharacterName(t.character),direction:t.direction}:{kind:"text",text:r}}promptBeatOf(e){return e.type==="group"&&e.prompt?e.prompt:((e.type==="snippet"?e:this.firstTextSnippetIn(e.children))?.beats??[]).find(r=>r.kind==="line"||r.kind==="text")}firstTextSnippetIn(e){let t;return k(e,r=>{!t&&r.type==="snippet"&&(r.beats??[]).some(n=>n.kind==="line"||n.kind==="text")&&(t=r)}),t}resolveString(e){if(this.host.emitIds)return e;let t=this.host.strings[e];if(t!==void 0)return t;let r=this.host.defaultStrings[e];return r!==void 0?`<Untranslated: ${e}> ${r}`:e}resolveCharacterName(e){if(e===void 0||this.host.emitIds)return;let t=V(e);return this.host.strings[t]??this.host.defaultStrings[t]??this.host.castDisplay.get(e)}splitRef(e){let t=this.host.refSplitCache.get(e);return t||(t=A(e,r=>r==="scene"||this.host.shared.has(r)),this.host.refSplitCache.set(e,t)),t}freshLocal(){return new w().defineOwned("patter",this.host.patterLocalDecls)}seedScene(e){let t=this.host.sceneSharedNames.get(e.id)??new Set;if(!this.sceneBags.has(e.id)){let r={};for(let n of e.sceneProps??[]){let i=n.name.toLowerCase();t.has(i)||(r[i]=T(n))}this.sceneBags.set(e.id,r)}if(!this.host.stageBags.has(e.id)){let r={};for(let n of e.sceneProps??[]){let i=n.name.toLowerCase();t.has(i)&&(r[i]=T(n))}this.host.stageBags.set(e.id,r)}for(let r of e.sceneProps??[]){if(!r.temporary)continue;let n=r.name.toLowerCase(),i=t.has(n)?this.host.stageBags.get(e.id):this.sceneBags.get(e.id);i&&(i[n]=T(r))}}};function ee(s){let e={};for(let[t,r]of s){let n={};r.seq!==void 0&&(n.seq=r.seq),r.bag&&(n.bag=[...r.bag]),r.last!==void 0&&(n.last=r.last),e[t]=n}return e}function te(s){let e=new Map;for(let[t,r]of Object.entries(s??{})){let n={};r.seq!==void 0&&(n.seq=r.seq),r.bag&&(n.bag=[...r.bag]),r.last!==void 0&&(n.last=r.last),e.set(t,n)}return e}function Q(s){return{name:s.name,type:s.type,values:s.values,default:s.default}}function ye(s){if(s.default!==void 0)return s.default;switch(s.type){case"number":return 0;case"string":return"";case"flags":return[];case"enum":return s.values?.[0]??"";default:return!1}}function Y(s){return{name:s.name,type:s.type,values:s.values,default:s.default,writable:s.writable}}function be(s){if(s.default!==void 0)return s.default;switch(s.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return s.values?.[0]??""}}function ve(s){let e=new Map;for(let t of s)e.set(t.name,be(t));return{get:t=>e.get(t),set:(t,r)=>{e.set(t,r)}}}function T(s){if(s.default!==void 0)return s.default;switch(s.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return s.values?.[0]??""}}function we(s){return typeof s=="boolean"?s:typeof s=="number"?s!==0:typeof s=="string"?s!=="":s.length>0}function re(s,e){return s.gameDataFields?.[e]??[]}function G(s,e,t){return e&&Object.prototype.hasOwnProperty.call(e,t)?e[t]:s.find(r=>r.name===t)?.default}function ne(s,e){let t={};for(let r of s){let n=G(s,e,r.name);n!==void 0&&(t[r.name]=n)}for(let[r,n]of Object.entries(e??{}))r in t||(t[r]=n);return t}return de(xe);})();\n//# sourceMappingURL=patterplay.min.js.map';
336
+
337
+ // src/export-html.ts
338
+ var esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
339
+ var PLAYER_JS = String.raw`
340
+ (function () {
341
+ var Engine = window.Patterplay.Engine;
342
+ var BUNDLE = window.PATTER_BUNDLE;
343
+ var startScene = (BUNDLE.start && BUNDLE.start.scene) || Object.keys(BUNDLE.scenes)[0];
344
+ var SAVE_KEY = "patter.play." + ((BUNDLE.content && BUNDLE.content.hash) || "x");
345
+
346
+ var stage = document.getElementById("stage");
347
+ var controls = document.getElementById("controls");
348
+ var engine, flow;
349
+
350
+ // Stable per-character hue (same idea as the editor's colour-by-character), tinted for a light page.
351
+ function hueOf(name) { var h = 0; for (var i = 0; i < (name || "").length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; return h; }
352
+ function add(cls, html) { var d = document.createElement("div"); d.className = cls; d.innerHTML = html; stage.appendChild(d); return d; }
353
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); }
354
+
355
+ // Pacing: a source-only reader carries no audio, so each line is held on screen for a reading-length delay
356
+ // (proportional to its word count) before the next appears, and every line fades in (see CSS). The Speed
357
+ // control sets the reading pace (words/min); "Instant" drops the delay entirely. The choice is persisted.
358
+ // A run token cancels an in-flight paced read the moment the player restarts, loads, or picks a choice.
359
+ var SPEED_WPM = { slow: 300, normal: 500, fast: 800, instant: 0 }, runToken = 0;
360
+ var speedKey = (function () { try { return localStorage.getItem("patter.playSpeed") || "normal"; } catch (e) { return "normal"; } })();
361
+ if (SPEED_WPM[speedKey] === undefined) speedKey = "normal";
362
+ function fakeDuration(text) {
363
+ var wpm = SPEED_WPM[speedKey];
364
+ if (!wpm) return 0; // "Instant" - reveal the next line immediately
365
+ var words = String(text == null ? "" : text).trim().split(/\s+/).filter(Boolean).length;
366
+ return Math.max(450, Math.round((words / wpm) * 60000)); // a beat needs to land
367
+ }
368
+ function scrollDown() { window.scrollTo(0, document.body.scrollHeight); }
369
+ var speedSel = document.getElementById("speed");
370
+ if (speedSel) {
371
+ speedSel.value = speedKey; // a fresh fakeDuration() reads speedKey each line, so a change applies next line
372
+ speedSel.onchange = function () { speedKey = speedSel.value; try { localStorage.setItem("patter.playSpeed", speedKey); } catch (e) {} };
373
+ }
374
+
375
+ function build() { engine = new Engine(BUNDLE); flow = engine.openFlow("main", { scene: startScene }); }
376
+
377
+ function newGame() { stage.innerHTML = ""; controls.innerHTML = ""; build(); run(); }
378
+
379
+ function run() {
380
+ controls.innerHTML = "";
381
+ var token = ++runToken;
382
+ (function step() {
383
+ if (token !== runToken) return; // superseded by a restart / load / choice pick
384
+ var s = flow.advance();
385
+ if (!s) { add("end", "The End"); return; } // defensive: a step should always be returned; never hard-crash
386
+ if (s.type === "line") {
387
+ var who = s.characterName || s.character || "";
388
+ var dir = s.direction ? '<em class="dir">(' + esc(s.direction) + ')</em> ' : "";
389
+ add("line", '<span class="who" style="color:hsl(' + hueOf(s.character || who) + ',55%,38%)">' + esc(who) + '</span>' + dir + esc(s.text));
390
+ scrollDown(); setTimeout(step, fakeDuration(s.text));
391
+ } else if (s.type === "text") {
392
+ add("text", esc(s.text)); scrollDown(); setTimeout(step, fakeDuration(s.text));
393
+ } else if (s.type === "choice") { renderChoice(s); }
394
+ else if (s.type === "end") { add("end", "The End"); }
395
+ else { step(); } // game-event beat: a host cue with no player-facing text - skip it, advance at once
396
+ })();
397
+ }
398
+
399
+ function renderChoice(step) {
400
+ var opts = step.options || [];
401
+ if (!opts.length) { add("end", "The End"); return; }
402
+ for (var i = 0; i < opts.length; i++) (function (opt, idx) {
403
+ var b = document.createElement("button");
404
+ b.className = "choice"; b.textContent = (opt.prompt && opt.prompt.text) || "(continue)";
405
+ b.disabled = opt.eligible === false;
406
+ b.style.animationDelay = (idx * 60) + "ms"; // gentle stagger so the options fade in one after another
407
+ b.onclick = function () { flow.choose(opt.id); scrollDown(); run(); };
408
+ controls.appendChild(b);
409
+ })(opts[i], i);
410
+ scrollDown();
411
+ }
412
+
413
+ document.getElementById("restart").onclick = newGame;
414
+ document.getElementById("save").onclick = function () { try { localStorage.setItem(SAVE_KEY, JSON.stringify(engine.saveGame())); flash("Saved"); } catch (e) {} };
415
+ document.getElementById("load").onclick = function () {
416
+ var blob; try { blob = localStorage.getItem(SAVE_KEY); } catch (e) {}
417
+ if (!blob) { flash("No save yet"); return; }
418
+ engine = new Engine(BUNDLE);
419
+ engine.loadGame(JSON.parse(blob)); flow = engine.getFlow("main");
420
+ stage.innerHTML = ""; add("text", "(resumed from save)"); run();
421
+ };
422
+ function flash(msg) { var f = document.getElementById("flash"); f.textContent = msg; f.style.opacity = "1"; setTimeout(function () { f.style.opacity = "0"; }, 1100); }
423
+
424
+ newGame();
425
+ })();
426
+ `;
427
+ var STYLE = String.raw`
428
+ :root { color-scheme: light; }
429
+ * { box-sizing: border-box; }
430
+ body { margin: 0; background: #f6f1e7; color: #2b2620; font: 18px/1.65 Georgia, "Times New Roman", serif; }
431
+ .wrap { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 6rem; }
432
+ header { display: flex; align-items: baseline; gap: 0.75rem; border-bottom: 1px solid #e0d7c5; padding-bottom: 0.75rem; margin-bottom: 1.25rem; }
433
+ h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
434
+ header .by { color: #9a8f78; font-size: 0.8rem; font-family: ui-sans-serif, system-ui, sans-serif; margin-left: auto; }
435
+ #stage > div { margin: 0.55rem 0; animation: fade 0.45s ease-out both; }
436
+ @keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
437
+ @media (prefers-reduced-motion: reduce) { #stage > div, .choice { animation: none; } }
438
+ .line .who { font-weight: 700; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 0.8rem; letter-spacing: 0.02em; text-transform: uppercase; margin-right: 0.4rem; }
439
+ .line .dir { color: #9a8f78; font-style: italic; }
440
+ .text { color: #4a4334; }
441
+ .end { color: #9a8f78; font-style: italic; text-align: center; margin-top: 1.5rem; }
442
+ #controls { margin: 1.25rem 0; display: flex; flex-direction: column; gap: 0.5rem; }
443
+ .choice { font: 1rem Georgia, serif; text-align: left; background: #fff; border: 1px solid #d9cfbb; border-radius: 8px; padding: 0.55rem 0.8rem; cursor: pointer; transition: border-color .15s, background .15s; animation: fade 0.4s ease-out both; }
444
+ .choice:hover:not(:disabled) { border-color: #b8956a; background: #fffaf0; }
445
+ .choice:disabled { color: #b8af9b; cursor: default; }
446
+ .bar { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(246,241,231,0.94); border-top: 1px solid #e0d7c5; backdrop-filter: blur(4px); }
447
+ .bar .inner { max-width: 42rem; margin: 0 auto; padding: 0.6rem 1.25rem; display: flex; gap: 0.5rem; align-items: center; }
448
+ .bar button { font: 0.85rem ui-sans-serif, system-ui, sans-serif; background: #fff; border: 1px solid #d9cfbb; border-radius: 6px; padding: 0.3rem 0.7rem; cursor: pointer; }
449
+ .bar button:hover { border-color: #b8956a; }
450
+ .bar .speedlabel { font: 0.8rem ui-sans-serif, system-ui, sans-serif; color: #9a8f78; display: flex; align-items: center; gap: 0.3rem; }
451
+ .bar #speed { font: 0.85rem ui-sans-serif, system-ui, sans-serif; background: #fff; border: 1px solid #d9cfbb; border-radius: 6px; padding: 0.3rem 0.4rem; cursor: pointer; color: #2b2620; }
452
+ .bar #speed:hover { border-color: #b8956a; }
453
+ #flash { margin-left: auto; color: #9a8f78; font: 0.8rem ui-sans-serif, system-ui, sans-serif; opacity: 0; transition: opacity .2s; }
454
+ `;
455
+ function sourceStory(loaded) {
456
+ const full = runExportFull(loaded);
457
+ const def = full.locales?.default ?? Object.keys(full.strings)[0] ?? "en";
458
+ const bundle = { ...full, locales: { default: def, included: [def] }, strings: { [def]: full.strings[def] ?? {} } };
459
+ const title = loaded.project.project.name?.trim() || "A Patter story";
460
+ const bundleJson = canonicalStringify(bundle, { trailingComma: false }).replace(/</g, "\\u003c");
461
+ return { title, lang: def, bundleJson };
462
+ }
463
+ function page(title, lang, head, tail) {
464
+ return `<!doctype html>
465
+ <html lang="${esc(lang)}">
466
+ <head>
467
+ <meta charset="utf-8" />
468
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
469
+ <title>${esc(title)}</title>
470
+ ${head}
471
+ </head>
472
+ <body>
473
+ <div class="wrap">
474
+ <header>
475
+ <h1>${esc(title)}</h1>
476
+ <span class="by">Patter</span>
477
+ </header>
478
+ <main id="stage"></main>
479
+ <div id="controls"></div>
480
+ </div>
481
+ <div class="bar"><div class="inner">
482
+ <button id="restart">\u21BA Restart</button>
483
+ <button id="save">Save</button>
484
+ <button id="load">Load</button>
485
+ <label class="speedlabel">Speed
486
+ <select id="speed" title="Reading speed: how long each line is held before the next appears">
487
+ <option value="slow">Slow</option>
488
+ <option value="normal">Normal</option>
489
+ <option value="fast">Fast</option>
490
+ <option value="instant">Instant</option>
491
+ </select>
492
+ </label>
493
+ <span id="flash"></span>
494
+ </div></div>
495
+ ${tail}
496
+ </body>
497
+ </html>
498
+ `;
499
+ }
500
+ function runExportHtml(loaded) {
501
+ const { title, lang, bundleJson } = sourceStory(loaded);
502
+ return page(
503
+ title,
504
+ lang,
505
+ `<style>${STYLE}</style>`,
506
+ `<script>${PLAYABLE_RUNTIME_JS}</script>
507
+ <script>window.PATTER_BUNDLE=${bundleJson};</script>
508
+ <script>${PLAYER_JS}</script>`
509
+ );
510
+ }
511
+ function runExportWeb(loaded) {
512
+ const { title, lang, bundleJson } = sourceStory(loaded);
513
+ return {
514
+ indexHtml: page(
515
+ title,
516
+ lang,
517
+ `<link rel="stylesheet" href="style.css" />`,
518
+ // The story must load first (patterplay.js reads window.PATTER_BUNDLE as it starts).
519
+ `<script src="story.js"></script>
520
+ <script src="patterplay.js"></script>`
521
+ ),
522
+ styleCss: `${STYLE.trim()}
523
+ `,
524
+ patterplayJs: `${PLAYABLE_RUNTIME_JS}
525
+ ${PLAYER_JS}`,
526
+ storyJs: `window.PATTER_BUNDLE=${bundleJson};
527
+ `
528
+ };
529
+ }
530
+
531
+ // src/audio-scan.ts
532
+ import { readdirSync as readdirSync2 } from "fs";
533
+ import { resolve as resolve2, extname, basename as basename3 } from "path";
534
+ import { deriveRecordingFolders, DEFAULT_RECORDING_STATUSES } from "@patterkit/model";
535
+ var AUDIO_EXT = /* @__PURE__ */ new Set([".wav", ".mp3"]);
536
+ function scanAudioStatus(loaded) {
537
+ const p = loaded.project;
538
+ const tracked = !!p.voiced && (p.trackAudioStatus ?? false);
539
+ if (!tracked || !p.audioFolders || !p.audioRoot?.trim()) return void 0;
540
+ const rungs = deriveRecordingFolders(p.audioRoot, p.recordingStatuses ?? DEFAULT_RECORDING_STATUSES);
541
+ const out = /* @__PURE__ */ new Map();
542
+ for (const rung of rungs) {
543
+ if (!rung.folder) continue;
544
+ let files;
545
+ try {
546
+ files = readdirSync2(resolve2(loaded.root, rung.folder));
547
+ } catch {
548
+ continue;
549
+ }
550
+ for (const f of files) {
551
+ const ext = extname(f).toLowerCase();
552
+ if (AUDIO_EXT.has(ext)) out.set(basename3(f, extname(f)), rung.name);
553
+ }
554
+ }
555
+ return out;
556
+ }
557
+
558
+ // src/play.ts
559
+ import { exportBundle as exportBundle3 } from "@patterkit/compiler";
560
+ import { Engine } from "@patterkit/runtime";
561
+
562
+ // src/loaded-helpers.ts
563
+ function tableFor(loaded, locale) {
564
+ const table = {};
565
+ for (const l of loaded.locales) if (l.locale === locale) Object.assign(table, l.strings);
566
+ return table;
567
+ }
568
+ function sourceStrings(loaded) {
569
+ return tableFor(loaded, loaded.project.locales.default);
570
+ }
571
+ function resolveStart(loaded, override) {
572
+ if (override?.scene) return { scene: override.scene, block: override.block };
573
+ if (loaded.project.start) return { scene: loaded.project.start.scene, block: loaded.project.start.block };
574
+ return {};
575
+ }
576
+ function stringsByLocale(loaded) {
577
+ const byLocale = /* @__PURE__ */ new Map();
578
+ for (const l of loaded.locales) {
579
+ const table = byLocale.get(l.locale) ?? {};
580
+ Object.assign(table, l.strings);
581
+ byLocale.set(l.locale, table);
582
+ }
583
+ return byLocale;
584
+ }
585
+ function mergeAuthoring(loaded) {
586
+ const writing = /* @__PURE__ */ new Map();
587
+ const recording = /* @__PURE__ */ new Map();
588
+ const cut = /* @__PURE__ */ new Set();
589
+ const documentation = /* @__PURE__ */ new Map();
590
+ const edits = /* @__PURE__ */ new Map();
591
+ for (const a of loaded.authoring) {
592
+ for (const [id, v] of Object.entries(a.writing ?? {})) writing.set(id, v);
593
+ for (const [id, v] of Object.entries(a.recording ?? {})) recording.set(id, v);
594
+ for (const [id, v] of Object.entries(a.cut ?? {})) if (v) cut.add(id);
595
+ for (const [id, lines] of Object.entries(a.documentation ?? {})) {
596
+ const cur = documentation.get(id);
597
+ if (cur) cur.push(...lines);
598
+ else documentation.set(id, [...lines]);
599
+ }
600
+ for (const [id, v] of Object.entries(a.edits ?? {})) edits.set(id, v);
601
+ }
602
+ return { writing, recording, cut, documentation, edits };
603
+ }
604
+
605
+ // src/play.ts
606
+ function runPlay(loaded, opts = {}) {
607
+ const bundle = exportBundle3({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
608
+ const events = [];
609
+ const engine = new Engine(bundle, { seed: opts.seed });
610
+ const start = resolveStart(loaded, opts);
611
+ const flow = engine.openFlow("main", { scene: start.scene, block: start.block });
612
+ const scripted = [...opts.choices ?? []];
613
+ const maxSteps = opts.maxSteps ?? 1e3;
614
+ for (let i = 0; i < maxSteps; i++) {
615
+ const r = flow.advance();
616
+ if (r.type === "end") return { events, outcome: "end" };
617
+ if (r.type === "choice") {
618
+ const picked = scripted.shift() ?? r.options.find((o) => o.eligible)?.id;
619
+ events.push({ type: "choice", options: r.options, picked });
620
+ if (picked === void 0) return { events, outcome: "stalled" };
621
+ flow.choose(picked);
622
+ } else {
623
+ events.push(r);
624
+ }
625
+ }
626
+ return { events, outcome: "max-steps" };
627
+ }
628
+ function renderPlay(result) {
629
+ const out = [];
630
+ for (const e of result.events) {
631
+ switch (e.type) {
632
+ case "line":
633
+ out.push(`${e.character ?? "?"}: ${e.text}`);
634
+ break;
635
+ case "text":
636
+ out.push(` ${e.text}`);
637
+ break;
638
+ case "gameEvent":
639
+ out.push(` (game event ${JSON.stringify(e.gameData ?? {})})`);
640
+ break;
641
+ case "choice":
642
+ for (const o of e.options) out.push(` ${o.eligible ? "[ ]" : "[x]"} ${o.prompt?.text ?? "(no label)"} (${o.id})`);
643
+ if (e.picked !== void 0) out.push(` > ${e.picked}`);
644
+ break;
645
+ }
646
+ }
647
+ switch (result.outcome) {
648
+ case "end":
649
+ out.push("--- END ---");
650
+ break;
651
+ case "stalled":
652
+ out.push(" (no eligible choice - stopping)");
653
+ break;
654
+ case "max-steps":
655
+ out.push("--- stopped: max steps reached ---");
656
+ break;
657
+ }
658
+ return out;
659
+ }
660
+
661
+ // src/coverage.ts
662
+ import { exportBundle as exportBundle4 } from "@patterkit/compiler";
663
+ import { Engine as Engine2 } from "@patterkit/runtime";
664
+ import { walkNodes } from "@patterkit/model";
665
+
666
+ // ../../../expr/packages/expr/src/ast.ts
667
+ function deserialiseAst(node) {
668
+ switch (node[0]) {
669
+ case "b":
670
+ return { kind: "bool", value: node[1] };
671
+ case "n":
672
+ return { kind: "number", value: node[1] };
673
+ case "s":
674
+ return { kind: "string", value: node[1] };
675
+ case "sv":
676
+ return { kind: "scopedvar", scope: node[1], name: node[2] };
677
+ case "u":
678
+ return { kind: "unary", op: node[1], operand: deserialiseAst(node[2]) };
679
+ case "bin":
680
+ return { kind: "binary", op: node[1], left: deserialiseAst(node[2]), right: deserialiseAst(node[3]) };
681
+ case "call": {
682
+ const args = node.slice(2).map(deserialiseAst);
683
+ return { kind: "call", name: node[1], args };
684
+ }
685
+ case "fd":
686
+ return { kind: "flagdelta", sign: node[1], name: node[2] };
687
+ }
688
+ }
689
+
690
+ // src/coverage.ts
691
+ var CADENCE_PROB = {
692
+ rarely: 0.15,
693
+ sometimes: 0.4,
694
+ often: 0.8
695
+ };
696
+ function mulberry32(seed) {
697
+ let a = seed >>> 0;
698
+ return () => {
699
+ a = a + 1831565813 | 0;
700
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
701
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
702
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
703
+ };
704
+ }
705
+ function scanExpr(node, hostTokens, refs, proposals) {
706
+ const refOf = (n) => n.kind === "scopedvar" && hostTokens.has(n.scope) ? `@${n.scope}.${n.name}` : void 0;
707
+ const propose = (ref, v) => (proposals.get(ref) ?? proposals.set(ref, /* @__PURE__ */ new Set()).get(ref)).add(v);
708
+ switch (node.kind) {
709
+ case "scopedvar": {
710
+ const r = refOf(node);
711
+ if (r) {
712
+ refs.add(r);
713
+ proposals.get(r) ?? proposals.set(r, /* @__PURE__ */ new Set());
714
+ }
715
+ break;
716
+ }
717
+ case "unary":
718
+ scanExpr(node.operand, hostTokens, refs, proposals);
719
+ break;
720
+ case "binary": {
721
+ for (const [a, b] of [[node.left, node.right], [node.right, node.left]]) {
722
+ const ref = refOf(a);
723
+ if (!ref) continue;
724
+ if (b.kind === "number") {
725
+ for (const d of [-1, 0, 1]) propose(ref, b.value + d);
726
+ } else if (b.kind === "string") propose(ref, b.value);
727
+ else if (b.kind === "bool") propose(ref, b.value);
728
+ }
729
+ scanExpr(node.left, hostTokens, refs, proposals);
730
+ scanExpr(node.right, hostTokens, refs, proposals);
731
+ break;
732
+ }
733
+ case "call":
734
+ for (const a of node.args) scanExpr(a, hostTokens, refs, proposals);
735
+ break;
736
+ }
737
+ }
738
+ function targetHostRef(target, hostTokens) {
739
+ const m = /^@([A-Za-z_][\w]*)\.(.+)$/.exec(target);
740
+ return m && hostTokens.has(m[1]) ? `@${m[1]}.${m[2]}` : void 0;
741
+ }
742
+ function analyzeHostScopes(bundle, hostTokens) {
743
+ const written = /* @__PURE__ */ new Set();
744
+ const gatesByBeat = /* @__PURE__ */ new Map();
745
+ const proposals = /* @__PURE__ */ new Map();
746
+ if (hostTokens.size === 0) return { written, gatesByBeat, proposals };
747
+ const refsIn = (expr) => {
748
+ const refs = /* @__PURE__ */ new Set();
749
+ if (expr) scanExpr(deserialiseAst(expr.ast), hostTokens, refs, proposals);
750
+ return refs;
751
+ };
752
+ const scanEffects = (effects) => {
753
+ for (const e of effects ?? []) {
754
+ const t = targetHostRef(e.target, hostTokens);
755
+ if (t) written.add(t);
756
+ refsIn(e.value);
757
+ }
758
+ };
759
+ const walk = (nodes, gate) => {
760
+ for (const node of nodes) {
761
+ const here = /* @__PURE__ */ new Set([...gate, ...refsIn(node.condition)]);
762
+ if (node.type === "group") {
763
+ walk(node.children, here);
764
+ } else {
765
+ scanEffects(node.onEnter);
766
+ scanEffects(node.onExit);
767
+ for (const beat of node.beats ?? []) gatesByBeat.set(beat.id, here);
768
+ }
769
+ }
770
+ };
771
+ for (const scene of Object.values(bundle.scenes)) {
772
+ scanEffects(scene.onEntry);
773
+ for (const block of scene.blocks) walk(block.children, /* @__PURE__ */ new Set());
774
+ }
775
+ return { written, gatesByBeat, proposals };
776
+ }
777
+ function proposeCoverageDrivers(loaded) {
778
+ const hostTokens = new Set((loaded.project.scopeRegistry?.scopes ?? []).map((s) => s.token));
779
+ if (hostTokens.size === 0) return [];
780
+ const bundle = exportBundle4({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
781
+ const { written, proposals } = analyzeHostScopes(bundle, hostTokens);
782
+ const declByRef = /* @__PURE__ */ new Map();
783
+ for (const s of loaded.project.scopeRegistry?.scopes ?? []) {
784
+ for (const d of s.declarations ?? []) declByRef.set(`@${s.token}.${d.name}`, { type: d.type, values: d.values });
785
+ }
786
+ const drivers = [];
787
+ for (const [ref, pool] of proposals) {
788
+ if (written.has(ref)) continue;
789
+ const decl = declByRef.get(ref);
790
+ let values = [...pool];
791
+ if (values.length === 0 && decl) {
792
+ if (decl.type === "boolean") values = [true, false];
793
+ else if (decl.type === "enum" && decl.values) values = [...decl.values];
794
+ }
795
+ if (values.length === 0) continue;
796
+ drivers.push({ ref, kind: "recurring", cadence: "sometimes", values: sortValues(values) });
797
+ }
798
+ return drivers.sort((a, b) => a.ref.localeCompare(b.ref));
799
+ }
800
+ function sortValues(values) {
801
+ return [...values].sort((a, b) => {
802
+ if (typeof a === "number" && typeof b === "number") return a - b;
803
+ return String(a).localeCompare(String(b));
804
+ });
805
+ }
806
+ function runCoverage(loaded, options = {}, hooks = {}) {
807
+ const runs = options.runs ?? 5e3;
808
+ const maxSteps = options.maxSteps ?? 200;
809
+ const seed = options.seed ?? 0;
810
+ const start = resolveStart(loaded, options);
811
+ const src = sourceStrings(loaded);
812
+ const order = [];
813
+ const meta = /* @__PURE__ */ new Map();
814
+ const choiceScene = /* @__PURE__ */ new Map();
815
+ for (const scene of loaded.scenes) {
816
+ for (const block of scene.blocks) {
817
+ walkNodes(block.children, (node) => {
818
+ if (node.type === "group") {
819
+ if (node.selector === "choice") choiceScene.set(node.id, scene.id);
820
+ return;
821
+ }
822
+ for (const beat of node.beats ?? []) {
823
+ if (meta.has(beat.id)) continue;
824
+ order.push(beat.id);
825
+ meta.set(beat.id, {
826
+ scene: scene.id,
827
+ kind: beat.kind,
828
+ character: beat.kind === "line" ? beat.character : void 0,
829
+ preview: beat.kind === "gameEvent" ? "(game event)" : src[beat.id] ?? ""
830
+ });
831
+ }
832
+ });
833
+ }
834
+ }
835
+ const hitCount = new Map(order.map((id) => [id, 0]));
836
+ const reachedRuns = new Map(order.map((id) => [id, 0]));
837
+ const dryRuns = /* @__PURE__ */ new Map();
838
+ const termination = { ended: 0, capped: 0, stalled: 0, evalError: 0 };
839
+ const bundle = exportBundle4({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
840
+ const rng = mulberry32(seed);
841
+ const hostTokens = new Set((loaded.project.scopeRegistry?.scopes ?? []).map((s) => s.token));
842
+ const analysis = analyzeHostScopes(bundle, hostTokens);
843
+ const drivers = (options.drivers ?? loaded.project.coverageDrivers ?? []).filter(
844
+ (d) => d.values.length > 0 && hostTokens.has(d.ref.replace(/^@/, "").split(".")[0] ?? "")
845
+ );
846
+ const initialDrivers = drivers.filter((d) => d.kind === "initial");
847
+ const recurringDrivers = drivers.filter((d) => d.kind === "recurring");
848
+ const drivenRefs = new Set(drivers.map((d) => d.ref));
849
+ const pick = (vals) => vals[Math.floor(rng() * vals.length)];
850
+ let executed = 0;
851
+ let cancelled = false;
852
+ for (let run = 0; run < runs; run++) {
853
+ if (hooks.signal?.aborted) {
854
+ cancelled = true;
855
+ break;
856
+ }
857
+ const dryThisRun = /* @__PURE__ */ new Set();
858
+ const engine = new Engine2(bundle, {
859
+ seed: Math.floor(rng() * 4294967296),
860
+ onDryChoice: (groupId) => dryThisRun.add(groupId)
861
+ });
862
+ for (const d of initialDrivers) engine.setProperty(d.ref, pick(d.values));
863
+ const flow = engine.openFlow("cov", { scene: start.scene, block: start.block });
864
+ const seenThisRun = /* @__PURE__ */ new Set();
865
+ let term = "capped";
866
+ try {
867
+ for (let step = 0; step < maxSteps; step++) {
868
+ const r = flow.advance();
869
+ if (r.type === "end") {
870
+ term = "ended";
871
+ break;
872
+ }
873
+ if (r.type === "choice") {
874
+ for (const d of recurringDrivers) {
875
+ if (rng() < CADENCE_PROB[d.cadence ?? "sometimes"]) engine.setProperty(d.ref, pick(d.values));
876
+ }
877
+ const eligible = r.options.filter((o) => o.eligible);
878
+ if (eligible.length === 0) {
879
+ term = "stalled";
880
+ break;
881
+ }
882
+ flow.choose(eligible[Math.floor(rng() * eligible.length)].id);
883
+ continue;
884
+ }
885
+ if (hitCount.has(r.id)) {
886
+ hitCount.set(r.id, hitCount.get(r.id) + 1);
887
+ seenThisRun.add(r.id);
888
+ }
889
+ }
890
+ } catch {
891
+ term = "evalError";
892
+ }
893
+ for (const id of seenThisRun) reachedRuns.set(id, reachedRuns.get(id) + 1);
894
+ for (const id of dryThisRun) dryRuns.set(id, (dryRuns.get(id) ?? 0) + 1);
895
+ termination[term]++;
896
+ executed++;
897
+ if ((run & 255) === 0) hooks.onProgress?.(executed, runs);
898
+ }
899
+ hooks.onProgress?.(executed, runs);
900
+ const unwrittenInputs = /* @__PURE__ */ new Set();
901
+ const beats = order.map((id) => {
902
+ const m = meta.get(id);
903
+ const reached = reachedRuns.get(id);
904
+ let needsInput;
905
+ if (reached === 0) {
906
+ const gates = [...analysis.gatesByBeat.get(id) ?? []].filter((r) => !analysis.written.has(r) && !drivenRefs.has(r));
907
+ if (gates.length) {
908
+ needsInput = gates;
909
+ for (const g of gates) unwrittenInputs.add(g);
910
+ }
911
+ }
912
+ return {
913
+ id,
914
+ scene: m.scene,
915
+ kind: m.kind,
916
+ character: m.character,
917
+ preview: m.preview,
918
+ hits: hitCount.get(id),
919
+ reachedRuns: reached,
920
+ reachPct: executed ? reached / executed * 100 : 0,
921
+ ...needsInput ? { needsInput } : {}
922
+ };
923
+ });
924
+ const neverHit = beats.filter((b) => b.reachedRuns === 0).length;
925
+ const covered = beats.length - neverHit;
926
+ const dryChoices = [...dryRuns.entries()].map(([id, r]) => ({ id, scene: choiceScene.get(id) ?? "", runs: r })).sort((a, b) => b.runs - a.runs || a.id.localeCompare(b.id));
927
+ return {
928
+ runs: executed,
929
+ maxSteps,
930
+ seed,
931
+ start,
932
+ beats,
933
+ totals: { beats: beats.length, covered, neverHit, coveragePct: beats.length ? covered / beats.length * 100 : 100 },
934
+ termination,
935
+ drivers,
936
+ unwrittenInputs: [...unwrittenInputs].sort(),
937
+ dryChoices,
938
+ cancelled
939
+ };
940
+ }
941
+ function renderCoverageText(report, sceneName = (id) => id) {
942
+ const out = [];
943
+ const t = report.totals;
944
+ const pct = (n) => `${n.toFixed(0)}%`;
945
+ out.push(`coverage: ${t.covered}/${t.beats} beats reached (${pct(t.coveragePct)})${t.neverHit ? ` - ${t.neverHit} never reached` : ""}`);
946
+ out.push(`${report.runs} run(s) - ${report.maxSteps} max steps - seed ${report.seed}${report.cancelled ? " - CANCELLED" : ""}`);
947
+ const term = report.termination;
948
+ out.push(`runs ended: ${term.ended} reached the end, ${term.stalled} stalled, ${term.capped} hit the step cap, ${term.evalError} errored`);
949
+ if (report.drivers.length) out.push(`input drivers: ${report.drivers.map((d) => d.ref).join(", ")}`);
950
+ if (report.unwrittenInputs.length) {
951
+ out.push(`? = gated on an input nothing writes/drives: ${report.unwrittenInputs.join(", ")} (add a coverage driver?)`);
952
+ }
953
+ if (report.dryChoices.length) {
954
+ out.push("");
955
+ out.push(`dry choices (fell through with nothing takeable - add a fallback or an unconditional option): ${report.dryChoices.length}`);
956
+ for (const d of report.dryChoices) {
957
+ out.push(` \u203C ${String(d.runs).padStart(6)} run(s) ${sceneName(d.scene)} choice '${d.id}'`);
958
+ }
959
+ }
960
+ const byScene = /* @__PURE__ */ new Map();
961
+ for (const b of report.beats) (byScene.get(b.scene) ?? byScene.set(b.scene, []).get(b.scene)).push(b);
962
+ const clip = (s, n = 48) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
963
+ for (const [scene, beats] of byScene) {
964
+ const dead = beats.filter((b) => b.reachedRuns === 0).length;
965
+ out.push("");
966
+ out.push(`${sceneName(scene)}${dead ? ` (${dead} never reached)` : ""}`);
967
+ for (const b of beats) {
968
+ const mark = b.reachedRuns === 0 ? b.needsInput ? "? " : "\u203C " : " ";
969
+ const label = b.character ? `${b.character}: ${clip(b.preview)}` : clip(b.preview || `(${b.kind})`);
970
+ out.push(` ${mark}${b.reachPct.toFixed(0).padStart(3)}% ${String(b.hits).padStart(6)} ${label}`);
971
+ }
972
+ }
973
+ return out;
974
+ }
975
+
976
+ // src/format.ts
977
+ import { readFileSync as readFileSync3 } from "fs";
978
+ import { canonicalStringify as canonicalStringify2, parseSource as parseSource3 } from "@patterkit/core";
979
+ function runFormat(files) {
980
+ return files.map((file) => {
981
+ const original = readFileSync3(file, "utf8");
982
+ let canonical;
983
+ try {
984
+ canonical = canonicalStringify2(parseSource3(original));
985
+ } catch (e) {
986
+ throw new Error(`${file}: ${e instanceof Error ? e.message : String(e)}`);
987
+ }
988
+ const changed = canonical !== original;
989
+ return changed ? { file, changed, write: { path: file, content: canonical } } : { file, changed };
990
+ });
991
+ }
992
+
993
+ // src/report.ts
994
+ import { walkNodes as walkNodes2, DEFAULT_WRITING_STATUSES, DEFAULT_RECORDING_STATUSES as DEFAULT_RECORDING_STATUSES2 } from "@patterkit/model";
995
+ var DEFAULT_WORDS_PER_LINE = 6;
996
+ var UNATTRIBUTED = "(unattributed)";
997
+ function allocateLargestRemainder(total, weights) {
998
+ const sum = [...weights.values()].reduce((a, b) => a + b, 0);
999
+ if (sum <= 0 || total <= 0) return /* @__PURE__ */ new Map();
1000
+ const parts = [...weights.entries()].map(([k, w]) => {
1001
+ const ideal = total * w / sum;
1002
+ const floor = Math.floor(ideal);
1003
+ return { k, floor, frac: ideal - floor };
1004
+ });
1005
+ let remainder = total - parts.reduce((n, p) => n + p.floor, 0);
1006
+ parts.sort((a, b) => b.frac - a.frac);
1007
+ for (let i = 0; i < parts.length && remainder > 0; i++, remainder--) parts[i].floor++;
1008
+ return new Map(parts.map((p) => [p.k, p.floor]));
1009
+ }
1010
+ function runReport(loaded, recordingOverride) {
1011
+ const { project } = loaded;
1012
+ const writingLadder = (project.writingStatuses ?? DEFAULT_WRITING_STATUSES).map((s) => s.name);
1013
+ const recordingLadder = (project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES2).map((s) => s.name);
1014
+ const stub = writingLadder[0];
1015
+ const ladderDecls = project.writingStatuses ?? DEFAULT_WRITING_STATUSES;
1016
+ const recordThreshold = ladderDecls.findIndex((s) => s.readyToRecord);
1017
+ const shipThreshold = ladderDecls.findIndex((s) => s.readyToShip);
1018
+ const writingIndex = new Map(writingLadder.map((name, i) => [name, i]));
1019
+ const est = project.estimating;
1020
+ const estimatingOn = est?.enabled ?? false;
1021
+ const thresholdIdx = estimatingOn ? writingIndex.get(est?.thresholdStatus ?? "") ?? 0 : -1;
1022
+ const defaultLines = est?.defaultLines ?? 0;
1023
+ const tagMap = new Map((est?.tagEstimates ?? []).map((t) => [t.tag, t.lines]));
1024
+ const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, edits: editsOf } = mergeAuthoring(loaded);
1025
+ const recordingOf = recordingOverride ?? manualRecordingOf;
1026
+ const byLocale = stringsByLocale(loaded);
1027
+ const sourceStrings2 = byLocale.get(project.locales.default) ?? {};
1028
+ const wordsOf = (id) => {
1029
+ const text = sourceStrings2[id];
1030
+ return text ? text.split(/\s+/).filter(Boolean).length : 0;
1031
+ };
1032
+ const cut = { scenes: 0, voicedLines: 0, writtenLines: 0 };
1033
+ const characters = /* @__PURE__ */ new Map();
1034
+ const getChar = (name) => {
1035
+ let c = characters.get(name);
1036
+ if (!c) {
1037
+ c = { character: name, lines: 0, words: 0, estimatedLines: 0, recording: Object.fromEntries(recordingLadder.map((s) => [s, 0])) };
1038
+ characters.set(name, c);
1039
+ }
1040
+ return c;
1041
+ };
1042
+ const reports = [];
1043
+ const localeIds = [];
1044
+ const estimatedScenes = [];
1045
+ let actualWrittenLines = 0, actualWords = 0;
1046
+ const rungIdxOf = (id) => writingIndex.get(writingOf.get(id) ?? stub) ?? 0;
1047
+ for (const scene of loaded.scenes) {
1048
+ const sceneCut = cutSet.has(scene.id);
1049
+ const units = [];
1050
+ let choices = 0;
1051
+ for (const block of scene.blocks) {
1052
+ walkNodes2(block.children, (node) => {
1053
+ if (node.type === "group") {
1054
+ if (node.selector === "choice") choices++;
1055
+ if (node.prompt) units.push({
1056
+ id: node.prompt.id,
1057
+ voiced: node.prompt.kind === "line",
1058
+ words: wordsOf(node.prompt.id),
1059
+ character: node.prompt.kind === "line" ? node.prompt.character : void 0
1060
+ });
1061
+ return;
1062
+ }
1063
+ for (const beat of node.beats ?? []) {
1064
+ if (beat.kind === "gameEvent") continue;
1065
+ units.push({
1066
+ id: beat.id,
1067
+ voiced: beat.kind === "line",
1068
+ words: wordsOf(beat.id),
1069
+ character: beat.kind === "line" ? beat.character : void 0
1070
+ });
1071
+ }
1072
+ });
1073
+ }
1074
+ for (const u of units) if (sceneCut || cutSet.has(u.id)) {
1075
+ cut.writtenLines++;
1076
+ if (u.voiced) cut.voicedLines++;
1077
+ }
1078
+ if (sceneCut) {
1079
+ cut.scenes++;
1080
+ continue;
1081
+ }
1082
+ const live = units.filter((u) => !cutSet.has(u.id));
1083
+ for (const u of live) if (sourceStrings2[u.id]) localeIds.push(u.id);
1084
+ const statusIdx = live.length ? Math.min(...live.map((u) => rungIdxOf(u.id))) : void 0;
1085
+ const status = statusIdx === void 0 ? void 0 : writingLadder[statusIdx];
1086
+ const highestIdx = live.length ? Math.max(...live.map((u) => rungIdxOf(u.id))) : void 0;
1087
+ const estimated = estimatingOn && live.length > 0 && highestIdx <= thresholdIdx;
1088
+ if (estimated) {
1089
+ const tagLines = (scene.tags ?? []).map((t) => tagMap.get(t)).filter((n) => n != null);
1090
+ const estimateLines = tagLines.length ? Math.max(...tagLines) : defaultLines;
1091
+ const weights = /* @__PURE__ */ new Map();
1092
+ for (const u of live) {
1093
+ const key = u.voiced && u.character ? u.character : UNATTRIBUTED;
1094
+ weights.set(key, (weights.get(key) ?? 0) + 1);
1095
+ }
1096
+ const shares = allocateLargestRemainder(estimateLines, weights);
1097
+ let voicedShare = 0;
1098
+ for (const [key, n] of shares) {
1099
+ if (key === UNATTRIBUTED) continue;
1100
+ getChar(key).estimatedLines += n;
1101
+ voicedShare += n;
1102
+ }
1103
+ const report = {
1104
+ sceneId: scene.id,
1105
+ name: scene.name,
1106
+ file: loaded.sceneFiles[scene.id],
1107
+ status,
1108
+ estimated: true,
1109
+ choices,
1110
+ voiced: emptyVoiced(writingLadder, recordingLadder),
1111
+ written: emptyWritten(writingLadder),
1112
+ estimate: estimateLines,
1113
+ writtenDone: 0,
1114
+ writtenRemaining: estimateLines,
1115
+ voicedDone: 0,
1116
+ voicedRemaining: voicedShare
1117
+ };
1118
+ reports.push(report);
1119
+ estimatedScenes.push({ report, voicedShare });
1120
+ continue;
1121
+ }
1122
+ const voiced = emptyVoiced(writingLadder, recordingLadder);
1123
+ const written = emptyWritten(writingLadder);
1124
+ for (const u of live) {
1125
+ const ws = writingOf.get(u.id) ?? stub;
1126
+ written.count++;
1127
+ written.words += u.words;
1128
+ written.byWriting[ws] = (written.byWriting[ws] ?? 0) + 1;
1129
+ actualWrittenLines++;
1130
+ actualWords += u.words;
1131
+ if (!u.voiced) continue;
1132
+ voiced.count++;
1133
+ voiced.words += u.words;
1134
+ voiced.byWriting[ws] = (voiced.byWriting[ws] ?? 0) + 1;
1135
+ const wi = writingIndex.get(ws) ?? 0;
1136
+ if (recordThreshold !== -1 && wi >= recordThreshold) voiced.readyToRecord++;
1137
+ if (shipThreshold !== -1 && wi >= shipThreshold) voiced.readyToShip++;
1138
+ const rec = recordingOf.get(u.id) ?? recordingLadder[0];
1139
+ voiced.byRecording[rec] = (voiced.byRecording[rec] ?? 0) + 1;
1140
+ const speaker = u.character ?? "(narrator)";
1141
+ const ch = getChar(speaker);
1142
+ ch.lines++;
1143
+ ch.words += u.words;
1144
+ ch.recording[rec] = (ch.recording[rec] ?? 0) + 1;
1145
+ }
1146
+ const writtenStub = written.byWriting[stub] ?? 0;
1147
+ const voicedStub = voiced.byWriting[stub] ?? 0;
1148
+ reports.push({
1149
+ sceneId: scene.id,
1150
+ name: scene.name,
1151
+ file: loaded.sceneFiles[scene.id],
1152
+ status,
1153
+ estimated: false,
1154
+ choices,
1155
+ voiced,
1156
+ written,
1157
+ writtenDone: written.count - writtenStub,
1158
+ writtenRemaining: writtenStub,
1159
+ voicedDone: voiced.count - voicedStub,
1160
+ voicedRemaining: voicedStub
1161
+ });
1162
+ }
1163
+ const avgWordsPerLine = actualWrittenLines > 0 ? actualWords / actualWrittenLines : DEFAULT_WORDS_PER_LINE;
1164
+ for (const { report, voicedShare } of estimatedScenes) {
1165
+ report.written.words = Math.round((report.estimate ?? 0) * avgWordsPerLine);
1166
+ report.voiced.words = Math.round(voicedShare * avgWordsPerLine);
1167
+ }
1168
+ const locales = computeLocales(loaded, byLocale, editsOf, localeIds, project.locales.default);
1169
+ const totals = computeTotals(reports, writingLadder, recordingLadder);
1170
+ const scenesByStatus = Object.fromEntries(writingLadder.map((s) => [s, 0]));
1171
+ for (const s of reports) if (s.status) scenesByStatus[s.status] = (scenesByStatus[s.status] ?? 0) + 1;
1172
+ const coverage = { totalScenes: reports.length, estimated: reports.filter((s) => s.estimated).length };
1173
+ return {
1174
+ project: { id: project.project.id, name: project.project.name },
1175
+ voiced: project.voiced ?? false,
1176
+ // Recording tracking is opt-in (default off) even for a voiced project (#206).
1177
+ recordingTracked: (project.voiced ?? false) && (project.trackAudioStatus ?? false),
1178
+ writingLadder,
1179
+ recordingLadder,
1180
+ scenes: reports,
1181
+ characters: [...characters.values()].sort((a, b) => b.lines + b.estimatedLines - (a.lines + a.estimatedLines)),
1182
+ locales,
1183
+ cut,
1184
+ estimating: estimatingOn,
1185
+ scenesByStatus,
1186
+ coverage,
1187
+ totals
1188
+ };
1189
+ }
1190
+ function emptyWritten(ladder) {
1191
+ return { count: 0, words: 0, byWriting: Object.fromEntries(ladder.map((s) => [s, 0])) };
1192
+ }
1193
+ function emptyVoiced(writing, recording) {
1194
+ return { ...emptyWritten(writing), byRecording: Object.fromEntries(recording.map((s) => [s, 0])), readyToRecord: 0, readyToShip: 0 };
1195
+ }
1196
+ function computeLocales(loaded, byLocale, editsOf, ids, defaultLocale) {
1197
+ const out = [];
1198
+ for (const locale of loaded.project.locales.all) {
1199
+ if (locale === defaultLocale) continue;
1200
+ const table = byLocale.get(locale) ?? {};
1201
+ let translated = 0, missing = 0, stale = 0, words = 0;
1202
+ for (const id of ids) {
1203
+ const text = table[id];
1204
+ if (text === void 0) {
1205
+ missing++;
1206
+ continue;
1207
+ }
1208
+ translated++;
1209
+ words += text.split(/\s+/).filter(Boolean).length;
1210
+ const edit = editsOf.get(id);
1211
+ const modified = edit?.modifiedAt, localised = edit?.localisedAt?.[locale];
1212
+ if (modified && localised && modified > localised) stale++;
1213
+ }
1214
+ out.push({ locale, translated, missing, stale, words });
1215
+ }
1216
+ return out;
1217
+ }
1218
+ function computeTotals(scenes, writingLadder, recordingLadder) {
1219
+ const t = {
1220
+ voiced: emptyVoiced(writingLadder, recordingLadder),
1221
+ written: emptyWritten(writingLadder),
1222
+ choices: 0,
1223
+ writtenDone: 0,
1224
+ writtenRemaining: 0,
1225
+ voicedDone: 0,
1226
+ voicedRemaining: 0,
1227
+ projectedWritten: 0,
1228
+ projectedVoiced: 0
1229
+ };
1230
+ const add = (into, from) => {
1231
+ for (const [k, v] of Object.entries(from)) into[k] = (into[k] ?? 0) + v;
1232
+ };
1233
+ for (const s of scenes) {
1234
+ t.voiced.count += s.voiced.count;
1235
+ t.voiced.words += s.voiced.words;
1236
+ t.voiced.readyToRecord += s.voiced.readyToRecord;
1237
+ t.voiced.readyToShip += s.voiced.readyToShip;
1238
+ add(t.voiced.byWriting, s.voiced.byWriting);
1239
+ add(t.voiced.byRecording, s.voiced.byRecording);
1240
+ t.written.count += s.written.count;
1241
+ t.written.words += s.written.words;
1242
+ add(t.written.byWriting, s.written.byWriting);
1243
+ t.choices += s.choices;
1244
+ t.writtenDone += s.writtenDone;
1245
+ t.writtenRemaining += s.writtenRemaining;
1246
+ t.voicedDone += s.voicedDone;
1247
+ t.voicedRemaining += s.voicedRemaining;
1248
+ }
1249
+ t.projectedWritten = t.writtenDone + t.writtenRemaining;
1250
+ t.projectedVoiced = t.voicedDone + t.voicedRemaining;
1251
+ return t;
1252
+ }
1253
+ function renderReportText(data) {
1254
+ const out = [];
1255
+ const t = data.totals;
1256
+ out.push(`${data.project.name} - ${data.scenes.length} scene(s)`);
1257
+ out.push(`written lines: ${t.writtenDone} done / ${t.writtenRemaining} to write -> ${t.projectedWritten} projected (${t.written.words} words)`);
1258
+ if (data.voiced) out.push(`voiced lines: ${t.voicedDone} done / ${t.voicedRemaining} to write -> ${t.projectedVoiced} projected (${t.voiced.words} words)`);
1259
+ out.push(`writing status: ${Object.entries(t.written.byWriting).map(([k, v]) => `${k} ${v}`).join(", ")}`);
1260
+ out.push(`scene status: ${Object.entries(data.scenesByStatus).map(([k, v]) => `${k} ${v}`).join(", ")}`);
1261
+ if (data.voiced) out.push(`ready to record: ${t.voiced.readyToRecord} / ready to ship: ${t.voiced.readyToShip} (voiced)`);
1262
+ if (data.recordingTracked) out.push(`recording: ${Object.entries(t.voiced.byRecording).map(([k, v]) => `${k} ${v}`).join(", ")}`);
1263
+ if (data.estimating) {
1264
+ const estLines = data.scenes.reduce((n, s) => n + (s.estimated ? s.estimate ?? 0 : 0), 0);
1265
+ out.push(`estimating: ${data.coverage.estimated}/${data.coverage.totalScenes} scene(s) estimated (${estLines} lines)`);
1266
+ }
1267
+ if (data.cut.writtenLines > 0) out.push(`cut: ${data.cut.scenes} scene(s), ${data.cut.writtenLines} written / ${data.cut.voicedLines} voiced line(s)`);
1268
+ for (const l of data.locales) {
1269
+ out.push(`locale ${l.locale}: ${l.translated} translated, ${l.missing} missing, ${l.stale} stale`);
1270
+ }
1271
+ return out;
1272
+ }
1273
+
1274
+ // src/report-xlsx.ts
1275
+ async function runReportXlsx(data) {
1276
+ const { default: ExcelJS } = await import("exceljs");
1277
+ const wb = new ExcelJS.Workbook();
1278
+ wb.creator = "patter";
1279
+ const v = data.voiced;
1280
+ const rec = data.recordingTracked;
1281
+ const scenes = wb.addWorksheet("Scenes");
1282
+ scenes.columns = [
1283
+ { header: "Scene", key: "name", width: 24 },
1284
+ { header: "Status", key: "status", width: 10 },
1285
+ ...v ? [{ header: "Voiced", key: "vCount", width: 8 }] : [],
1286
+ { header: "Written", key: "wCount", width: 8 },
1287
+ { header: "Words", key: "words", width: 9 },
1288
+ { header: "Choices", key: "choices", width: 8 },
1289
+ ...data.writingLadder.map((s) => ({ header: s, key: `w:${s}`, width: 10 })),
1290
+ ...v ? [{ header: "Ready record", key: "rr", width: 12 }, { header: "Ready ship", key: "rs", width: 11 }] : [],
1291
+ ...rec ? data.recordingLadder.map((s) => ({ header: `rec ${s}`, key: `r:${s}`, width: 10 })) : [],
1292
+ ...data.estimating ? [{ header: "Estimate", key: "estimate", width: 9 }] : [],
1293
+ { header: "Written left", key: "wRem", width: 11 },
1294
+ ...v ? [{ header: "Voiced left", key: "vRem", width: 11 }] : []
1295
+ ];
1296
+ const sceneRow = (name, s) => ({
1297
+ name,
1298
+ status: s.status,
1299
+ vCount: s.voiced.count,
1300
+ wCount: s.written.count,
1301
+ words: s.written.words,
1302
+ choices: s.choices,
1303
+ ...Object.fromEntries(data.writingLadder.map((k) => [`w:${k}`, s.written.byWriting[k] ?? 0])),
1304
+ rr: s.voiced.readyToRecord,
1305
+ rs: s.voiced.readyToShip,
1306
+ ...Object.fromEntries(data.recordingLadder.map((k) => [`r:${k}`, s.voiced.byRecording[k] ?? 0])),
1307
+ estimate: s.estimate,
1308
+ wRem: s.writtenRemaining,
1309
+ vRem: s.voicedRemaining
1310
+ });
1311
+ for (const s of data.scenes) scenes.addRow(sceneRow(s.name, s));
1312
+ const totals = data.totals;
1313
+ const totalRow = scenes.addRow(sceneRow("TOTAL", totals));
1314
+ totalRow.font = { bold: true };
1315
+ scenes.getRow(1).font = { bold: true };
1316
+ const chars = wb.addWorksheet("Characters");
1317
+ chars.columns = [
1318
+ { header: "Character", key: "character", width: 20 },
1319
+ { header: "Lines", key: "lines", width: 8 },
1320
+ ...data.estimating ? [{ header: "Est. lines", key: "estLines", width: 9 }] : [],
1321
+ { header: "Words", key: "words", width: 9 },
1322
+ ...rec ? data.recordingLadder.map((s) => ({ header: `rec ${s}`, key: `r:${s}`, width: 10 })) : []
1323
+ ];
1324
+ for (const c of data.characters) {
1325
+ chars.addRow({
1326
+ character: c.character,
1327
+ lines: c.lines,
1328
+ estLines: c.estimatedLines,
1329
+ words: c.words,
1330
+ ...Object.fromEntries(data.recordingLadder.map((k) => [`r:${k}`, c.recording[k] ?? 0]))
1331
+ });
1332
+ }
1333
+ chars.getRow(1).font = { bold: true };
1334
+ if (data.locales.length > 0) {
1335
+ const loc = wb.addWorksheet("Localisation");
1336
+ loc.columns = [
1337
+ { header: "Locale", key: "locale", width: 10 },
1338
+ { header: "Translated", key: "translated", width: 11 },
1339
+ { header: "Missing", key: "missing", width: 9 },
1340
+ { header: "Stale", key: "stale", width: 8 },
1341
+ { header: "Words", key: "words", width: 9 }
1342
+ ];
1343
+ for (const l of data.locales) loc.addRow(l);
1344
+ loc.getRow(1).font = { bold: true };
1345
+ }
1346
+ if (data.estimating && data.coverage.estimated > 0) {
1347
+ const plan = wb.addWorksheet("Estimates");
1348
+ plan.columns = [
1349
+ { header: "Scene", key: "name", width: 24 },
1350
+ { header: "Est. written", key: "written", width: 12 },
1351
+ ...v ? [{ header: "Est. voiced", key: "voiced", width: 11 }] : []
1352
+ ];
1353
+ for (const s of data.scenes) if (s.estimated) plan.addRow({ name: s.name, written: s.estimate, voiced: s.voicedRemaining });
1354
+ const coverageRow = plan.addRow({ name: `coverage: ${data.coverage.estimated}/${data.coverage.totalScenes} scenes estimated` });
1355
+ coverageRow.font = { italic: true };
1356
+ plan.getRow(1).font = { bold: true };
1357
+ }
1358
+ return Buffer.from(await wb.xlsx.writeBuffer());
1359
+ }
1360
+
1361
+ // src/localisation.ts
1362
+ import { basename as basename4, join as join3, sep } from "path";
1363
+ import { canonicalStringify as canonicalStringify3 } from "@patterkit/core";
1364
+ import { walkNodes as walkNodes3, castStringKey, PROJECT_LOCALE_SCENE } from "@patterkit/model";
1365
+
1366
+ // src/documentation.ts
1367
+ import { DEFAULT_DOCUMENTATION_CLASSES } from "@patterkit/model";
1368
+ function classesForChannel(classes, channel) {
1369
+ const out = /* @__PURE__ */ new Set();
1370
+ for (const c of classes) {
1371
+ if (c.deliver === "*" || Array.isArray(c.deliver) && c.deliver.includes(channel)) out.add(c.name);
1372
+ }
1373
+ return out;
1374
+ }
1375
+ function resolveDocumentation(loaded, channel) {
1376
+ const classes = loaded.project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES;
1377
+ const allowed = classesForChannel(classes, channel);
1378
+ const docsOf = mergeAuthoring(loaded).documentation;
1379
+ const own = (id) => (docsOf.get(id) ?? []).filter((l) => l.type !== void 0 && allowed.has(l.type));
1380
+ const out = /* @__PURE__ */ new Map();
1381
+ const visit = (id, inherited) => {
1382
+ const mine = own(id);
1383
+ if (inherited.length === 0 && mine.length === 0) return inherited;
1384
+ const resolved = [...inherited, ...mine];
1385
+ out.set(id, resolved);
1386
+ return resolved;
1387
+ };
1388
+ const walkNode = (node, inherited) => {
1389
+ const passed = visit(node.id, inherited);
1390
+ if (node.type === "group") {
1391
+ if (node.prompt) visit(node.prompt.id, passed);
1392
+ for (const child of node.children ?? []) walkNode(child, passed);
1393
+ } else {
1394
+ for (const beat of node.beats ?? []) visit(beat.id, passed);
1395
+ }
1396
+ };
1397
+ for (const scene of loaded.scenes) {
1398
+ const sceneInh = visit(scene.id, []);
1399
+ for (const block of scene.blocks) {
1400
+ const blockInh = visit(block.id, sceneInh);
1401
+ for (const child of block.children ?? []) walkNode(child, blockInh);
1402
+ }
1403
+ }
1404
+ return out;
1405
+ }
1406
+
1407
+ // src/localisation.ts
1408
+ function extractLoc(loaded, opts = {}) {
1409
+ const defaultLocale = loaded.project.locales.default;
1410
+ const isTemplate = opts.locale === void 0 || opts.locale === defaultLocale;
1411
+ const targetLocale = isTemplate ? void 0 : opts.locale;
1412
+ const source = tableFor(loaded, defaultLocale);
1413
+ const target = targetLocale ? tableFor(loaded, targetLocale) : {};
1414
+ const edits = mergeAuthoring(loaded).edits;
1415
+ const docs = resolveDocumentation(loaded, "loc");
1416
+ const commentsOf = (id) => (docs.get(id) ?? []).map((d) => d.text);
1417
+ const staleFor = (id, translation) => {
1418
+ if (isTemplate || !translation) return false;
1419
+ const e = edits.get(id);
1420
+ const localised = e?.localisedAt?.[targetLocale];
1421
+ return !!(e?.modifiedAt && localised && e.modifiedAt > localised);
1422
+ };
1423
+ const entries = [];
1424
+ const push = (id, scene, context) => {
1425
+ const src = source[id];
1426
+ if (src === void 0) return;
1427
+ const translation = isTemplate ? "" : target[id] ?? "";
1428
+ entries.push({ id, scene, source: src, translation, comments: commentsOf(id), context, stale: staleFor(id, translation) });
1429
+ };
1430
+ for (const scene of loaded.scenes) {
1431
+ for (const block of scene.blocks) {
1432
+ walkNodes3(block.children, (node) => {
1433
+ if (node.type === "group") {
1434
+ const p = node.prompt;
1435
+ if (p) push(p.id, scene.id, { character: p.kind === "line" ? p.character : void 0, kind: p.kind });
1436
+ return;
1437
+ }
1438
+ for (const beat of node.beats ?? []) {
1439
+ if (beat.kind !== "gameEvent") push(beat.id, scene.id, { character: beat.kind === "line" ? beat.character : void 0, kind: beat.kind });
1440
+ }
1441
+ });
1442
+ }
1443
+ }
1444
+ for (const c of loaded.project.cast ?? []) {
1445
+ if (!c.displayName) continue;
1446
+ const id = castStringKey(c.name);
1447
+ const src = source[id] ?? c.displayName;
1448
+ const translation = isTemplate ? "" : target[id] ?? "";
1449
+ entries.push({ id, scene: PROJECT_LOCALE_SCENE, source: src, translation, comments: commentsOf(id), context: { character: c.name }, stale: staleFor(id, translation) });
1450
+ }
1451
+ return { project: loaded.project.project.id, defaultLocale, locale: targetLocale, entries };
1452
+ }
1453
+ var layoutOf = (loaded) => ({
1454
+ strings: loaded.project.layout?.strings ?? "loc/",
1455
+ authoring: loaded.project.layout?.authoring ?? "authoring/"
1456
+ });
1457
+ function existingLocPath(loaded, scene, locale) {
1458
+ for (let i = 0; i < loaded.locales.length; i++) {
1459
+ const l = loaded.locales[i];
1460
+ if (l.scene === scene && l.locale === locale) return loaded.localeFiles[i] ?? null;
1461
+ }
1462
+ return null;
1463
+ }
1464
+ function targetLocPath(loaded, scene, locale, defaultLocale) {
1465
+ const existing = existingLocPath(loaded, scene, locale);
1466
+ if (existing) return existing;
1467
+ const fromDefault = existingLocPath(loaded, scene, defaultLocale);
1468
+ if (fromDefault) {
1469
+ const parts = fromDefault.split(sep);
1470
+ for (let i = parts.length - 2; i >= 0; i--) {
1471
+ if (parts[i] === defaultLocale) {
1472
+ parts[i] = locale;
1473
+ return parts.join(sep);
1474
+ }
1475
+ }
1476
+ }
1477
+ const stem = scene === PROJECT_LOCALE_SCENE ? "_project" : scene;
1478
+ return join3(loaded.root, layoutOf(loaded).strings, locale, `${stem}.patterloc`);
1479
+ }
1480
+ function authoringPath(loaded, scene) {
1481
+ const dir = join3(loaded.root, layoutOf(loaded).authoring);
1482
+ if (scene === PROJECT_LOCALE_SCENE) return join3(dir, "_project.patterx");
1483
+ const flow = loaded.sceneFiles[scene];
1484
+ const stem = flow ? basename4(flow).replace(/\.patterflow$/, "") : scene;
1485
+ return join3(dir, `${stem}.patterx`);
1486
+ }
1487
+ var findFile = (files, items, path) => {
1488
+ const i = files.indexOf(path);
1489
+ return i >= 0 ? items[i] : void 0;
1490
+ };
1491
+ function applyLoc(loaded, catalog, opts = {}) {
1492
+ const locale = catalog.locale;
1493
+ if (!locale || locale === loaded.project.locales.default) {
1494
+ return { writes: [], stats: { updated: 0, files: 0 } };
1495
+ }
1496
+ const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
1497
+ const bySceneText = /* @__PURE__ */ new Map();
1498
+ const bySceneFresh = /* @__PURE__ */ new Map();
1499
+ for (const e of catalog.entries) {
1500
+ if (!e.translation.trim()) continue;
1501
+ (bySceneText.get(e.scene) ?? bySceneText.set(e.scene, /* @__PURE__ */ new Map()).get(e.scene)).set(e.id, e.translation);
1502
+ if (!e.stale) (bySceneFresh.get(e.scene) ?? bySceneFresh.set(e.scene, /* @__PURE__ */ new Set()).get(e.scene)).add(e.id);
1503
+ }
1504
+ const writes = [];
1505
+ let updated = 0;
1506
+ for (const [scene, strings] of bySceneText) {
1507
+ const path = targetLocPath(loaded, scene, locale, loaded.project.locales.default);
1508
+ const existing = findFile(loaded.localeFiles, loaded.locales, path);
1509
+ const merged = { ...existing?.strings ?? {} };
1510
+ for (const [id, text] of strings) {
1511
+ merged[id] = text;
1512
+ updated++;
1513
+ }
1514
+ const file = { schema: existing?.schema ?? "patter/strings@0", scene, locale, strings: merged };
1515
+ writes.push({ path, content: canonicalStringify3(file) });
1516
+ const fresh = bySceneFresh.get(scene);
1517
+ if (!fresh?.size) continue;
1518
+ const aPath = authoringPath(loaded, scene);
1519
+ const aExisting = findFile(loaded.authoringFiles, loaded.authoring, aPath);
1520
+ const authoring = aExisting ? { ...aExisting, edits: { ...aExisting.edits } } : { schema: "patter/authoring@0" };
1521
+ const editsMap = { ...authoring.edits ?? {} };
1522
+ for (const id of fresh) editsMap[id] = { ...editsMap[id], localisedAt: { ...editsMap[id]?.localisedAt, [locale]: now } };
1523
+ authoring.edits = editsMap;
1524
+ writes.push({ path: aPath, content: canonicalStringify3(authoring) });
1525
+ }
1526
+ return { writes, stats: { updated, files: bySceneText.size } };
1527
+ }
1528
+
1529
+ // src/loc-format.ts
1530
+ function catalogToJson(catalog) {
1531
+ return JSON.stringify(catalog, null, 2) + "\n";
1532
+ }
1533
+ function jsonToCatalog(text) {
1534
+ const o = JSON.parse(text);
1535
+ if (!o || typeof o !== "object" || !Array.isArray(o.entries)) throw new Error("not a localisation JSON file (no 'entries' array)");
1536
+ if (typeof o.defaultLocale !== "string") throw new Error("localisation JSON: missing 'defaultLocale'");
1537
+ return {
1538
+ project: typeof o.project === "string" ? o.project : "",
1539
+ defaultLocale: o.defaultLocale,
1540
+ locale: typeof o.locale === "string" ? o.locale : void 0,
1541
+ entries: o.entries.map((e) => ({
1542
+ id: String(e.id),
1543
+ scene: String(e.scene),
1544
+ source: String(e.source ?? ""),
1545
+ translation: String(e.translation ?? ""),
1546
+ comments: Array.isArray(e.comments) ? e.comments.map(String) : [],
1547
+ context: e.context,
1548
+ stale: !!e.stale
1549
+ }))
1550
+ };
1551
+ }
1552
+ var poEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1553
+ var poUnescape = (s) => s.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1554
+ var poField = (key, value) => `${key} "${poEscape(value)}"`;
1555
+ function catalogToPo(catalog) {
1556
+ const out = [];
1557
+ out.push(
1558
+ 'msgid ""',
1559
+ 'msgstr ""',
1560
+ `"Project-Id-Version: ${catalog.project}\\n"`,
1561
+ `"Language: ${catalog.locale ?? ""}\\n"`,
1562
+ '"MIME-Version: 1.0\\n"',
1563
+ '"Content-Type: text/plain; charset=UTF-8\\n"',
1564
+ '"Content-Transfer-Encoding: 8bit\\n"',
1565
+ ""
1566
+ );
1567
+ for (const e of catalog.entries) {
1568
+ for (const c of e.comments) out.push(`#. ${c}`);
1569
+ if (e.context?.character || e.context?.kind) {
1570
+ out.push(`#. [${[e.context.kind, e.context.character].filter(Boolean).join(" ")}]`);
1571
+ }
1572
+ out.push(`#: ${e.scene}`);
1573
+ if (e.stale) out.push("#, fuzzy");
1574
+ out.push(poField("msgctxt", e.id), poField("msgid", e.source), poField("msgstr", e.translation), "");
1575
+ }
1576
+ return out.join("\n");
1577
+ }
1578
+ function readQuoted(lines, start) {
1579
+ const first = lines[start];
1580
+ let value = poUnescape(first.slice(first.indexOf('"') + 1, first.lastIndexOf('"')));
1581
+ let i = start + 1;
1582
+ while (i < lines.length && lines[i].trimStart().startsWith('"')) {
1583
+ const l = lines[i].trim();
1584
+ value += poUnescape(l.slice(1, l.lastIndexOf('"')));
1585
+ i++;
1586
+ }
1587
+ return { value, next: i };
1588
+ }
1589
+ function poToCatalog(text) {
1590
+ const lines = text.split(/\r?\n/);
1591
+ const entries = [];
1592
+ let locale;
1593
+ let project = "";
1594
+ let comments = [];
1595
+ let scene = "";
1596
+ let stale = false;
1597
+ let ctxt;
1598
+ let msgid = "";
1599
+ let i = 0;
1600
+ const flush = (msgstr) => {
1601
+ if (ctxt !== void 0) {
1602
+ entries.push({ id: ctxt, scene, source: msgid, translation: msgstr, comments, stale });
1603
+ } else if (msgid === "") {
1604
+ const lang = /Language:\s*([^\\\n]*)/.exec(msgstr);
1605
+ if (lang?.[1]?.trim()) locale = lang[1].trim();
1606
+ const pid = /Project-Id-Version:\s*([^\\\n]*)/.exec(msgstr);
1607
+ if (pid?.[1]?.trim()) project = pid[1].trim();
1608
+ }
1609
+ comments = [];
1610
+ scene = "";
1611
+ stale = false;
1612
+ ctxt = void 0;
1613
+ msgid = "";
1614
+ };
1615
+ while (i < lines.length) {
1616
+ const line = lines[i];
1617
+ const t = line.trim();
1618
+ if (t === "") {
1619
+ i++;
1620
+ continue;
1621
+ }
1622
+ if (t.startsWith("#.")) {
1623
+ comments.push(t.slice(2).trim());
1624
+ i++;
1625
+ continue;
1626
+ }
1627
+ if (t.startsWith("#:")) {
1628
+ scene = t.slice(2).trim().split(/\s+/)[0] ?? "";
1629
+ i++;
1630
+ continue;
1631
+ }
1632
+ if (t.startsWith("#,")) {
1633
+ if (t.includes("fuzzy")) stale = true;
1634
+ i++;
1635
+ continue;
1636
+ }
1637
+ if (t.startsWith("#")) {
1638
+ i++;
1639
+ continue;
1640
+ }
1641
+ if (t.startsWith("msgctxt")) {
1642
+ const r = readQuoted(lines, i);
1643
+ ctxt = r.value;
1644
+ i = r.next;
1645
+ continue;
1646
+ }
1647
+ if (t.startsWith("msgid")) {
1648
+ const r = readQuoted(lines, i);
1649
+ msgid = r.value;
1650
+ i = r.next;
1651
+ continue;
1652
+ }
1653
+ if (t.startsWith("msgstr")) {
1654
+ const r = readQuoted(lines, i);
1655
+ flush(r.value);
1656
+ i = r.next;
1657
+ continue;
1658
+ }
1659
+ i++;
1660
+ }
1661
+ return { project, defaultLocale: locale ?? "", locale, entries };
1662
+ }
1663
+
1664
+ // src/loc-xlsx.ts
1665
+ var HEADERS = ["ID", "Source", "Translation", "Comments", "Status"];
1666
+ var sheetName = (scene) => scene.replace(/[:\\/?*[\]]/g, "-").slice(0, 31);
1667
+ async function catalogToXlsx(catalog) {
1668
+ const { default: ExcelJS } = await import("exceljs");
1669
+ const wb = new ExcelJS.Workbook();
1670
+ wb.creator = "patter";
1671
+ const byScene = /* @__PURE__ */ new Map();
1672
+ for (const e of catalog.entries) (byScene.get(e.scene) ?? byScene.set(e.scene, []).get(e.scene)).push(e);
1673
+ for (const [scene, entries] of byScene) {
1674
+ const ws = wb.addWorksheet(sheetName(scene));
1675
+ ws.columns = [
1676
+ { header: "ID", key: "id", width: 22 },
1677
+ { header: "Source", key: "source", width: 40 },
1678
+ { header: "Translation", key: "translation", width: 40 },
1679
+ { header: "Comments", key: "comments", width: 30 },
1680
+ { header: "Status", key: "status", width: 10 }
1681
+ ];
1682
+ ws.getRow(1).font = { bold: true };
1683
+ for (const e of entries) {
1684
+ ws.addRow({
1685
+ id: e.id,
1686
+ source: e.source,
1687
+ translation: e.translation,
1688
+ comments: e.comments.join("\n"),
1689
+ status: e.stale ? "stale" : e.translation ? "translated" : ""
1690
+ });
1691
+ }
1692
+ }
1693
+ return Buffer.from(await wb.xlsx.writeBuffer());
1694
+ }
1695
+ async function xlsxToCatalog(buffer) {
1696
+ const { default: ExcelJS } = await import("exceljs");
1697
+ const wb = new ExcelJS.Workbook();
1698
+ await wb.xlsx.load(buffer);
1699
+ const entries = [];
1700
+ for (const ws of wb.worksheets) {
1701
+ const scene = ws.name;
1702
+ const header = ws.getRow(1).values.slice(1).map((v) => String(v ?? ""));
1703
+ if (header[0] !== HEADERS[0]) continue;
1704
+ ws.eachRow((row, n) => {
1705
+ if (n === 1) return;
1706
+ const cell = (c) => {
1707
+ const v = row.getCell(c).value;
1708
+ return v == null ? "" : String(typeof v === "object" && "text" in v ? v.text : v);
1709
+ };
1710
+ const id = cell(1).trim();
1711
+ if (!id) return;
1712
+ const status = cell(5).trim().toLowerCase();
1713
+ entries.push({
1714
+ id,
1715
+ scene,
1716
+ source: cell(2),
1717
+ translation: cell(3),
1718
+ comments: cell(4) ? cell(4).split("\n").filter(Boolean) : [],
1719
+ stale: status === "stale"
1720
+ });
1721
+ });
1722
+ }
1723
+ return { project: "", defaultLocale: "", locale: void 0, entries };
1724
+ }
1725
+
1726
+ // src/voice-script.ts
1727
+ import { DEFAULT_WRITING_STATUSES as DEFAULT_WRITING_STATUSES2, DEFAULT_RECORDING_STATUSES as DEFAULT_RECORDING_STATUSES3, DEFAULT_DOCUMENTATION_CLASSES as DEFAULT_DOCUMENTATION_CLASSES2 } from "@patterkit/model";
1728
+ function plainVoice(text) {
1729
+ return text.replace(/<\/?(?:b|i|bi)>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1730
+ }
1731
+ function groupLabel(node, source) {
1732
+ if (node.prompt) return plainVoice(source[node.prompt.id]?.trim() || "option");
1733
+ if (node.selector === "choice") return "";
1734
+ return node.selector ?? "group";
1735
+ }
1736
+ function runVoiceScript(loaded, opts = {}) {
1737
+ const { project } = loaded;
1738
+ const everything = !!opts.everything;
1739
+ const ladderDecls = project.writingStatuses ?? DEFAULT_WRITING_STATUSES2;
1740
+ const writingLadder = ladderDecls.map((s) => s.name);
1741
+ const recordingLadder = (project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES3).map((s) => s.name);
1742
+ const stub = writingLadder[0];
1743
+ const recordThreshold = ladderDecls.findIndex((s) => s.readyToRecord);
1744
+ const writingIndex = new Map(writingLadder.map((name, i) => [name, i]));
1745
+ const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, documentation: docsOf } = mergeAuthoring(loaded);
1746
+ const recordingOf = opts.recordingOverride ?? manualRecordingOf;
1747
+ const source = sourceStrings(loaded);
1748
+ const actorOf = /* @__PURE__ */ new Map();
1749
+ for (const c of project.cast ?? []) if (c.actor) actorOf.set(c.name, c.actor);
1750
+ const voClasses = classesForChannel(project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES2, "vo");
1751
+ const ownVo = (id) => (docsOf.get(id) ?? []).filter((d) => d.type !== void 0 && voClasses.has(d.type)).map((d) => d.text);
1752
+ const lines = [];
1753
+ const walkNode = (node, scope, ancestorVo) => {
1754
+ if (node.type === "group") {
1755
+ if (cutSet.has(node.id)) return;
1756
+ const label = groupLabel(node, source);
1757
+ const childScope = label ? [...scope, label] : scope;
1758
+ const childVo = [...ancestorVo, ...ownVo(node.id)];
1759
+ for (const child of node.children ?? []) walkNode(child, childScope, childVo);
1760
+ return;
1761
+ }
1762
+ if (cutSet.has(node.id)) return;
1763
+ let leading = true;
1764
+ for (const beat of node.beats ?? []) {
1765
+ if (beat.kind !== "line" || cutSet.has(beat.id)) continue;
1766
+ const ws = writingOf.get(beat.id) ?? stub;
1767
+ if (!everything && recordThreshold !== -1 && (writingIndex.get(ws) ?? 0) < recordThreshold) continue;
1768
+ const own = ownVo(beat.id);
1769
+ lines.push({
1770
+ scope: scope.join(" \u203A "),
1771
+ id: beat.id,
1772
+ character: beat.character ?? "",
1773
+ actor: beat.character ? actorOf.get(beat.character) : void 0,
1774
+ text: plainVoice(source[beat.id] ?? ""),
1775
+ comments: leading ? [...ancestorVo, ...own] : own,
1776
+ // first line of the run gets the enclosing context
1777
+ recordingStatus: recordingOf.get(beat.id) ?? recordingLadder[0]
1778
+ });
1779
+ leading = false;
1780
+ }
1781
+ };
1782
+ for (const scene of loaded.scenes) {
1783
+ if (cutSet.has(scene.id)) continue;
1784
+ const sceneVo = ownVo(scene.id);
1785
+ for (const block of scene.blocks) {
1786
+ if (cutSet.has(block.id)) continue;
1787
+ const scope = [scene.name, block.name].filter(Boolean);
1788
+ const blockVo = [...sceneVo, ...ownVo(block.id)];
1789
+ for (const child of block.children ?? []) walkNode(child, scope, blockVo);
1790
+ }
1791
+ }
1792
+ return { project: project.project.id, voiced: project.voiced ?? false, recordingLadder, everything, lines };
1793
+ }
1794
+
1795
+ // src/voice-script-xlsx.ts
1796
+ async function voiceScriptToXlsx(data) {
1797
+ const { default: ExcelJS } = await import("exceljs");
1798
+ const wb = new ExcelJS.Workbook();
1799
+ wb.creator = "patter";
1800
+ const ws = wb.addWorksheet("Voice Script");
1801
+ ws.columns = [
1802
+ { header: "Scope", key: "scope", width: 28 },
1803
+ { header: "Line ID", key: "id", width: 18 },
1804
+ { header: "Character", key: "character", width: 16 },
1805
+ { header: "Actor", key: "actor", width: 16 },
1806
+ { header: "Text", key: "text", width: 60 },
1807
+ { header: "Comments", key: "comments", width: 36 },
1808
+ { header: "Status", key: "status", width: 12 }
1809
+ ];
1810
+ ws.getRow(1).font = { bold: true };
1811
+ for (const l of data.lines) {
1812
+ const row = ws.addRow({
1813
+ scope: l.scope,
1814
+ id: l.id,
1815
+ character: l.character,
1816
+ actor: l.actor ?? "",
1817
+ text: l.text,
1818
+ comments: l.comments.join("\n"),
1819
+ status: l.recordingStatus
1820
+ });
1821
+ row.getCell("text").alignment = { wrapText: true, vertical: "top" };
1822
+ row.getCell("comments").alignment = { wrapText: true, vertical: "top" };
1823
+ }
1824
+ return Buffer.from(await wb.xlsx.writeBuffer());
1825
+ }
1826
+
1827
+ // src/script-doc.ts
1828
+ var TOKENS = {
1829
+ ink: "26221c",
1830
+ // near-black warm ink (headings)
1831
+ inkRead: "3a352d",
1832
+ // dialogue body (ink ~84% + muted)
1833
+ inkSoft: "5c554b",
1834
+ // narration (ink ~68% + muted) - a step softer than dialogue
1835
+ muted: "8c8479",
1836
+ // labels, quiet tags
1837
+ accent: "8a5a34",
1838
+ // structural marks: conditions, options, jumps, game events
1839
+ line: "e4dfd5"
1840
+ // hairlines / rules / the group rail
1841
+ };
1842
+ var CHAR_PALETTE = [
1843
+ "b23b3b",
1844
+ "b5642b",
1845
+ "927524",
1846
+ "6b7a31",
1847
+ "3f7d49",
1848
+ "2c7d6e",
1849
+ "2d7791",
1850
+ "3a6aa8",
1851
+ "5a5ba6",
1852
+ "7e4e9c",
1853
+ "9c4486",
1854
+ "a83e5c"
1855
+ ];
1856
+ function colourIndex(name) {
1857
+ let h = 2166136261;
1858
+ for (let i = 0; i < name.length; i++) {
1859
+ h ^= name.charCodeAt(i);
1860
+ h = Math.imul(h, 16777619);
1861
+ }
1862
+ h ^= h >>> 16;
1863
+ h = Math.imul(h, 2246822507);
1864
+ h ^= h >>> 13;
1865
+ h = Math.imul(h, 3266489909);
1866
+ h ^= h >>> 16;
1867
+ return (h >>> 0) % 12;
1868
+ }
1869
+ function characterColour(name) {
1870
+ return CHAR_PALETTE[colourIndex(name)];
1871
+ }
1872
+ var VISIT_FN_RE = /\b(patter_visits|patter_seen|visits|seen)\s*\(\s*(['"])(.*?)\2\s*\)/g;
1873
+ function humanizeCondition(cond, label) {
1874
+ return cond.replace(VISIT_FN_RE, (_m, fn, _q, id) => `${fn}(${label(id)})`);
1875
+ }
1876
+ var MARKUP_RE = /<(b|i|bi)>([\s\S]*?)<\/\1>/g;
1877
+ var INTERP_RE = /\{@[^}]*\}/g;
1878
+ function textRuns(text) {
1879
+ const marked = [];
1880
+ let last = 0;
1881
+ let m;
1882
+ MARKUP_RE.lastIndex = 0;
1883
+ while ((m = MARKUP_RE.exec(text)) !== null) {
1884
+ if (m.index > last) marked.push({ text: text.slice(last, m.index), bold: false, italic: false });
1885
+ const tag = m[1];
1886
+ marked.push({ text: m[2], bold: tag === "b" || tag === "bi", italic: tag === "i" || tag === "bi" });
1887
+ last = MARKUP_RE.lastIndex;
1888
+ }
1889
+ if (last < text.length) marked.push({ text: text.slice(last), bold: false, italic: false });
1890
+ if (!marked.length) marked.push({ text, bold: false, italic: false });
1891
+ const out = [];
1892
+ for (const r of marked) {
1893
+ let li = 0;
1894
+ let im;
1895
+ INTERP_RE.lastIndex = 0;
1896
+ while ((im = INTERP_RE.exec(r.text)) !== null) {
1897
+ if (im.index > li) out.push({ text: r.text.slice(li, im.index), bold: r.bold, italic: r.italic, code: false });
1898
+ out.push({ text: im[0], bold: r.bold, italic: r.italic, code: true });
1899
+ li = INTERP_RE.lastIndex;
1900
+ }
1901
+ if (li < r.text.length) out.push({ text: r.text.slice(li), bold: r.bold, italic: r.italic, code: false });
1902
+ }
1903
+ return out.length ? out : [{ text, bold: false, italic: false, code: false }];
1904
+ }
1905
+ function targetLabel(to, sceneOf, blockTrail) {
1906
+ if (to === "END") return "END";
1907
+ return blockTrail.get(to) ?? sceneOf.get(to) ?? to;
1908
+ }
1909
+ function gameEventLabel(beat) {
1910
+ const gd = beat.gameData;
1911
+ if (gd && typeof gd === "object") {
1912
+ const parts = Object.entries(gd).slice(0, 3).map(([k, v]) => {
1913
+ const val = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
1914
+ return val === "" ? k : `${k}: ${val}`;
1915
+ });
1916
+ if (parts.length) return `game event \xB7 ${parts.join(", ")}`;
1917
+ }
1918
+ return "game event";
1919
+ }
1920
+ function optionTag(node) {
1921
+ const parts = [];
1922
+ if (node.fallback) parts.push("fallback");
1923
+ parts.push(node.sticky ? "repeatable" : "once only");
1924
+ if (node.secretUntilEligible) parts.push("hidden");
1925
+ return parts.join(" \xB7 ");
1926
+ }
1927
+ function sequenceLabel(node) {
1928
+ const o = node.options ?? {};
1929
+ const bits = [];
1930
+ if ((o.order ?? "sequential") === "shuffle") bits.push("shuffled");
1931
+ const ex = o.exhaust ?? "once";
1932
+ if (ex === "repeat") bits.push("repeating");
1933
+ else if (ex === "stick") bits.push("holds on the last");
1934
+ return bits.length ? `In sequence \xB7 ${bits.join(", ")}` : "In sequence";
1935
+ }
1936
+ function runScriptDoc(loaded) {
1937
+ const { project } = loaded;
1938
+ const source = sourceStrings(loaded);
1939
+ const { cut } = mergeAuthoring(loaded);
1940
+ const sceneOf = /* @__PURE__ */ new Map();
1941
+ const blockTrail = /* @__PURE__ */ new Map();
1942
+ const blockName = /* @__PURE__ */ new Map();
1943
+ for (const scene of loaded.scenes) {
1944
+ sceneOf.set(scene.id, scene.name);
1945
+ for (const block of scene.blocks) {
1946
+ blockTrail.set(block.id, `${scene.name} \u203A ${block.name}`);
1947
+ blockName.set(block.id, block.name);
1948
+ }
1949
+ }
1950
+ const humanize = (cond) => humanizeCondition(cond, (id) => blockName.get(id) ?? sceneOf.get(id) ?? id);
1951
+ const els = [];
1952
+ const textOf = (id) => source[id] ?? "";
1953
+ const jumpText = (j) => {
1954
+ const dest = targetLabel(j.to, sceneOf, blockTrail);
1955
+ return j.mode === "call" ? `${dest} (and return)` : dest;
1956
+ };
1957
+ let snippetSeq = 0;
1958
+ const nextSid = () => ++snippetSeq;
1959
+ const emitBeats = (node, indent, sid) => {
1960
+ for (const beat of node.beats ?? []) {
1961
+ if (cut.has(beat.id)) continue;
1962
+ if (beat.kind === "line") els.push({ kind: "line", indent, snippet: sid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
1963
+ else if (beat.kind === "text") els.push({ kind: "narration", indent, snippet: sid, runs: textRuns(textOf(beat.id)) });
1964
+ else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent, snippet: sid, text: gameEventLabel(beat) });
1965
+ }
1966
+ if (node.jump) els.push({ kind: "jump", indent, snippet: sid, text: jumpText(node.jump) });
1967
+ };
1968
+ const walk = (node, indent, sid) => {
1969
+ if (cut.has(node.id)) return;
1970
+ if (node.type === "snippet") {
1971
+ if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
1972
+ emitBeats(node, indent, sid);
1973
+ return;
1974
+ }
1975
+ if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
1976
+ if (node.selector === "choice") {
1977
+ els.push({ kind: "group", indent, label: "Choose" });
1978
+ for (const child of node.children ?? []) {
1979
+ if (cut.has(child.id)) continue;
1980
+ const cid = nextSid();
1981
+ if (child.type === "group" && child.prompt) {
1982
+ if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
1983
+ els.push({ kind: "option", indent, snippet: cid, runs: textRuns(textOf(child.prompt.id) || "(option)"), tag: optionTag(child) });
1984
+ for (const c of child.children ?? []) walk(c, indent + 1, cid);
1985
+ } else if (child.type === "snippet") {
1986
+ const beats = child.beats ?? [];
1987
+ const first = beats.find((b) => b.kind === "line" || b.kind === "text");
1988
+ if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
1989
+ els.push({ kind: "option", indent, snippet: cid, runs: textRuns(first ? textOf(first.id) || "(option)" : "(option)"), tag: optionTag(child) });
1990
+ let seenPrompt = false;
1991
+ for (const beat of beats) {
1992
+ if (!seenPrompt && beat === first) {
1993
+ seenPrompt = true;
1994
+ continue;
1995
+ }
1996
+ if (cut.has(beat.id)) continue;
1997
+ if (beat.kind === "line") els.push({ kind: "line", indent: indent + 1, snippet: cid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
1998
+ else if (beat.kind === "text") els.push({ kind: "narration", indent: indent + 1, snippet: cid, runs: textRuns(textOf(beat.id)) });
1999
+ else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent: indent + 1, snippet: cid, text: gameEventLabel(beat) });
2000
+ }
2001
+ if (child.jump) els.push({ kind: "jump", indent: indent + 1, snippet: cid, text: jumpText(child.jump) });
2002
+ } else {
2003
+ walk(child, indent + 1, cid);
2004
+ }
2005
+ }
2006
+ return;
2007
+ }
2008
+ if (node.selector === "branch") {
2009
+ els.push({ kind: "group", indent, label: "One of \xB7 first match wins" });
2010
+ const kids = (node.children ?? []).filter((c) => !cut.has(c.id));
2011
+ kids.forEach((child, i) => {
2012
+ const isCatchAll = i === kids.length - 1 && kids.length > 1 && !("condition" in child && child.condition);
2013
+ if (isCatchAll) els.push({ kind: "else", indent });
2014
+ walk(child, indent, nextSid());
2015
+ });
2016
+ return;
2017
+ }
2018
+ if (node.selector === "sequence") {
2019
+ els.push({ kind: "group", indent, label: sequenceLabel(node) });
2020
+ for (const child of node.children ?? []) walk(child, indent, nextSid());
2021
+ return;
2022
+ }
2023
+ for (const child of node.children ?? []) walk(child, indent, sid);
2024
+ };
2025
+ for (const scene of loaded.scenes) {
2026
+ if (cut.has(scene.id)) continue;
2027
+ els.push({ kind: "scene", text: scene.name });
2028
+ for (const block of scene.blocks) {
2029
+ if (cut.has(block.id)) continue;
2030
+ els.push({ kind: "block", text: block.name });
2031
+ for (const child of block.children ?? []) walk(child, 0, void 0);
2032
+ }
2033
+ }
2034
+ return { project: project.project.name, elements: els };
2035
+ }
2036
+
2037
+ // src/script-docx.ts
2038
+ import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, TabStopType, TextRun } from "docx";
2039
+ var SERIF = "Georgia";
2040
+ var SANS = "Calibri";
2041
+ var MONO = "Consolas";
2042
+ var STEP = 360;
2043
+ var SNIPPET_INSET = 220;
2044
+ var DIALOGUE_INDENT = 300;
2045
+ var HANG = 340;
2046
+ var RIGHT_TAB = 9020;
2047
+ var SNIP_GAP = 170;
2048
+ var WITHIN = 30;
2049
+ var S = { h1: 32, h2: 26, body: 22, cue: 18, label: 16, tag: 15, mech: 18 };
2050
+ function bodyRuns(runs, color, base = {}) {
2051
+ return runs.map((r) => r.code ? new TextRun({ text: r.text, font: MONO, color: TOKENS.accent, size: S.body - 2 }) : new TextRun({ text: r.text, font: SERIF, color, size: S.body, bold: base.bold || r.bold || void 0, italics: base.italic || r.italic || void 0 }));
2052
+ }
2053
+ var leftOf = (el) => ("indent" in el ? el.indent * STEP : 0) + ("snippet" in el && el.snippet !== void 0 ? SNIPPET_INSET : 0);
2054
+ function paragraph(el, prev) {
2055
+ const sid = "snippet" in el ? el.snippet : void 0;
2056
+ const startsSnippet = sid !== void 0 && (!prev || !("snippet" in prev) || prev.snippet !== sid);
2057
+ const before = sid !== void 0 ? startsSnippet ? SNIP_GAP : WITHIN : 100;
2058
+ switch (el.kind) {
2059
+ case "scene":
2060
+ return new Paragraph({
2061
+ heading: HeadingLevel.HEADING_1,
2062
+ keepNext: true,
2063
+ spacing: { before: 480, after: 200 },
2064
+ border: { bottom: { style: BorderStyle.SINGLE, size: 14, color: TOKENS.accent, space: 6 } },
2065
+ children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h1 })]
2066
+ });
2067
+ case "block":
2068
+ return new Paragraph({
2069
+ heading: HeadingLevel.HEADING_2,
2070
+ keepNext: true,
2071
+ spacing: { before: 360, after: 160 },
2072
+ border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: TOKENS.line, space: 4 } },
2073
+ children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h2 })]
2074
+ });
2075
+ case "line": {
2076
+ const kids = [];
2077
+ if (el.character) kids.push(new TextRun({ text: el.character.toUpperCase(), font: SANS, bold: true, allCaps: true, color: characterColour(el.character), size: S.cue }), new TextRun({ text: " ", font: SANS, size: S.cue }));
2078
+ if (el.direction) kids.push(new TextRun({ text: `(${el.direction}) `, font: SERIF, italics: true, color: TOKENS.muted, size: S.body }));
2079
+ kids.push(...bodyRuns(el.runs, TOKENS.inkRead));
2080
+ return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) + DIALOGUE_INDENT + HANG, hanging: HANG }, children: kids });
2081
+ }
2082
+ case "narration":
2083
+ return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) }, children: bodyRuns(el.runs, TOKENS.inkSoft) });
2084
+ case "condition":
2085
+ return new Paragraph({ keepNext: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) }, children: [new TextRun({ text: `\u2039 ${el.text} \u203A`, font: MONO, color: TOKENS.accent, size: S.mech - 1 })] });
2086
+ case "group":
2087
+ return new Paragraph({ keepNext: true, spacing: { before: 260, after: 60 }, indent: { left: el.indent * STEP }, children: [new TextRun({ text: el.label.toUpperCase(), font: SANS, bold: true, allCaps: true, color: TOKENS.accent, size: S.label, characterSpacing: 14 })] });
2088
+ case "else":
2089
+ return new Paragraph({ keepNext: true, spacing: { before: 140, after: 20 }, indent: { left: el.indent * STEP }, children: [new TextRun({ text: "else \xB7 catch-all", font: SANS, allCaps: true, color: TOKENS.muted, size: S.label, characterSpacing: 12 })] });
2090
+ case "option": {
2091
+ const kids = [new TextRun({ text: "\u25C7 ", color: TOKENS.accent, size: S.body }), ...bodyRuns(el.runs, TOKENS.ink)];
2092
+ if (el.tag) kids.push(new TextRun({ text: " ", size: S.body }), new TextRun({ text: el.tag, font: SANS, smallCaps: true, color: TOKENS.muted, size: S.tag }));
2093
+ return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) + HANG, hanging: HANG }, tabStops: el.tag ? [{ type: TabStopType.RIGHT, position: RIGHT_TAB }] : void 0, children: kids });
2094
+ }
2095
+ case "jump":
2096
+ return new Paragraph({ keepLines: true, alignment: AlignmentType.RIGHT, spacing: { before: 40, after: 40 }, children: [new TextRun({ text: `\u21AA ${el.text}`, font: SANS, bold: true, color: TOKENS.accent, size: S.mech })] });
2097
+ case "gameEvent":
2098
+ return new Paragraph({ keepLines: true, alignment: AlignmentType.RIGHT, spacing: { before: 40, after: 40 }, children: [new TextRun({ text: `\u2699 ${el.text}`, font: MONO, color: TOKENS.accent, size: S.mech - 2 })] });
2099
+ }
2100
+ }
2101
+ async function scriptToDocx(doc) {
2102
+ const body = [
2103
+ new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: "Readable script", font: SANS, bold: true, allCaps: true, color: TOKENS.accent, size: 16, characterSpacing: 30 })] }),
2104
+ new Paragraph({ spacing: { after: 560 }, children: [new TextRun({ text: doc.project, font: SERIF, bold: true, color: TOKENS.ink, size: 52 })] }),
2105
+ ...doc.elements.map((el, i) => paragraph(el, doc.elements[i - 1]))
2106
+ ];
2107
+ const document = new Document({ creator: "Patter", title: doc.project, sections: [{ children: body }] });
2108
+ return Packer.toBuffer(document);
2109
+ }
2110
+
2111
+ // src/script-pdf.ts
2112
+ import PDFDocument from "pdfkit";
2113
+ var MARGIN = 64;
2114
+ var INDENT_STEP = 20;
2115
+ var EDGE_INSET = 12;
2116
+ var EDGE_OFF = 3;
2117
+ var DIALOGUE_INDENT2 = 14;
2118
+ var HANG2 = 18;
2119
+ var CUE_GAP = 7;
2120
+ var BODY = 11;
2121
+ var CUE = 8.5;
2122
+ var LEAD = 3.5;
2123
+ var WITHIN2 = 4;
2124
+ var BETWEEN = 10;
2125
+ var BEAT = 6;
2126
+ var TIGHT = 3;
2127
+ var hex = (h) => `#${h}`;
2128
+ var INK = hex(TOKENS.ink);
2129
+ var INK_READ = hex(TOKENS.inkRead);
2130
+ var INK_SOFT = hex(TOKENS.inkSoft);
2131
+ var MUTED = hex(TOKENS.muted);
2132
+ var ACCENT = hex(TOKENS.accent);
2133
+ var LINE = hex(TOKENS.line);
2134
+ var EDGE = "#d7d0c3";
2135
+ var SERIF2 = (bold, italic) => bold && italic ? "Serif-BoldItalic" : bold ? "Serif-Bold" : italic ? "Serif-Italic" : "Serif";
2136
+ var isEmoji = (cp) => cp >= 126976 || cp >= 9728 && cp <= 10175;
2137
+ async function scriptToPdf(doc) {
2138
+ const { scriptFont } = await import("./script-fonts-AQNQOADI.js");
2139
+ return new Promise((resolve4, reject) => {
2140
+ const pdf = new PDFDocument({ size: "A4", margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN }, info: { Title: doc.project, Author: "Patter" } });
2141
+ pdf.registerFont("Serif", scriptFont("serif"));
2142
+ pdf.registerFont("Serif-Bold", scriptFont("serifBold"));
2143
+ pdf.registerFont("Serif-Italic", scriptFont("serifItalic"));
2144
+ pdf.registerFont("Serif-BoldItalic", scriptFont("serifBoldItalic"));
2145
+ pdf.registerFont("Sans", scriptFont("sans"));
2146
+ pdf.registerFont("Sans-Bold", scriptFont("sansBold"));
2147
+ pdf.registerFont("Mono", scriptFont("mono"));
2148
+ pdf.registerFont("Symbol", scriptFont("symbol"));
2149
+ pdf.registerFont("Emoji", scriptFont("emoji"));
2150
+ const coverage = {};
2151
+ const ascender = {};
2152
+ for (const name of ["Serif", "Serif-Bold", "Serif-Italic", "Serif-BoldItalic", "Sans", "Sans-Bold", "Mono"]) {
2153
+ pdf.font(name);
2154
+ const f = pdf._font;
2155
+ coverage[name] = (cp) => f.font.hasGlyphForCodePoint(cp);
2156
+ ascender[name] = f.ascender;
2157
+ }
2158
+ const ascentPt = (name, size) => ascender[name] / 1e3 * size;
2159
+ const split = (text, primary, color) => {
2160
+ const cov = coverage[primary];
2161
+ const out = [];
2162
+ let buf = "";
2163
+ let cur = primary;
2164
+ for (const ch of text) {
2165
+ const cp = ch.codePointAt(0);
2166
+ const f = isEmoji(cp) ? "Emoji" : cov(cp) ? primary : "Symbol";
2167
+ if (f !== cur) {
2168
+ if (buf) out.push({ text: buf, font: cur, color });
2169
+ buf = "";
2170
+ cur = f;
2171
+ }
2172
+ buf += ch;
2173
+ }
2174
+ if (buf) out.push({ text: buf, font: cur, color });
2175
+ return out;
2176
+ };
2177
+ const bodyPieces = (runs, color, base = {}) => runs.flatMap((r) => r.code ? split(r.text, "Mono", ACCENT) : split(r.text, SERIF2(base.bold || r.bold, base.italic || r.italic), color));
2178
+ const chunks = [];
2179
+ pdf.on("data", (c) => chunks.push(c));
2180
+ pdf.on("end", () => resolve4(Buffer.concat(chunks)));
2181
+ pdf.on("error", reject);
2182
+ const pageRight = pdf.page.width - MARGIN;
2183
+ const pageBottom = pdf.page.height - MARGIN;
2184
+ const contentW = pdf.page.width - MARGIN * 2;
2185
+ const ensure = (space) => {
2186
+ if (pdf.y > pageBottom - space) pdf.addPage();
2187
+ };
2188
+ const draw = (pieces, x, opts) => {
2189
+ pieces.forEach((p, i) => {
2190
+ pdf.font(p.font).fillColor(p.color);
2191
+ const cont = i < pieces.length - 1;
2192
+ if (i === 0) pdf.text(p.text, x, pdf.y, { ...opts, continued: cont });
2193
+ else pdf.text(p.text, { continued: cont });
2194
+ });
2195
+ };
2196
+ const drawRight = (pieces, size) => {
2197
+ let total = 0;
2198
+ for (const p of pieces) {
2199
+ pdf.font(p.font).fontSize(size);
2200
+ total += pdf.widthOfString(p.text);
2201
+ }
2202
+ const x = Math.max(MARGIN, pageRight - total);
2203
+ pieces.forEach((p, i) => {
2204
+ pdf.font(p.font).fillColor(p.color).fontSize(size);
2205
+ const cont = i < pieces.length - 1;
2206
+ if (i === 0) pdf.text(p.text, x, pdf.y, { continued: cont });
2207
+ else pdf.text(p.text, { continued: cont });
2208
+ });
2209
+ };
2210
+ pdf.fontSize(9);
2211
+ draw(split("READABLE SCRIPT", "Sans-Bold", ACCENT), MARGIN, { characterSpacing: 2 });
2212
+ pdf.moveDown(0.4).fontSize(26);
2213
+ draw(split(doc.project, "Serif-Bold", INK), MARGIN, { width: contentW });
2214
+ pdf.strokeColor(ACCENT).lineWidth(2).moveTo(MARGIN, pdf.y + 10).lineTo(MARGIN + 40, pdf.y + 10).stroke();
2215
+ pdf.y += 28;
2216
+ const drawContent = (el, cx) => {
2217
+ const w = pageRight - cx;
2218
+ switch (el.kind) {
2219
+ case "scene":
2220
+ pdf.fontSize(17);
2221
+ draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2222
+ pdf.strokeColor(ACCENT).lineWidth(1.5).moveTo(MARGIN, pdf.y + 4).lineTo(MARGIN + 34, pdf.y + 4).stroke();
2223
+ break;
2224
+ case "block":
2225
+ pdf.fontSize(13);
2226
+ draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2227
+ pdf.strokeColor(LINE).lineWidth(0.75).moveTo(MARGIN, pdf.y + 3).lineTo(pageRight, pdf.y + 3).stroke();
2228
+ break;
2229
+ case "line": {
2230
+ const cueX = cx + DIALOGUE_INDENT2;
2231
+ let bodyX = cueX;
2232
+ if (el.character) {
2233
+ pdf.font("Sans-Bold").fontSize(CUE);
2234
+ bodyX = cueX + pdf.widthOfString(el.character.toUpperCase()) + CUE_GAP;
2235
+ }
2236
+ const yTop = pdf.y;
2237
+ pdf.fontSize(BODY);
2238
+ draw([...el.direction ? split(`(${el.direction}) `, "Serif-Italic", MUTED) : [], ...bodyPieces(el.runs, INK_READ)], bodyX, { width: Math.max(120, pageRight - bodyX), lineGap: LEAD });
2239
+ const yEnd = pdf.y;
2240
+ if (el.character) {
2241
+ pdf.font("Sans-Bold").fontSize(CUE).fillColor(hex(characterColour(el.character))).text(el.character.toUpperCase(), cueX, yTop + ascentPt("Serif", BODY) - ascentPt("Sans-Bold", CUE), { lineBreak: false });
2242
+ }
2243
+ pdf.y = yEnd;
2244
+ break;
2245
+ }
2246
+ case "narration":
2247
+ pdf.fontSize(BODY);
2248
+ draw(bodyPieces(el.runs, INK_SOFT), cx, { width: w, lineGap: LEAD });
2249
+ break;
2250
+ case "condition":
2251
+ pdf.fontSize(9.5);
2252
+ draw(split(`\u2039 ${el.text} \u203A`, "Mono", ACCENT), cx, { width: w });
2253
+ break;
2254
+ case "group":
2255
+ pdf.fontSize(8);
2256
+ draw(split(el.label.toUpperCase(), "Sans-Bold", ACCENT), cx, { width: w, characterSpacing: 1.2 });
2257
+ break;
2258
+ case "else":
2259
+ pdf.fontSize(8);
2260
+ draw(split("ELSE \xB7 CATCH-ALL", "Sans", MUTED), cx, { width: w, characterSpacing: 1 });
2261
+ break;
2262
+ case "option": {
2263
+ const ox = cx + HANG2;
2264
+ const tag = el.tag ? el.tag.toUpperCase() : "";
2265
+ const tagW = tag ? pdf.font("Sans").fontSize(7.5).widthOfString(tag) + 8 : 0;
2266
+ const yStart = pdf.y;
2267
+ pdf.fontSize(BODY);
2268
+ draw([...split("\u25C7 ", "Serif", ACCENT), ...bodyPieces(el.runs, INK)], ox, { width: Math.max(80, pageRight - ox - tagW), indent: -HANG2, lineGap: LEAD });
2269
+ const yEnd = pdf.y;
2270
+ if (tag && yEnd >= yStart) {
2271
+ pdf.font("Sans").fontSize(7.5).fillColor(MUTED).text(tag, pageRight - tagW, yStart, { width: tagW, align: "right", lineBreak: false });
2272
+ pdf.y = yEnd;
2273
+ }
2274
+ break;
2275
+ }
2276
+ case "jump":
2277
+ drawRight(split(`\u21AA ${el.text}`, "Sans-Bold", ACCENT), 9.5);
2278
+ break;
2279
+ case "gameEvent":
2280
+ drawRight(split(`\u2699 ${el.text}`, "Mono", ACCENT), 9);
2281
+ break;
2282
+ }
2283
+ };
2284
+ const leadOf = (k) => k === "scene" ? 24 : k === "block" ? 16 : k === "group" ? 13 : k === "else" ? 9 : 0;
2285
+ const els = doc.elements;
2286
+ let curSid;
2287
+ let curBase = 0;
2288
+ for (let i = 0; i < els.length; i++) {
2289
+ const el = els[i];
2290
+ const next = els[i + 1];
2291
+ const sid = "snippet" in el ? el.snippet : void 0;
2292
+ const sameNext = sid !== void 0 && next !== void 0 && "snippet" in next && next.snippet === sid;
2293
+ if (el.kind === "scene") ensure(90);
2294
+ else if (el.kind === "block") ensure(70);
2295
+ pdf.y += leadOf(el.kind);
2296
+ const base = "indent" in el ? MARGIN + el.indent * INDENT_STEP : MARGIN;
2297
+ const cx = base + (sid !== void 0 ? EDGE_INSET : 0);
2298
+ const yTop = pdf.y;
2299
+ drawContent(el, cx);
2300
+ const bottom = pdf.y;
2301
+ const trail = el.kind === "condition" ? TIGHT : el.kind === "scene" ? 12 : el.kind === "block" ? 11 : el.kind === "group" ? 7 : el.kind === "else" ? 4 : sid !== void 0 ? sameNext ? WITHIN2 : BETWEEN : BEAT;
2302
+ if (sid !== void 0) {
2303
+ if (sid !== curSid) {
2304
+ curSid = sid;
2305
+ curBase = "indent" in el ? el.indent : 0;
2306
+ }
2307
+ const ex = MARGIN + curBase * INDENT_STEP + EDGE_OFF;
2308
+ pdf.strokeColor(EDGE).lineWidth(0.75).moveTo(ex, yTop).lineTo(ex, bottom + (sameNext ? trail : 0)).stroke();
2309
+ }
2310
+ pdf.y = bottom + trail;
2311
+ }
2312
+ pdf.end();
2313
+ });
2314
+ }
2315
+
2316
+ // src/init.ts
2317
+ import { join as join4, basename as basename5, resolve as resolve3 } from "path";
2318
+ import { readdirSync as readdirSync3, existsSync as existsSync2 } from "fs";
2319
+ import { newId, slug, canonicalStringify as canonicalStringify4 } from "@patterkit/core";
2320
+ function runInit(opts) {
2321
+ const dir = resolve3(opts.dir);
2322
+ const name = opts.name?.trim() || basename5(dir).replace(/\.patter$/, "");
2323
+ const fileSlug = slug(name);
2324
+ let existing = [];
2325
+ try {
2326
+ existing = readdirSync3(dir).filter((f) => f.endsWith(".patterproj"));
2327
+ } catch {
2328
+ }
2329
+ if (existing.length > 0) throw new Error(`a project already exists here: ${existing[0]}`);
2330
+ const sceneId = newId("scn");
2331
+ const beatId = newId("T");
2332
+ const project = {
2333
+ schema: "patter/project@0",
2334
+ project: { id: newId("proj"), name },
2335
+ locales: { default: "en", all: ["en"] },
2336
+ ...opts.vcs ? { vcs: opts.vcs } : {},
2337
+ // record the chosen VCS so it can be read / switched later
2338
+ voiced: true
2339
+ };
2340
+ const flow = {
2341
+ schema: "patter/flow@0",
2342
+ scene: {
2343
+ id: sceneId,
2344
+ type: "scene",
2345
+ name: "Start",
2346
+ blocks: [{
2347
+ id: newId("blk"),
2348
+ type: "block",
2349
+ name: "Main",
2350
+ children: [{
2351
+ id: newId("sn"),
2352
+ type: "snippet",
2353
+ beats: [{ id: beatId, kind: "text" }],
2354
+ jump: { to: "END" }
2355
+ }]
2356
+ }]
2357
+ }
2358
+ };
2359
+ const locale = {
2360
+ schema: "patter/strings@0",
2361
+ scene: sceneId,
2362
+ locale: "en",
2363
+ default: true,
2364
+ strings: { [beatId]: `Welcome to ${name}. Edit scenes/start.patterflow, then run: patter play` }
2365
+ };
2366
+ const bundle = opts.bundle ?? "commit";
2367
+ const projectFile = join4(dir, `${fileSlug}.patterproj`);
2368
+ const writes = [
2369
+ { path: projectFile, content: canonicalStringify4(project) },
2370
+ { path: join4(dir, "scenes", "start.patterflow"), content: canonicalStringify4(flow) },
2371
+ { path: join4(dir, "loc", "en", "start.patterloc"), content: canonicalStringify4(locale) },
2372
+ { path: join4(dir, ".editorconfig"), content: EDITORCONFIG },
2373
+ ...vcsConfigWrites(dir, opts.vcs, bundle)
2374
+ ];
2375
+ const collisions = writes.map((w) => w.path).filter((p) => existsSync2(p));
2376
+ if (collisions.length > 0) {
2377
+ throw new Error(`refusing to overwrite existing file(s): ${collisions.join(", ")}`);
2378
+ }
2379
+ return { writes, projectFile, name };
2380
+ }
2381
+ function vcsConfigWrites(dir, vcs, bundle = "commit") {
2382
+ const writes = [{ path: join4(dir, "vcs-setup.md"), content: vcsSetup(vcs, bundle) }];
2383
+ if (vcs === "git") writes.push({ path: join4(dir, ".gitattributes"), content: GITATTRIBUTES });
2384
+ const ignore = ignoreFileFor(vcs);
2385
+ if (ignore) writes.push({ path: join4(dir, ignore), content: ignoreContent(bundle) });
2386
+ return writes;
2387
+ }
2388
+ var EDITORCONFIG = `# Patter projects are UTF-8 + LF, always (spec - patter format enforces this).
2389
+ root = true
2390
+
2391
+ [*]
2392
+ charset = utf-8
2393
+ end_of_line = lf
2394
+ insert_final_newline = true
2395
+
2396
+ [*.{patterflow,patterloc,patterx,patterproj}]
2397
+ indent_style = space
2398
+ indent_size = 2
2399
+ `;
2400
+ var GITATTRIBUTES = `# Patter source is UTF-8 + LF text (pinned; never let autocrlf touch it).
2401
+ *.patterflow text eol=lf
2402
+ *.patterloc text eol=lf
2403
+ *.patterx text eol=lf
2404
+ *.patterproj text eol=lf
2405
+
2406
+ # Id-keyed structured merge for Patter source (the 'patter merge' driver).
2407
+ # Register it once per clone (see vcs-setup.md); until then git falls back to a
2408
+ # normal text merge for these - no harm, just not structure-aware.
2409
+ *.patterflow merge=patter
2410
+ *.patterloc merge=patter
2411
+ *.patterx merge=patter
2412
+ *.patterproj merge=patter
2413
+
2414
+ # Generated artifacts (spec \xA711). The compiled bundle is committed but
2415
+ # REGENERATED, never hand-merged - keep ours on conflict and rebuild (the
2416
+ # validate staleness gate catches a stale one). Needs a one-time
2417
+ # 'git config merge.ours.driver true' (see vcs-setup.md).
2418
+ *.patterc text eol=lf merge=ours
2419
+ # The packed document (.patterpack) is a binary zip envelope - kept out of VCS (.gitignore).
2420
+ *.patterpack binary
2421
+ `;
2422
+ function ignoreFileFor(vcs) {
2423
+ switch (vcs) {
2424
+ case "git":
2425
+ return ".gitignore";
2426
+ case "perforce":
2427
+ return ".p4ignore";
2428
+ case "plastic":
2429
+ return "ignore.conf";
2430
+ default:
2431
+ return void 0;
2432
+ }
2433
+ }
2434
+ function ignoreContent(bundle) {
2435
+ const lines = [
2436
+ "# Patter generated artifacts - not source (see vcs-setup.md).",
2437
+ "# Packed document (the send-and-return envelope):",
2438
+ "*.patterpack",
2439
+ "# Unresolved merge sidecar:",
2440
+ "*.patterconflict"
2441
+ ];
2442
+ if (bundle === "ignore") {
2443
+ lines.push("# Compiled bundle (built in CI, not committed under this posture):", "*.patterc");
2444
+ }
2445
+ return lines.join("\n") + "\n";
2446
+ }
2447
+ function vcsSetup(vcs, bundle) {
2448
+ const sections = {
2449
+ git: `## git
2450
+
2451
+ \`.gitattributes\` (already emitted) pins Patter source shards to UTF-8 + LF text
2452
+ and marks the generated artifacts (the \`.patterc\` bundle \`merge=ours\`, the
2453
+ \`.patterpack\` document \`binary\`). \`.gitignore\` (already emitted) keeps the document
2454
+ (and the bundle, under the "ignore" posture) out of source control.
2455
+
2456
+ Recommended pre-commit hook (\`.git/hooks/pre-commit\`, executable):
2457
+
2458
+ #!/bin/sh
2459
+ patter validate || exit 1
2460
+
2461
+ The \`merge=patter\` rules in \`.gitattributes\` are already active; register the
2462
+ drivers once per clone (git config is not repo-tracked) - until then git falls
2463
+ back to a normal text merge for those files:
2464
+
2465
+ git config merge.patter.name "Patter structured merge"
2466
+ git config merge.patter.driver "patter merge %O %A %B -o %A"
2467
+ git config merge.ours.driver true
2468
+
2469
+ git invokes the per-path driver directly (no \`mergetool\` wrapper needed). \`%O %A
2470
+ %B\` are base / ours / theirs; the merged result is written back to \`%A\`. On a
2471
+ conflict \`patter merge\` exits non-zero and writes a \`.patterconflict\` sidecar
2472
+ beside the file, so the merge stays unresolved.
2473
+ `,
2474
+ perforce: `## Perforce
2475
+
2476
+ Add Patter extensions to the typemap (\`p4 typemap\`): source shards and the
2477
+ compiled bundle are TEXT with LF; the packed document is BINARY:
2478
+
2479
+ text //....patterflow
2480
+ text //....patterloc
2481
+ text //....patterx
2482
+ text //....patterproj
2483
+ text //....patterc
2484
+ binary //....patterpack
2485
+
2486
+ On a unicode-mode server, set \`P4CHARSET=utf8\`. For the lock-based workflow
2487
+ (spec: one scene per file = one lock per scene), add \`+l\` to make checkouts
2488
+ exclusive. A \`.p4ignore\` (already emitted) keeps the packed document out of the
2489
+ depot.
2490
+
2491
+ Perforce allows ONE global merge tool, so set \`patter mergetool\` as it - it
2492
+ runs the structured merge for Patter source and hands everything else to your
2493
+ normal tool. Map Perforce's variables into the order BASE THEIRS OURS OUT:
2494
+
2495
+ patter mergetool --fallback "<your merge tool>" %b %t %y %r
2496
+
2497
+ (\`%b\` base, \`%t\` theirs, \`%y\` yours/ours, \`%r\` result - adjust to your P4
2498
+ client's variable names; the argument ORDER is what matters.)
2499
+ `,
2500
+ plastic: `## Plastic SCM (Unity VC)
2501
+
2502
+ Patter source shards are plain UTF-8 text; Plastic handles them as-is. The
2503
+ \`ignore.conf\` (already emitted) keeps the packed document out of the repo. For
2504
+ the lock-based workflow, configure exclusive checkout (lock.conf) for the shard
2505
+ extensions.
2506
+
2507
+ In Preferences > Merge tools, add an external tool (a global entry is fine -
2508
+ the wrapper sniffs the path) with the arguments in BASE THEIRS OURS OUT order:
2509
+
2510
+ patter mergetool --fallback "<your merge tool>" @basefile @sourcefile @destinationfile @output
2511
+ `,
2512
+ svn: `## SVN
2513
+
2514
+ Set auto-props so Patter source keeps LF (\`~/.subversion/config\` or repo config):
2515
+
2516
+ [auto-props]
2517
+ *.patterflow = svn:eol-style=LF
2518
+ *.patterloc = svn:eol-style=LF
2519
+ *.patterx = svn:eol-style=LF
2520
+ *.patterproj = svn:eol-style=LF
2521
+ *.patterc = svn:eol-style=LF
2522
+ *.patterpack = svn:mime-type=application/octet-stream
2523
+
2524
+ SVN's ignore is a directory PROPERTY, not a file - set it from the project root:
2525
+
2526
+ svn propset svn:ignore "*.patterpack${bundle === "ignore" ? "\\n*.patterc" : ""}\\n*.patterconflict" .
2527
+
2528
+ For the lock-based workflow add \`svn:needs-lock\` to the shard patterns.
2529
+
2530
+ SVN allows one global merge tool. Point \`[helpers] merge-tool-cmd\` at a small
2531
+ wrapper script that forwards SVN's four arguments (base theirs mine merged =
2532
+ BASE THEIRS OURS OUT) to \`patter mergetool\`:
2533
+
2534
+ #!/bin/sh
2535
+ exec patter mergetool --fallback "<your merge tool>" "$1" "$2" "$3" "$4"
2536
+ `
2537
+ };
2538
+ const head = vcs ? sections[vcs] : `No VCS selected (\`patter init --vcs git|perforce|plastic|svn\` emits tailored
2539
+ config). Whichever you adopt, the rules are the same: Patter source is UTF-8 +
2540
+ LF text, one scene per file, and \`patter validate\` belongs in your pre-commit /
2541
+ CI. Sections for every supported VCS:
2542
+
2543
+ ${Object.keys(sections).map((k) => sections[k]).join("\n")}`;
2544
+ return `# Version-control setup for this Patter project
2545
+
2546
+ ${head}
2547
+ ${bundleSection(bundle)}`;
2548
+ }
2549
+ function bundleSection(bundle) {
2550
+ const posture = bundle === "commit" ? `This project COMMITS the compiled \`.patterc\` bundle (\`patter init --bundle ignore\`
2551
+ to build it in CI instead). The bundle is regenerated, never hand-merged: on a
2552
+ conflict, keep ours and re-run \`patter export\`. \`patter validate\` recomputes the
2553
+ bundle's embedded hash from source and FAILS if it is stale, so a forgotten
2554
+ regenerate cannot ship silently.` : `This project IGNORES the compiled \`.patterc\` bundle (built in CI; \`patter init
2555
+ --bundle commit\` to commit it instead). Run \`patter export\` as a build step.`;
2556
+ return `## Compiled bundle & packed document
2557
+
2558
+ ${posture}
2559
+
2560
+ The packed \`.patterpack\` document (\`patter pack\`) is the send-and-return envelope
2561
+ for collaborators without VCS. It is a binary zip and a projection of the
2562
+ shards - NOT source - so it stays out of version control (ignored above); edits
2563
+ return via \`patter unpack\`.
2564
+ `;
2565
+ }
2566
+
2567
+ // src/resolve.ts
2568
+ import { walkNodes as walkNodes4, effectiveGameId, DEFAULT_WRITING_STATUSES as DEFAULT_WRITING_STATUSES3, DEFAULT_RECORDING_STATUSES as DEFAULT_RECORDING_STATUSES4 } from "@patterkit/model";
2569
+ function runResolve(loaded, query) {
2570
+ const entries = indexProject(loaded);
2571
+ const q = query.trim();
2572
+ const ql = q.toLowerCase();
2573
+ const tiers = [
2574
+ (e) => e.id === q,
2575
+ (e) => e.gameId?.toLowerCase() === ql,
2576
+ (e) => e.name?.toLowerCase() === ql,
2577
+ (e) => e.id.toLowerCase().includes(ql) || (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false)
2578
+ ];
2579
+ for (const match of tiers) {
2580
+ const hits = entries.filter(match);
2581
+ if (hits.length > 0) return hits;
2582
+ }
2583
+ return [];
2584
+ }
2585
+ function runSearch(loaded, query, focus) {
2586
+ const ql = query.trim().toLowerCase();
2587
+ if (!ql) return [];
2588
+ const entries = indexProject(loaded);
2589
+ const inMeta = (e) => (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false);
2590
+ const inText = (e) => e.text?.toLowerCase().includes(ql) ?? false;
2591
+ const inTech = (e) => e.id.toLowerCase().includes(ql);
2592
+ const matched = entries.map((e, i) => ({ e, i })).filter(({ e }) => inMeta(e) || inText(e) || inTech(e));
2593
+ if (!focus) {
2594
+ const meta = matched.filter(({ e }) => inMeta(e)).map((m) => m.e);
2595
+ const text = matched.filter(({ e }) => !inMeta(e) && inText(e)).map((m) => m.e);
2596
+ const tech = matched.filter(({ e }) => !inMeta(e) && !inText(e) && inTech(e)).map((m) => m.e);
2597
+ return [...meta, ...text, ...tech];
2598
+ }
2599
+ const cut = focus.fromBeatId ? entries.findIndex((e) => e.id === focus.fromBeatId) : -1;
2600
+ const group = (e, i) => e.sceneId !== focus.sceneId ? 2 : cut < 0 || i >= cut ? 0 : 1;
2601
+ return matched.sort((a, b) => group(a.e, a.i) - group(b.e, b.i) || a.i - b.i).map((m) => m.e);
2602
+ }
2603
+ function runStatusBrowse(loaded, status, dimension = "writing", focus, recordingOverride) {
2604
+ const strings = sourceStrings(loaded);
2605
+ const merged = mergeAuthoring(loaded);
2606
+ const recording = dimension === "recording";
2607
+ const statusOf = recording ? recordingOverride ?? merged.recording : merged.writing;
2608
+ const ladder = recording ? loaded.project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES4 : loaded.project.writingStatuses ?? DEFAULT_WRITING_STATUSES3;
2609
+ const lowest = ladder[0]?.name;
2610
+ const out = [];
2611
+ for (const scene of loaded.scenes) {
2612
+ const file = loaded.sceneFiles[scene.id];
2613
+ for (const block of scene.blocks) {
2614
+ const segments = [scene.name, block.name];
2615
+ const consider = (beat) => {
2616
+ const tracked = recording ? beat.kind === "line" : beat.kind === "line" || beat.kind === "text";
2617
+ if (!tracked) return;
2618
+ if ((statusOf.get(beat.id) ?? lowest) !== status) return;
2619
+ out.push({ id: beat.id, kind: "beat", text: strings[beat.id], location: segments, sceneId: scene.id, file });
2620
+ };
2621
+ walkNodes4(block.children, (node) => {
2622
+ if (node.type === "group") {
2623
+ if (node.prompt) consider(node.prompt);
2624
+ return;
2625
+ }
2626
+ for (const beat of node.beats ?? []) consider(beat);
2627
+ });
2628
+ }
2629
+ }
2630
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2631
+ return out;
2632
+ }
2633
+ function listProjectTags(loaded) {
2634
+ const counts = /* @__PURE__ */ new Map();
2635
+ const add = (tags) => {
2636
+ for (const t of tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1);
2637
+ };
2638
+ for (const scene of loaded.scenes) {
2639
+ add(scene.tags);
2640
+ for (const block of scene.blocks) {
2641
+ add(block.tags);
2642
+ walkNodes4(block.children, (node) => {
2643
+ add(node.tags);
2644
+ if (node.type === "group") {
2645
+ add(node.prompt?.tags);
2646
+ return;
2647
+ }
2648
+ for (const beat of node.beats ?? []) add(beat.tags);
2649
+ });
2650
+ }
2651
+ }
2652
+ return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
2653
+ }
2654
+ function runTagBrowse(loaded, tag, focus) {
2655
+ if (!tag) return [];
2656
+ const strings = sourceStrings(loaded);
2657
+ const has = (tags) => !!tags && tags.includes(tag);
2658
+ const out = [];
2659
+ for (const scene of loaded.scenes) {
2660
+ const file = loaded.sceneFiles[scene.id];
2661
+ if (has(scene.tags)) out.push({ id: scene.id, kind: "scene", name: scene.name, gameId: effectiveGameId(scene), location: [scene.name], sceneId: scene.id, file });
2662
+ for (const block of scene.blocks) {
2663
+ const segments = [scene.name, block.name];
2664
+ if (has(block.tags)) out.push({ id: block.id, kind: "block", name: block.name, gameId: effectiveGameId(block), location: segments, sceneId: scene.id, file });
2665
+ walkNodes4(block.children, (node) => {
2666
+ if (has(node.tags)) {
2667
+ const preview = node.type === "group" ? node.prompt ? strings[node.prompt.id] : void 0 : strings[node.beats?.[0]?.id ?? ""];
2668
+ out.push({ id: node.id, kind: node.type, text: preview, location: segments, sceneId: scene.id, file });
2669
+ }
2670
+ if (node.type === "group") {
2671
+ if (node.prompt && has(node.prompt.tags)) out.push({ id: node.prompt.id, kind: "beat", text: strings[node.prompt.id], location: segments, sceneId: scene.id, file });
2672
+ return;
2673
+ }
2674
+ for (const beat of node.beats ?? []) if (has(beat.tags)) out.push({ id: beat.id, kind: "beat", text: strings[beat.id], location: segments, sceneId: scene.id, file });
2675
+ });
2676
+ }
2677
+ }
2678
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2679
+ return out;
2680
+ }
2681
+ function runPropertyUsage(loaded, query, focus) {
2682
+ const parsed = parsePropertyQuery(query);
2683
+ if (!parsed) return [];
2684
+ const { regexes, value } = parsed;
2685
+ const strings = sourceStrings(loaded);
2686
+ const refs = (src) => !!src && regexes.some((re) => re.test(src));
2687
+ const valueOk = (src) => !value || src.toLowerCase().includes(value);
2688
+ const out = [];
2689
+ for (const scene of loaded.scenes) {
2690
+ const file = loaded.sceneFiles[scene.id];
2691
+ const push = (id, kind, segments, usage) => {
2692
+ out.push({ id, kind, text: usage, location: segments, sceneId: scene.id, file });
2693
+ };
2694
+ for (const e of scene.onEntry ?? []) {
2695
+ const src = `${e.target} = ${e.value}`;
2696
+ if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(scene.id, "scene", [scene.name], `on entry: set ${src}`);
2697
+ }
2698
+ for (const block of scene.blocks) {
2699
+ const segments = [scene.name, block.name];
2700
+ walkNodes4(block.children, (node) => {
2701
+ if (refs(node.condition) && valueOk(node.condition)) push(node.id, node.type, segments, `if ${node.condition}`);
2702
+ if (node.type === "group") {
2703
+ if (node.prompt && refs(strings[node.prompt.id]) && valueOk(strings[node.prompt.id])) push(node.prompt.id, "beat", segments, strings[node.prompt.id]);
2704
+ return;
2705
+ }
2706
+ for (const phase of ["onEnter", "onExit"]) {
2707
+ for (const e of node[phase] ?? []) {
2708
+ const src = `${e.target} = ${e.value}`;
2709
+ if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(node.id, "snippet", segments, `${phase === "onEnter" ? "on enter" : "on exit"}: set ${src}`);
2710
+ }
2711
+ }
2712
+ for (const beat of node.beats ?? []) {
2713
+ if (refs(strings[beat.id]) && valueOk(strings[beat.id])) push(beat.id, "beat", segments, strings[beat.id]);
2714
+ }
2715
+ });
2716
+ }
2717
+ }
2718
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2719
+ return out;
2720
+ }
2721
+ function parsePropertyQuery(query) {
2722
+ const m = /^@?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)(.*)$/.exec(query.trim());
2723
+ if (!m) return null;
2724
+ const prop = m[1];
2725
+ const value = m[2].replace(/^[\s=<>!]+/, "").replace(/^["']|["']$/g, "").trim().toLowerCase() || void 0;
2726
+ const esc2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2727
+ const regexes = prop.includes(".") ? [new RegExp(`@${esc2(prop)}\\b`, "i")] : [new RegExp(`@${esc2(prop)}\\b`, "i"), new RegExp(`@patter\\.${esc2(prop)}\\b`, "i")];
2728
+ return { regexes, value };
2729
+ }
2730
+ var indexCache = null;
2731
+ function sameRefs(a, b) {
2732
+ if (a.length !== b.length) return false;
2733
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2734
+ return true;
2735
+ }
2736
+ function indexProject(loaded) {
2737
+ if (indexCache && indexCache.loaded === loaded && sameRefs(indexCache.scenes, loaded.scenes) && sameRefs(indexCache.locales, loaded.locales)) {
2738
+ return indexCache.entries;
2739
+ }
2740
+ const entries = buildIndex(loaded);
2741
+ indexCache = { loaded, scenes: [...loaded.scenes], locales: [...loaded.locales], entries };
2742
+ return entries;
2743
+ }
2744
+ function buildIndex(loaded) {
2745
+ const out = [];
2746
+ const strings = sourceStrings(loaded);
2747
+ for (const scene of loaded.scenes) {
2748
+ const file = loaded.sceneFiles[scene.id];
2749
+ out.push({
2750
+ id: scene.id,
2751
+ kind: "scene",
2752
+ name: scene.name,
2753
+ gameId: effectiveGameId(scene),
2754
+ location: [scene.name],
2755
+ sceneId: scene.id,
2756
+ file
2757
+ });
2758
+ for (const block of scene.blocks) {
2759
+ const segments = [scene.name, block.name];
2760
+ out.push({
2761
+ id: block.id,
2762
+ kind: "block",
2763
+ name: block.name,
2764
+ gameId: effectiveGameId(block),
2765
+ location: segments,
2766
+ sceneId: scene.id,
2767
+ file
2768
+ });
2769
+ walkNodes4(block.children, (node) => {
2770
+ out.push({
2771
+ id: node.id,
2772
+ kind: node.type,
2773
+ location: segments,
2774
+ sceneId: scene.id,
2775
+ file
2776
+ });
2777
+ if (node.type === "group" && node.prompt) {
2778
+ out.push({
2779
+ id: node.prompt.id,
2780
+ kind: "beat",
2781
+ text: strings[node.prompt.id],
2782
+ location: segments,
2783
+ sceneId: scene.id,
2784
+ file
2785
+ });
2786
+ return;
2787
+ }
2788
+ if (node.type !== "snippet") return;
2789
+ for (const beat of node.beats ?? []) {
2790
+ out.push({
2791
+ id: beat.id,
2792
+ kind: "beat",
2793
+ text: strings[beat.id],
2794
+ location: segments,
2795
+ sceneId: scene.id,
2796
+ file
2797
+ });
2798
+ }
2799
+ });
2800
+ }
2801
+ }
2802
+ return out;
2803
+ }
2804
+
2805
+ // src/replace.ts
2806
+ import { canonicalStringify as canonicalStringify5 } from "@patterkit/core";
2807
+ import { walkNodes as walkNodes5, PROJECT_LOCALE_SCENE as PROJECT_LOCALE_SCENE2 } from "@patterkit/model";
2808
+ function matcher(opts) {
2809
+ if (!opts.query) return null;
2810
+ const esc2 = opts.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2811
+ const body = opts.wholeWord ? `\\b${esc2}\\b` : esc2;
2812
+ return new RegExp(body, opts.caseSensitive ? "g" : "gi");
2813
+ }
2814
+ function runReplace(loaded, opts) {
2815
+ const plan = { hits: [], writes: [], shards: [], scenes: 0 };
2816
+ const re = matcher(opts);
2817
+ if (!re) return plan;
2818
+ const def = loaded.project.locales.default;
2819
+ const locOf = /* @__PURE__ */ new Map();
2820
+ const sceneName = /* @__PURE__ */ new Map();
2821
+ for (const scene of loaded.scenes) {
2822
+ sceneName.set(scene.id, scene.name);
2823
+ for (const block of scene.blocks) {
2824
+ const segs = [scene.name, block.name];
2825
+ walkNodes5(block.children, (node) => {
2826
+ if (node.type === "group") {
2827
+ if (node.prompt) locOf.set(node.prompt.id, segs);
2828
+ return;
2829
+ }
2830
+ for (const b of node.beats ?? []) locOf.set(b.id, segs);
2831
+ });
2832
+ }
2833
+ }
2834
+ for (let i = 0; i < loaded.locales.length; i++) {
2835
+ const shard = loaded.locales[i];
2836
+ if (shard.locale !== def || shard.scene === PROJECT_LOCALE_SCENE2) continue;
2837
+ const path = loaded.localeFiles[i];
2838
+ if (!path) continue;
2839
+ let changed = false;
2840
+ const merged = { ...shard.strings };
2841
+ for (const [id, before] of Object.entries(shard.strings)) {
2842
+ if (opts.onlyId && id !== opts.onlyId) continue;
2843
+ re.lastIndex = 0;
2844
+ if (!re.test(before)) continue;
2845
+ const after = before.replace(re, () => opts.replacement);
2846
+ if (after === before) continue;
2847
+ plan.hits.push({ id, sceneId: shard.scene, location: locOf.get(id) ?? [sceneName.get(shard.scene) ?? shard.scene], before, after });
2848
+ merged[id] = after;
2849
+ changed = true;
2850
+ }
2851
+ if (changed) {
2852
+ const next = { ...shard, strings: merged };
2853
+ plan.writes.push({ path, content: canonicalStringify5(next) });
2854
+ plan.shards.push(next);
2855
+ plan.scenes++;
2856
+ }
2857
+ }
2858
+ return plan;
2859
+ }
2860
+
2861
+ // src/pack.ts
2862
+ import JSZip from "jszip";
2863
+ import { readFileSync as readFileSync4 } from "fs";
2864
+ import { dirname as dirname4, relative, sep as sep2 } from "path";
2865
+ import { parseSource as parseSource4 } from "@patterkit/core";
2866
+ var SHARD_EXTENSIONS = [".patterflow", ".patterloc", ".patterx", ".patterproj"];
2867
+ var FIXED_DATE = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
2868
+ async function runPack(startPath) {
2869
+ const projectFile = findProjectFile(startPath);
2870
+ const root = dirname4(projectFile);
2871
+ const project = parseSource4(readFileSync4(projectFile, "utf8"));
2872
+ const files = SHARD_EXTENSIONS.flatMap((ext) => walkFiles(root, ext)).map((abs) => ({ abs, rel: relative(root, abs).split(sep2).join("/") })).sort((a, b) => a.rel.localeCompare(b.rel));
2873
+ const manifest = {
2874
+ schema: "patter/document@0",
2875
+ project: { id: project.project.id, name: project.project.name },
2876
+ files: files.map((f) => f.rel)
2877
+ };
2878
+ const zip = new JSZip();
2879
+ zip.file("patter.manifest.json", JSON.stringify(manifest, null, 2) + "\n", { date: FIXED_DATE });
2880
+ for (const f of files) zip.file(f.rel, readFileSync4(f.abs, "utf8"), { date: FIXED_DATE });
2881
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", streamFiles: false });
2882
+ }
2883
+
2884
+ // src/unpack.ts
2885
+ import JSZip2 from "jszip";
2886
+ import { join as join5, normalize, isAbsolute as isAbsolute2, sep as sep3 } from "path";
2887
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
2888
+ import { parseSource as parseSource5, canonicalStringify as canonicalStringify7 } from "@patterkit/core";
2889
+
2890
+ // src/merge.ts
2891
+ import { canonicalStringify as canonicalStringify6 } from "@patterkit/core";
2892
+ var UnsupportedMergeError = class extends Error {
2893
+ };
2894
+ var eq = (a, b) => canonicalStringify6(a) === canonicalStringify6(b);
2895
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2896
+ function detectMergeType(file) {
2897
+ const s = typeof file.schema === "string" ? file.schema : "";
2898
+ if (s.startsWith("patter/flow")) return "flow";
2899
+ if (s.startsWith("patter/strings")) return "loc";
2900
+ if (s.startsWith("patter/authoring")) return "authoring";
2901
+ if (s.startsWith("patter/project")) return "project";
2902
+ throw new UnsupportedMergeError(`cannot detect a Patter merge type from schema '${s}'`);
2903
+ }
2904
+ function runMerge(base, ours, theirs, opts) {
2905
+ const type = opts?.type ?? detectMergeType(ours);
2906
+ switch (type) {
2907
+ case "loc":
2908
+ return mergeLoc(base, ours, theirs);
2909
+ case "authoring":
2910
+ return mergeAuthoring2(base, ours, theirs);
2911
+ case "flow":
2912
+ return mergeFlow(base, ours, theirs);
2913
+ case "project":
2914
+ return mergeProject(base, ours, theirs);
2915
+ }
2916
+ }
2917
+ function deletedKind(base, ours, theirs) {
2918
+ const deleted = base !== void 0 && (ours === void 0 || theirs === void 0);
2919
+ return deleted ? "delete-vs-edit" : "both-changed";
2920
+ }
2921
+ function merge3(base, ours, theirs, id, path, conflicts) {
2922
+ if (eq(ours, theirs)) return ours;
2923
+ if (eq(base, ours)) return theirs;
2924
+ if (eq(base, theirs)) return ours;
2925
+ conflicts.push({ id, path, base, ours, theirs, kind: deletedKind(base, ours, theirs) });
2926
+ return ours;
2927
+ }
2928
+ function mergeMap(base, ours, theirs, prefix, conflicts, resolve4) {
2929
+ const out = {};
2930
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
2931
+ const b = base[k], o = ours[k], t = theirs[k];
2932
+ let v;
2933
+ if (eq(o, t)) v = o;
2934
+ else if (eq(b, o)) v = t;
2935
+ else if (eq(b, t)) v = o;
2936
+ else {
2937
+ const r = resolve4?.(k, b, o, t);
2938
+ if (r && r.resolved) v = r.value;
2939
+ else {
2940
+ conflicts.push({ id: k, path: `${prefix}.${k}`, base: b, ours: o, theirs: t, kind: r ? r.kind : deletedKind(b, o, t) });
2941
+ v = o;
2942
+ }
2943
+ }
2944
+ if (v !== void 0) out[k] = v;
2945
+ }
2946
+ return out;
2947
+ }
2948
+ var asMap = (v) => isObj(v) ? v : {};
2949
+ var asArr = (v) => Array.isArray(v) ? v : [];
2950
+ function setIf(obj, key, val) {
2951
+ if (Array.isArray(val) ? val.length > 0 : Object.keys(val).length > 0) obj[key] = val;
2952
+ }
2953
+ function mergeLoc(base, ours, theirs) {
2954
+ const conflicts = [];
2955
+ const merged = {};
2956
+ for (const f of ["schema", "scene", "locale", "default"]) {
2957
+ const v = merge3(base[f], ours[f], theirs[f], "", f, conflicts);
2958
+ if (v !== void 0) merged[f] = v;
2959
+ }
2960
+ merged.strings = mergeMap(asMap(base.strings), asMap(ours.strings), asMap(theirs.strings), "strings", conflicts);
2961
+ return { type: "loc", merged, conflicts, warnings: [] };
2962
+ }
2963
+ function mergeAuthoring2(base, ours, theirs) {
2964
+ const conflicts = [];
2965
+ const merged = {};
2966
+ merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
2967
+ setIf(merged, "comments", mergeComments(asArr(base.comments), asArr(ours.comments), asArr(theirs.comments)));
2968
+ const edits = mergeEdits(asMap(base.edits), asMap(ours.edits), asMap(theirs.edits));
2969
+ setIf(merged, "edits", edits);
2970
+ const lww = lastWriterWins(asMap(ours.edits), asMap(theirs.edits));
2971
+ for (const f of ["writing", "recording", "audio"]) {
2972
+ setIf(merged, f, mergeMap(asMap(base[f]), asMap(ours[f]), asMap(theirs[f]), f, conflicts, lww));
2973
+ }
2974
+ setIf(merged, "documentation", mergeMap(asMap(base.documentation), asMap(ours.documentation), asMap(theirs.documentation), "documentation", conflicts));
2975
+ setIf(merged, "cut", mergeMap(asMap(base.cut), asMap(ours.cut), asMap(theirs.cut), "cut", conflicts, void 0));
2976
+ return { type: "authoring", merged, conflicts, warnings: [] };
2977
+ }
2978
+ function mergeComments(base, ours, theirs) {
2979
+ const byId = /* @__PURE__ */ new Map();
2980
+ for (const c of [...base, ...ours, ...theirs]) {
2981
+ if (isObj(c) && typeof c.id === "string" && !byId.has(c.id)) byId.set(c.id, c);
2982
+ }
2983
+ return [...byId.values()].sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")));
2984
+ }
2985
+ function lastWriterWins(oursEdits, theirsEdits) {
2986
+ return (id, _b, o, t) => {
2987
+ const om = asMap(oursEdits[id]).modifiedAt, tm = asMap(theirsEdits[id]).modifiedAt;
2988
+ if (typeof om === "string" && typeof tm === "string") return { resolved: true, value: om >= tm ? o : t };
2989
+ return { resolved: false, kind: "no-timestamp" };
2990
+ };
2991
+ }
2992
+ function mergeEdits(base, ours, theirs) {
2993
+ const out = {};
2994
+ for (const id of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
2995
+ const o = ours[id], t = theirs[id];
2996
+ if (eq(o, t)) {
2997
+ if (o !== void 0) out[id] = o;
2998
+ continue;
2999
+ }
3000
+ if (o === void 0) {
3001
+ if (t !== void 0) out[id] = t;
3002
+ continue;
3003
+ }
3004
+ if (t === void 0) {
3005
+ out[id] = o;
3006
+ continue;
3007
+ }
3008
+ out[id] = mergeEditRecord(asMap(o), asMap(t));
3009
+ }
3010
+ return out;
3011
+ }
3012
+ function mergeEditRecord(o, t) {
3013
+ const om = typeof o.modifiedAt === "string" ? o.modifiedAt : "";
3014
+ const tm = typeof t.modifiedAt === "string" ? t.modifiedAt : "";
3015
+ const newer = om >= tm ? o : t;
3016
+ const ol = asMap(o.localisedAt), tl = asMap(t.localisedAt);
3017
+ const loc = {};
3018
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(ol), ...Object.keys(tl)])) {
3019
+ const a = typeof ol[k] === "string" ? ol[k] : "";
3020
+ const b = typeof tl[k] === "string" ? tl[k] : "";
3021
+ loc[k] = a >= b ? ol[k] ?? tl[k] : tl[k] ?? ol[k];
3022
+ }
3023
+ const merged = { ...newer };
3024
+ if (Object.keys(loc).length > 0) merged.localisedAt = loc;
3025
+ return merged;
3026
+ }
3027
+ function mergeKeyedByName(base, ours, theirs, path, conflicts) {
3028
+ const byName = (arr) => {
3029
+ const m = {};
3030
+ for (const p of arr) if (isObj(p) && typeof p.name === "string") m[p.name] = p;
3031
+ return m;
3032
+ };
3033
+ const merged = mergeMap(byName(base), byName(ours), byName(theirs), path, conflicts);
3034
+ return Object.keys(merged).sort().map((name) => merged[name]);
3035
+ }
3036
+ function mergeProject(base, ours, theirs) {
3037
+ const conflicts = [];
3038
+ const merged = {};
3039
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
3040
+ if (k === "properties" || k === "cast") {
3041
+ const v = mergeKeyedByName(asArr(base[k]), asArr(ours[k]), asArr(theirs[k]), k, conflicts);
3042
+ if (v.length > 0) merged[k] = v;
3043
+ } else if (k === "gameDataFields") {
3044
+ const v = mergeGameDataFields(asMap(base[k]), asMap(ours[k]), asMap(theirs[k]), conflicts);
3045
+ if (Object.keys(v).length > 0) merged[k] = v;
3046
+ } else if (k === "locales") {
3047
+ merged.locales = mergeLocales(asMap(base.locales), asMap(ours.locales), asMap(theirs.locales), conflicts);
3048
+ } else {
3049
+ const v = merge3(base[k], ours[k], theirs[k], "", k, conflicts);
3050
+ if (v !== void 0) merged[k] = v;
3051
+ }
3052
+ }
3053
+ return { type: "project", merged, conflicts, warnings: [] };
3054
+ }
3055
+ function mergeGameDataFields(b, o, t, conflicts) {
3056
+ const out = {};
3057
+ for (const kind of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
3058
+ const v = mergeKeyedByName(asArr(b[kind]), asArr(o[kind]), asArr(t[kind]), `gameDataFields.${kind}`, conflicts);
3059
+ if (v.length > 0) out[kind] = v;
3060
+ }
3061
+ return out;
3062
+ }
3063
+ function mergeLocales(b, o, t, conflicts) {
3064
+ const out = {};
3065
+ const def = merge3(b.default, o.default, t.default, "", "locales.default", conflicts);
3066
+ if (def !== void 0) out.default = def;
3067
+ const all = [];
3068
+ for (const arr of [asArr(b.all), asArr(o.all), asArr(t.all)]) {
3069
+ for (const x of arr) if (typeof x === "string" && !all.includes(x)) all.push(x);
3070
+ }
3071
+ out.all = all;
3072
+ return out;
3073
+ }
3074
+ var CHILD_KEYS = ["blocks", "children", "beats"];
3075
+ function toTree(raw) {
3076
+ const childKey = CHILD_KEYS.find((k) => Array.isArray(raw[k]));
3077
+ const fields = {};
3078
+ for (const [k, v] of Object.entries(raw)) if (k !== childKey) fields[k] = v;
3079
+ const children = childKey ? raw[childKey].filter(isObj).map(toTree) : [];
3080
+ return { id: typeof raw.id === "string" ? raw.id : "", fields, childKey, children };
3081
+ }
3082
+ function fromTree(n) {
3083
+ const out = { ...n.fields };
3084
+ if (n.childKey) out[n.childKey] = n.children.map(fromTree);
3085
+ return out;
3086
+ }
3087
+ function mergeFlow(base, ours, theirs) {
3088
+ const conflicts = [];
3089
+ const warnings = [];
3090
+ const merged = {};
3091
+ merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
3092
+ const bScene = isObj(base.scene) ? toTree(base.scene) : void 0;
3093
+ const oScene = toTree(asMap(ours.scene));
3094
+ const tScene = isObj(theirs.scene) ? toTree(theirs.scene) : void 0;
3095
+ const mergedScene = mergeNode(bScene, oScene, tScene, "scene", conflicts, warnings);
3096
+ merged.scene = fromTree(mergedScene);
3097
+ checkDuplicateIds(mergedScene, conflicts);
3098
+ return { type: "flow", merged, conflicts, warnings };
3099
+ }
3100
+ function mergeNode(b, o, t, path, conflicts, warnings) {
3101
+ const fields = mergeFields(b?.fields ?? {}, o.fields, t?.fields ?? {}, o.id, path, conflicts);
3102
+ const childKey = o.childKey ?? t?.childKey ?? b?.childKey;
3103
+ const children = childKey ? mergeChildren(b?.children ?? [], o.children, t?.children ?? [], `${path}.${childKey}`, conflicts, warnings) : [];
3104
+ return { id: o.id, fields, childKey, children };
3105
+ }
3106
+ function mergeFields(b, o, t, id, path, conflicts) {
3107
+ const out = {};
3108
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
3109
+ const v = merge3(b[k], o[k], t[k], id, `${path}.${k}`, conflicts);
3110
+ if (v !== void 0) out[k] = v;
3111
+ }
3112
+ return out;
3113
+ }
3114
+ function mergeChildren(B, O, T, path, conflicts, warnings) {
3115
+ const Bm = idMap(B), Om = idMap(O), Tm = idMap(T);
3116
+ const Bset = new Set(Bm.keys()), Oset = new Set(Om.keys()), Tset = new Set(Tm.keys());
3117
+ const merged = /* @__PURE__ */ new Map();
3118
+ const here = (id) => `${path}[${id}]`;
3119
+ for (const id of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
3120
+ const b = Bm.get(id), o = Om.get(id), t = Tm.get(id);
3121
+ const inB = Bset.has(id), inO = Oset.has(id), inT = Tset.has(id);
3122
+ if (inO && inT) {
3123
+ if (!inB && !eq(fromTree(o), fromTree(t))) {
3124
+ conflicts.push({ id, path: here(id), base: void 0, ours: fromTree(o), theirs: fromTree(t), kind: "added-both" });
3125
+ merged.set(id, o);
3126
+ } else {
3127
+ merged.set(id, mergeNode(b, o, t, here(id), conflicts, warnings));
3128
+ }
3129
+ } else if (inO && !inT) {
3130
+ if (inB && !eq(fromTree(o), fromTree(b))) {
3131
+ conflicts.push({ id, path: here(id), base: fromTree(b), ours: fromTree(o), theirs: void 0, kind: "delete-vs-edit" });
3132
+ merged.set(id, o);
3133
+ } else if (!inB) {
3134
+ merged.set(id, o);
3135
+ }
3136
+ } else if (!inO && inT) {
3137
+ if (inB && !eq(fromTree(t), fromTree(b))) {
3138
+ conflicts.push({ id, path: here(id), base: fromTree(b), ours: void 0, theirs: fromTree(t), kind: "delete-vs-edit" });
3139
+ } else if (!inB) {
3140
+ merged.set(id, t);
3141
+ }
3142
+ }
3143
+ }
3144
+ return orderChildren(merged, B, O, T, path, conflicts, warnings);
3145
+ }
3146
+ function orderChildren(merged, B, O, T, path, conflicts, warnings) {
3147
+ const Bids = B.map((n) => n.id), Oids = O.map((n) => n.id), Tids = T.map((n) => n.id);
3148
+ const Oidset = new Set(Oids);
3149
+ const common = new Set(Bids.filter((id) => merged.has(id)));
3150
+ const restrict = (ids) => ids.filter((id) => common.has(id));
3151
+ const relB = restrict(Bids), relO = restrict(Oids), relT = restrict(Tids);
3152
+ const fullO = relO.length === common.size, fullT = relT.length === common.size;
3153
+ let commonOrder;
3154
+ if (!fullO && !fullT) commonOrder = relB;
3155
+ else if (!fullO) commonOrder = relT;
3156
+ else if (!fullT) commonOrder = relO;
3157
+ else if (arrEq(relO, relB)) commonOrder = relT;
3158
+ else if (arrEq(relT, relB)) commonOrder = relO;
3159
+ else if (arrEq(relO, relT)) commonOrder = relO;
3160
+ else {
3161
+ conflicts.push({ id: "", path, base: relB, ours: relO, theirs: relT, kind: "moved" });
3162
+ commonOrder = relO;
3163
+ }
3164
+ const anchorIn = (ids, id) => {
3165
+ for (let i = ids.indexOf(id) - 1; i >= 0; i--) if (common.has(ids[i])) return ids[i];
3166
+ return null;
3167
+ };
3168
+ const byAnchor = /* @__PURE__ */ new Map();
3169
+ const group = (a) => {
3170
+ let g = byAnchor.get(a);
3171
+ if (!g) {
3172
+ g = { ours: [], theirs: [] };
3173
+ byAnchor.set(a, g);
3174
+ }
3175
+ return g;
3176
+ };
3177
+ for (const n of O) if (merged.has(n.id) && !common.has(n.id)) group(anchorIn(Oids, n.id)).ours.push(n.id);
3178
+ for (const n of T) if (merged.has(n.id) && !common.has(n.id) && !Oidset.has(n.id)) group(anchorIn(Tids, n.id)).theirs.push(n.id);
3179
+ const out = [];
3180
+ const emit = (anchor) => {
3181
+ const g = byAnchor.get(anchor);
3182
+ if (!g) return;
3183
+ if (g.ours.length > 0 && g.theirs.length > 0) warnings.push({ id: anchor ?? "", path, message: "both sides inserted here; kept ours-then-theirs - review the order" });
3184
+ out.push(...g.ours, ...g.theirs);
3185
+ };
3186
+ emit(null);
3187
+ for (const id of commonOrder) {
3188
+ out.push(id);
3189
+ emit(id);
3190
+ }
3191
+ return out.map((id) => merged.get(id));
3192
+ }
3193
+ function checkDuplicateIds(root, conflicts) {
3194
+ const seen = /* @__PURE__ */ new Set(), dup = /* @__PURE__ */ new Set();
3195
+ const walk = (n) => {
3196
+ if (n.id) {
3197
+ if (seen.has(n.id)) dup.add(n.id);
3198
+ else seen.add(n.id);
3199
+ }
3200
+ n.children.forEach(walk);
3201
+ };
3202
+ walk(root);
3203
+ for (const id of dup) conflicts.push({ id, path: "scene", base: void 0, ours: void 0, theirs: void 0, kind: "structural" });
3204
+ }
3205
+ var idMap = (nodes) => new Map(nodes.filter((n) => n.id).map((n) => [n.id, n]));
3206
+ var arrEq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
3207
+
3208
+ // src/unpack.ts
3209
+ var MANIFEST = "patter.manifest.json";
3210
+ var UnsafeEntryError = class extends Error {
3211
+ };
3212
+ async function readDocShards(bytes) {
3213
+ const zip = await JSZip2.loadAsync(bytes);
3214
+ const out = /* @__PURE__ */ new Map();
3215
+ for (const [name, entry] of Object.entries(zip.files)) {
3216
+ if (entry.dir || name === MANIFEST) continue;
3217
+ if (isUnsafeEntry(name)) throw new UnsafeEntryError(`document entry escapes the target directory: ${name}`);
3218
+ out.set(name, await entry.async("string"));
3219
+ }
3220
+ return out;
3221
+ }
3222
+ async function runUnpack(bytes, targetDir) {
3223
+ const shards = await readDocShards(bytes);
3224
+ return [...shards.entries()].map(([name, content]) => ({ path: join5(targetDir, name), content })).sort((a, b) => a.path.localeCompare(b.path));
3225
+ }
3226
+ async function runUnpackMerge(returnedBytes, baseBytes, projectDir) {
3227
+ const theirs = await readDocShards(returnedBytes);
3228
+ const base = await readDocShards(baseBytes);
3229
+ const shards = [];
3230
+ const writes = [];
3231
+ const sidecars = [];
3232
+ let conflicts = 0, warnings = 0;
3233
+ for (const [rel, theirText] of [...theirs.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
3234
+ const outPath = join5(projectDir, rel);
3235
+ if (!existsSync3(outPath)) {
3236
+ writes.push({ path: outPath, content: theirText });
3237
+ shards.push({ path: rel, added: true });
3238
+ continue;
3239
+ }
3240
+ const oursObj = parseSource5(readFileSync5(outPath, "utf8"));
3241
+ const theirsObj = parseSource5(theirText);
3242
+ const baseText = base.get(rel);
3243
+ const baseObj = baseText !== void 0 ? parseSource5(baseText) : {};
3244
+ const result = runMerge(baseObj, oursObj, theirsObj);
3245
+ writes.push({ path: outPath, content: canonicalStringify7(result.merged) });
3246
+ if (result.conflicts.length > 0) {
3247
+ sidecars.push({ path: `${outPath}.patterconflict`, content: JSON.stringify({ type: result.type, conflicts: result.conflicts, warnings: result.warnings }, null, 2) + "\n" });
3248
+ conflicts += result.conflicts.length;
3249
+ }
3250
+ warnings += result.warnings.length;
3251
+ shards.push({ path: rel, result, added: false });
3252
+ }
3253
+ return { shards, writes, sidecars, conflicts, warnings };
3254
+ }
3255
+ function isUnsafeEntry(name) {
3256
+ if (isAbsolute2(name) || /^[a-zA-Z]:/.test(name)) return true;
3257
+ const norm = normalize(name);
3258
+ return norm === ".." || norm.startsWith(".." + sep3) || norm.startsWith("../");
3259
+ }
3260
+ export {
3261
+ SHARD_EXTENSIONS,
3262
+ UnsafeEntryError,
3263
+ UnsupportedMergeError,
3264
+ applyLoc,
3265
+ applySceneOrder,
3266
+ applyWrites,
3267
+ bundleOutputPath,
3268
+ catalogToJson,
3269
+ catalogToPo,
3270
+ catalogToXlsx,
3271
+ classesForChannel,
3272
+ detectMergeType,
3273
+ extractLoc,
3274
+ findProjectFile,
3275
+ isUnsafeEntry,
3276
+ jsonToCatalog,
3277
+ listProjectTags,
3278
+ loadProject,
3279
+ loadProjectLanding,
3280
+ poToCatalog,
3281
+ proposeCoverageDrivers,
3282
+ renderCoverageText,
3283
+ renderPlay,
3284
+ renderReportText,
3285
+ resolveDocumentation,
3286
+ resolveStart,
3287
+ runCoverage,
3288
+ runExport,
3289
+ runExportFull,
3290
+ runExportHtml,
3291
+ runExportWeb,
3292
+ runFormat,
3293
+ runInit,
3294
+ runMerge,
3295
+ runPack,
3296
+ runPlay,
3297
+ runPropertyUsage,
3298
+ runReplace,
3299
+ runReport,
3300
+ runReportXlsx,
3301
+ runResolve,
3302
+ runScriptDoc,
3303
+ runSearch,
3304
+ runStatusBrowse,
3305
+ runTagBrowse,
3306
+ runUnpack,
3307
+ runUnpackMerge,
3308
+ runValidate,
3309
+ runVoiceScript,
3310
+ scanAudioStatus,
3311
+ sceneIdForShard,
3312
+ scriptToDocx,
3313
+ scriptToPdf,
3314
+ vcsConfigWrites,
3315
+ voiceScriptToXlsx,
3316
+ xlsxToCatalog
3317
+ };