@patterkit/ops 0.3.5 → 0.4.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +25 -5
  2. package/dist/index.js +1873 -1799
  3. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ function readDirSafe(dir) {
13
13
  function walkFiles(dir, ext) {
14
14
  const out = [];
15
15
  for (const e of readDirSafe(dir)) {
16
+ if (e.name.startsWith(".")) continue;
16
17
  const p = join(dir, e.name);
17
18
  if (e.isDirectory()) out.push(...walkFiles(p, ext));
18
19
  else if (e.isFile() && e.name.endsWith(ext)) out.push(p);
@@ -225,251 +226,626 @@ function applyWrites(writes) {
225
226
 
226
227
  // src/validate.ts
227
228
  import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
228
- import { validateProject, parseSource as parseSource2 } from "@patterkit/core";
229
- import { validateConditions, validateInterpolation, exportBundle, hostScopesToSpec } from "@patterkit/compiler";
230
- function runValidate(loaded) {
231
- const { project, scenes, locales } = loaded;
232
- const structural = validateProject({ project, scenes, authoring: loaded.authoring });
233
- const foreignScopes = hostScopesToSpec(project.scopeRegistry);
234
- const conditions = validateConditions({ project, scenes }, { foreignScopes });
235
- const interpolation = validateInterpolation({ project, scenes, locales }, { foreignScopes });
236
- const hygiene = checkHygiene([loaded.projectFile, ...Object.values(loaded.sceneFiles), ...loaded.localeFiles, ...loaded.authoringFiles]);
237
- const staleBundles = checkBundles(loaded);
238
- const unresolvedMerges = walkFiles(loaded.root, ".patterconflict").map((file) => ({
229
+
230
+ // src/merge.ts
231
+ import { canonicalStringify } from "@patterkit/core";
232
+ var UnsupportedMergeError = class extends Error {
233
+ };
234
+ var eq = (a, b) => canonicalStringify(a) === canonicalStringify(b);
235
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
236
+ var CONFLICT_SIDECAR = ".patterconflict";
237
+ function sidecarIssues(sidecarPaths) {
238
+ return sidecarPaths.map((file) => ({
239
239
  file,
240
240
  message: "unresolved merge conflict - resolve it and delete the .patterconflict sidecar before committing"
241
241
  }));
242
- return {
243
- structural,
244
- conditions,
245
- interpolation,
246
- hygiene,
247
- staleBundles,
248
- unresolvedMerges,
249
- ok: structural.length === 0 && conditions.length === 0 && interpolation.length === 0 && hygiene.length === 0 && staleBundles.length === 0 && unresolvedMerges.length === 0
250
- };
251
242
  }
252
- function checkBundles(loaded) {
253
- const issues = [];
254
- const bundles = walkFiles(loaded.root, ".patterc");
255
- if (bundles.length === 0) return issues;
256
- let fresh;
257
- try {
258
- fresh = exportBundle({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales }).content.hash;
259
- } catch {
260
- return issues;
243
+ function detectMergeType(file) {
244
+ const s = typeof file.schema === "string" ? file.schema : "";
245
+ if (s.startsWith("patter/flow")) return "flow";
246
+ if (s.startsWith("patter/strings")) return "loc";
247
+ if (s.startsWith("patter/authoring")) return "authoring";
248
+ if (s.startsWith("patter/project")) return "project";
249
+ throw new UnsupportedMergeError(`cannot detect a Patter merge type from schema '${s}'`);
250
+ }
251
+ function runMerge(base, ours, theirs, opts) {
252
+ const type = opts?.type ?? detectMergeType(ours);
253
+ switch (type) {
254
+ case "loc":
255
+ return mergeLoc(base, ours, theirs);
256
+ case "authoring":
257
+ return mergeAuthoring(base, ours, theirs);
258
+ case "flow":
259
+ return mergeFlow(base, ours, theirs);
260
+ case "project":
261
+ return mergeProject(base, ours, theirs);
261
262
  }
262
- for (const file of bundles) {
263
- let hash;
264
- try {
265
- const parsed = parseSource2(readFileSync2(file, "utf8"));
266
- hash = parsed?.content?.hash;
267
- } catch {
268
- issues.push({ file, message: "compiled bundle is unparseable - run `patter export`" });
269
- continue;
270
- }
271
- if (hash !== fresh) {
272
- issues.push({ file, message: "compiled bundle is stale (does not match current source) - run `patter export`" });
263
+ }
264
+ function deletedKind(base, ours, theirs) {
265
+ const deleted = base !== void 0 && (ours === void 0 || theirs === void 0);
266
+ return deleted ? "delete-vs-edit" : "both-changed";
267
+ }
268
+ function merge3(base, ours, theirs, id, path, conflicts) {
269
+ if (eq(ours, theirs)) return ours;
270
+ if (eq(base, ours)) return theirs;
271
+ if (eq(base, theirs)) return ours;
272
+ conflicts.push({ id, path, base, ours, theirs, kind: deletedKind(base, ours, theirs) });
273
+ return ours;
274
+ }
275
+ function mergeMap(base, ours, theirs, prefix, conflicts, resolve5) {
276
+ const out = {};
277
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
278
+ const b = base[k], o = ours[k], t = theirs[k];
279
+ let v;
280
+ if (eq(o, t)) v = o;
281
+ else if (eq(b, o)) v = t;
282
+ else if (eq(b, t)) v = o;
283
+ else {
284
+ const r = resolve5?.(k, b, o, t);
285
+ if (r && r.resolved) v = r.value;
286
+ else {
287
+ conflicts.push({ id: k, path: `${prefix}.${k}`, base: b, ours: o, theirs: t, kind: r ? r.kind : deletedKind(b, o, t) });
288
+ v = o;
289
+ }
273
290
  }
291
+ if (v !== void 0) out[k] = v;
274
292
  }
275
- return issues;
293
+ return out;
276
294
  }
277
- var hygieneCache = /* @__PURE__ */ new Map();
278
- function checkHygiene(files) {
279
- const issues = [];
280
- for (const file of files) {
281
- let mtimeMs;
282
- try {
283
- mtimeMs = statSync2(file).mtimeMs;
284
- } catch {
295
+ var asMap = (v) => isObj(v) ? v : {};
296
+ var asArr = (v) => Array.isArray(v) ? v : [];
297
+ function setIf(obj, key, val) {
298
+ if (Array.isArray(val) ? val.length > 0 : Object.keys(val).length > 0) obj[key] = val;
299
+ }
300
+ function mergeLoc(base, ours, theirs) {
301
+ const conflicts = [];
302
+ const merged = {};
303
+ for (const f of ["schema", "scene", "locale", "default"]) {
304
+ const v = merge3(base[f], ours[f], theirs[f], "", f, conflicts);
305
+ if (v !== void 0) merged[f] = v;
306
+ }
307
+ merged.strings = mergeMap(asMap(base.strings), asMap(ours.strings), asMap(theirs.strings), "strings", conflicts);
308
+ return { type: "loc", merged, conflicts, warnings: [] };
309
+ }
310
+ function mergeAuthoring(base, ours, theirs) {
311
+ const conflicts = [];
312
+ const merged = {};
313
+ merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
314
+ setIf(merged, "comments", mergeComments(asArr(base.comments), asArr(ours.comments), asArr(theirs.comments)));
315
+ const edits = mergeEdits(asMap(base.edits), asMap(ours.edits), asMap(theirs.edits));
316
+ setIf(merged, "edits", edits);
317
+ const lww = lastWriterWins(asMap(ours.edits), asMap(theirs.edits));
318
+ for (const f of ["writing", "recording", "audio"]) {
319
+ setIf(merged, f, mergeMap(asMap(base[f]), asMap(ours[f]), asMap(theirs[f]), f, conflicts, lww));
320
+ }
321
+ setIf(merged, "documentation", mergeMap(asMap(base.documentation), asMap(ours.documentation), asMap(theirs.documentation), "documentation", conflicts));
322
+ setIf(merged, "cut", mergeMap(asMap(base.cut), asMap(ours.cut), asMap(theirs.cut), "cut", conflicts, void 0));
323
+ setIf(merged, "suggestions", mergeById(asArr(base.suggestions), asArr(ours.suggestions), asArr(theirs.suggestions), "suggestions", conflicts));
324
+ setIf(merged, "rerecord", mergeMap(asMap(base.rerecord), asMap(ours.rerecord), asMap(theirs.rerecord), "rerecord", conflicts, void 0));
325
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
326
+ if (k in merged || AUTHORING_HANDLED.has(k)) continue;
327
+ const v = merge3(base[k], ours[k], theirs[k], "", k, conflicts);
328
+ if (v !== void 0) merged[k] = v;
329
+ }
330
+ return { type: "authoring", merged, conflicts, warnings: [] };
331
+ }
332
+ var AUTHORING_HANDLED = /* @__PURE__ */ new Set(["schema", "comments", "edits", "writing", "recording", "audio", "documentation", "cut", "suggestions", "rerecord"]);
333
+ function mergeById(base, ours, theirs, path, conflicts) {
334
+ const index = (arr) => {
335
+ const m = {};
336
+ for (const r of arr) if (isObj(r) && typeof r.id === "string") m[r.id] = r;
337
+ return m;
338
+ };
339
+ const merged = mergeMap(index(base), index(ours), index(theirs), path, conflicts);
340
+ return Object.values(merged).sort((a, b) => String(asMap(a).ts ?? "").localeCompare(String(asMap(b).ts ?? "")));
341
+ }
342
+ function mergeComments(base, ours, theirs) {
343
+ const byId = /* @__PURE__ */ new Map();
344
+ for (const c of [...base, ...ours, ...theirs]) {
345
+ if (isObj(c) && typeof c.id === "string" && !byId.has(c.id)) byId.set(c.id, c);
346
+ }
347
+ return [...byId.values()].sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")));
348
+ }
349
+ function lastWriterWins(oursEdits, theirsEdits) {
350
+ return (id, _b, o, t) => {
351
+ const om = asMap(oursEdits[id]).modifiedAt, tm = asMap(theirsEdits[id]).modifiedAt;
352
+ if (typeof om === "string" && typeof tm === "string") return { resolved: true, value: om >= tm ? o : t };
353
+ return { resolved: false, kind: "no-timestamp" };
354
+ };
355
+ }
356
+ function mergeEdits(base, ours, theirs) {
357
+ const out = {};
358
+ for (const id of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
359
+ const o = ours[id], t = theirs[id];
360
+ if (eq(o, t)) {
361
+ if (o !== void 0) out[id] = o;
285
362
  continue;
286
363
  }
287
- const hit = hygieneCache.get(file);
288
- if (hit && hit.mtimeMs === mtimeMs) {
289
- issues.push(...hit.issues);
364
+ if (o === void 0) {
365
+ if (t !== void 0) out[id] = t;
290
366
  continue;
291
367
  }
292
- const fileIssues = [];
293
- try {
294
- const text = readFileSync2(file, "utf8");
295
- if (text.charCodeAt(0) === 65279) {
296
- fileIssues.push({ file, message: "file starts with a UTF-8 BOM (canonical form is UTF-8 without BOM - run `patter format`)" });
297
- }
298
- if (text.includes("\r")) {
299
- fileIssues.push({ file, message: "file contains CRLF line endings (canonical form is LF - run `patter format`)" });
300
- }
301
- } catch {
368
+ if (t === void 0) {
369
+ out[id] = o;
302
370
  continue;
303
371
  }
304
- hygieneCache.set(file, { mtimeMs, issues: fileIssues });
305
- issues.push(...fileIssues);
372
+ out[id] = mergeEditRecord(asMap(o), asMap(t));
306
373
  }
307
- return issues;
374
+ return out;
308
375
  }
309
-
310
- // src/export.ts
311
- import { basename as basename2, isAbsolute, join as join2 } from "path";
312
- import { exportBundle as exportBundle2 } from "@patterkit/compiler";
313
- import { isContentlessBeat } from "@patterkit/model";
314
- function compileScenes(loaded) {
315
- const def = loaded.project.locales.default;
316
- const written = /* @__PURE__ */ new Set();
317
- for (const l of loaded.locales) if (l.locale === def) {
318
- for (const [k, v] of Object.entries(l.strings)) if (v) written.add(k);
376
+ function mergeEditRecord(o, t) {
377
+ const om = typeof o.modifiedAt === "string" ? o.modifiedAt : "";
378
+ const tm = typeof t.modifiedAt === "string" ? t.modifiedAt : "";
379
+ const newer = om >= tm ? o : t;
380
+ const ol = asMap(o.localisedAt), tl = asMap(t.localisedAt);
381
+ const loc = {};
382
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(ol), ...Object.keys(tl)])) {
383
+ const a = typeof ol[k] === "string" ? ol[k] : "";
384
+ const b = typeof tl[k] === "string" ? tl[k] : "";
385
+ loc[k] = a >= b ? ol[k] ?? tl[k] : tl[k] ?? ol[k];
319
386
  }
320
- const clean = (n) => {
321
- if (n.type === "group") return { ...n, children: (n.children ?? []).map(clean) };
322
- if (!n.beats) return n;
323
- const kept = n.beats.filter((b) => !isContentlessBeat(b, written.has(b.id)));
324
- if (kept.length === n.beats.length) return n;
325
- const { beats, ...rest } = n;
326
- return kept.length ? { ...rest, beats: kept } : rest;
387
+ const merged = { ...newer };
388
+ if (Object.keys(loc).length > 0) merged.localisedAt = loc;
389
+ return merged;
390
+ }
391
+ function mergeKeyedByName(base, ours, theirs, path, conflicts) {
392
+ const byName = (arr) => {
393
+ const m = {};
394
+ for (const p of arr) if (isObj(p) && typeof p.name === "string") m[p.name] = p;
395
+ return m;
327
396
  };
328
- return loaded.scenes.map((s) => ({
329
- ...s,
330
- blocks: s.blocks.map((b) => ({ ...b, children: b.children.map(clean) }))
331
- }));
397
+ const merged = mergeMap(byName(base), byName(ours), byName(theirs), path, conflicts);
398
+ return Object.keys(merged).sort().map((name) => merged[name]);
332
399
  }
333
- function runExport(loaded) {
334
- const { project, locales } = loaded;
335
- const full = exportBundle2({ project, scenes: compileScenes(loaded), locales });
336
- const loc = project.export?.localisation;
337
- if (!loc || loc.mode === "embedded") return full;
338
- const strings = loc.sourceDebug ? { [full.locales.default]: full.strings[full.locales.default] ?? {} } : {};
339
- return { ...full, strings, localisation: { mode: "ids", ...loc.sourceDebug ? { sourceDebug: true } : {} } };
400
+ function mergeProject(base, ours, theirs) {
401
+ const conflicts = [];
402
+ const merged = {};
403
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
404
+ if (k === "properties" || k === "cast") {
405
+ const v = mergeKeyedByName(asArr(base[k]), asArr(ours[k]), asArr(theirs[k]), k, conflicts);
406
+ if (v.length > 0) merged[k] = v;
407
+ } else if (k === "gameDataFields") {
408
+ const v = mergeGameDataFields(asMap(base[k]), asMap(ours[k]), asMap(theirs[k]), conflicts);
409
+ if (Object.keys(v).length > 0) merged[k] = v;
410
+ } else if (k === "locales") {
411
+ merged.locales = mergeLocales(asMap(base.locales), asMap(ours.locales), asMap(theirs.locales), conflicts);
412
+ } else {
413
+ const v = merge3(base[k], ours[k], theirs[k], "", k, conflicts);
414
+ if (v !== void 0) merged[k] = v;
415
+ }
416
+ }
417
+ return { type: "project", merged, conflicts, warnings: [] };
340
418
  }
341
- function runExportFull(loaded) {
342
- const { project, locales } = loaded;
343
- return exportBundle2({ project, scenes: compileScenes(loaded), locales });
419
+ function mergeGameDataFields(b, o, t, conflicts) {
420
+ const out = {};
421
+ for (const kind of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
422
+ const v = mergeKeyedByName(asArr(b[kind]), asArr(o[kind]), asArr(t[kind]), `gameDataFields.${kind}`, conflicts);
423
+ if (v.length > 0) out[kind] = v;
424
+ }
425
+ return out;
344
426
  }
345
- function bundleOutputPath(loaded) {
346
- const override = loaded.project.export?.bundle;
347
- if (override) return isAbsolute(override) ? override : join2(loaded.root, override);
348
- const stem = basename2(loaded.projectFile).replace(/\.patterproj$/, "");
349
- return join2(loaded.root, "dist", `${stem}.patterc`);
427
+ function mergeLocales(b, o, t, conflicts) {
428
+ const out = {};
429
+ const def = merge3(b.default, o.default, t.default, "", "locales.default", conflicts);
430
+ if (def !== void 0) out.default = def;
431
+ const all = [];
432
+ for (const arr of [asArr(b.all), asArr(o.all), asArr(t.all)]) {
433
+ for (const x of arr) if (typeof x === "string" && !all.includes(x)) all.push(x);
434
+ }
435
+ out.all = all;
436
+ return out;
350
437
  }
351
-
352
- // src/export-html.ts
353
- import { canonicalStringify } from "@patterkit/core";
354
-
355
- // src/playable-runtime.ts
356
- var PLAYABLE_RUNTIME_JS = '"use strict";var Patterplay=(()=>{var A=Object.defineProperty;var he=Object.getOwnPropertyDescriptor;var me=Object.getOwnPropertyNames;var Se=Object.prototype.hasOwnProperty;var ye=(n,e)=>{for(var t in e)A(n,t,{get:e[t],enumerable:!0})},ve=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of me(e))!Se.call(n,s)&&s!==t&&A(n,s,{get:()=>e[s],enumerable:!(r=he(e,s))||r.enumerable});return n};var be=n=>ve(A({},"__esModule",{value:!0}),n);var Ge={};ye(Ge,{Engine:()=>O,Flow:()=>w,buildTagIndex:()=>B,describeBundle:()=>pe,effectiveGameData:()=>ue,gameDataFields:()=>de,gameDataValue:()=>_});function S(n){switch(n[0]){case"b":return{kind:"bool",value:n[1]};case"n":return{kind:"number",value:n[1]};case"s":return{kind:"string",value:n[1]};case"sv":return{kind:"scopedvar",scope:n[1],name:n[2]};case"u":return{kind:"unary",op:n[1],operand:S(n[2])};case"bin":return{kind:"binary",op:n[1],left:S(n[2]),right:S(n[3])};case"call":{let e=n.slice(2).map(S);return{kind:"call",name:n[1],args:e}}case"fd":return{kind:"flagdelta",sign:n[1],name:n[2]}}}var p=class extends Error{constructor(e){super(e),this.name="EvalError"}};function C(n,e,t){let r=new Map(t.scopes.map(i=>[i.token,i.missing??"false"])),s=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 p(`@${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 p(`unknown function \'${i.name}\'`);return o.eval(i.args,{evaluate:s,ctx:e})}case"flagdelta":throw new p("flagdelta node is only valid as an argument to a flag-delta function");case"unary":{if(i.op==="not"){let a=s(i.operand);if(typeof a!="boolean")throw new p(`\'not\' requires a boolean operand, got ${typeof a}`);return!a}let o=s(i.operand);if(typeof o!="number")throw new p(`unary \'-\' requires a numeric operand, got ${typeof o}`);return-o}case"binary":{if(i.op==="and"){let c=s(i.left);if(typeof c!="boolean")throw new p(`\'and\' requires boolean operands, left is ${typeof c}`);if(!c)return!1;let l=s(i.right);if(typeof l!="boolean")throw new p(`\'and\' requires boolean operands, right is ${typeof l}`);return l}if(i.op==="or"){let c=s(i.left);if(typeof c!="boolean")throw new p(`\'or\' requires boolean operands, left is ${typeof c}`);if(c)return!0;let l=s(i.right);if(typeof l!="boolean")throw new p(`\'or\' requires boolean operands, right is ${typeof l}`);return l}let o=s(i.left),a=s(i.right);switch(i.op){case"==":return J(o,a);case"!=":return!J(o,a);case">":return y(o,a,">"),o>a;case">=":return y(o,a,">="),o>=a;case"<":return y(o,a,"<"),o<a;case"<=":return y(o,a,"<="),o<=a;case"+":if(typeof o=="number"&&typeof a=="number"||typeof o=="string"&&typeof a=="string")return o+a;throw new p(`\'+\' requires two numbers or two strings, got ${typeof o} and ${typeof a}`);case"-":return y(o,a,"-"),o-a;case"*":return y(o,a,"*"),o*a;case"/":if(y(o,a,"/"),a===0)throw new p("division by zero");return o/a}}}};return s(n)}function J(n,e){if(Array.isArray(n)||Array.isArray(e)){if(!Array.isArray(n)||!Array.isArray(e)||n.length!==e.length)return!1;for(let t=0;t<n.length;t++)if(n[t]!==e[t])return!1;return!0}return n===e}function y(n,e,t){if(typeof n!="number"||typeof e!="number")throw new p(`\'${t}\' requires numeric operands, got ${typeof n} and ${typeof e}`)}var we={name:"check_flags",count:n=>Math.max(1,n.args.length-1)},xe=[we];function z(n,e,t){let r=t?.countingCalls??xe;return E(n,t?.want??!0,e,r)}function E(n,e,t,r){if(n.kind==="binary"&&(n.op==="and"||n.op==="or")){let s=E(n.left,e,t,r),i=E(n.right,e,t,r);return n.op==="and"===e?s>0&&i>0?s+i:0:Math.max(s,i)}if(n.kind==="unary"&&n.op==="not")return E(n.operand,!e,t,r);if(n.kind==="call"){let s=r.find(i=>i.name===n.name);if(s){let i=s.count(n),o=t(n);return e?o?i:0:o?0:1}}return t(n)===e?1:0}var G=class n{values={};decls=new Map;subscribers=new Set;auditors=new Set;norm;constructor(e=[],t){this.norm=t?.normalise??(r=>r.toLowerCase()),this.seed(e)}seed(e){for(let t of e){let r=this.norm(t.name);this.decls.set(r,t),this.values[r]=structuredClone(t.default??Y(t))}}get(e){return this.values[this.norm(e)]}set(e,t,r){let s=this.norm(e);if(this.decls.get(s)?.writable===!1)throw new Error(`\'${e}\' is read-only`);let i={name:s,prev:this.values[s],next:t,silent:r?.silent??!1,reason:r?.reason};this.values[s]=t;for(let o of this.auditors)o(i);if(!i.silent)for(let o of this.subscribers)o(i);return i}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}onAudit(e){return this.auditors.add(e),()=>this.auditors.delete(e)}rows(){return[...this.decls.entries()].map(([e,t])=>X(t,this.get(e),void 0,e))}declarations(){return[...this.decls.values()]}clone(){let e=new n([],{normalise:this.norm});return e.decls=new Map(this.decls),Object.assign(e.values,structuredClone(this.values)),e}reseed(e){for(let t of Object.keys(this.values))delete this.values[t];this.decls.clear(),this.seed(e)}save(){return structuredClone(this.values)}load(e){for(let[t,r]of Object.entries(e))this.values[this.norm(t)]=r}};function X(n,e,t,r){return{name:r??n.name.toLowerCase(),type:n.type,value:e,default:n.default??Y(n),...n.values!==void 0?{values:n.values}:{},writable:t??n.writable??!0}}var F=1,x=class{scopes=new Map;defineOwned(e,t){return this.mountOwned(e,new G(t))}mountOwned(e,t){return this.assertFree(e),this.scopes.set(e,{kind:"owned",bag:t}),this}ownedBag(e){let t=this.scopes.get(e);if(!t||t.kind!=="owned")throw new Error(`\'@${e}\' is not an owned scope`);return t.bag}reseedOwned(e,t){return this.ownedBag(e).reseed(t),this}defineForeign(e,t,r=[],s=!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:s}),this}has(e){return this.scopes.has(e)}get(e,t){let r=this.scopes.get(e);if(r)return r.kind==="owned"?r.bag.get(t):r.resolver.get(t.toLowerCase())}set(e,t,r){let s=this.scopes.get(e);if(!s)throw new Error(`unknown scope \'@${e}\'`);if(s.kind==="owned"){try{s.bag.set(t,r)}catch{throw new Error(`\'@${e}.${t}\' is read-only`)}return}let i=t.toLowerCase();if(!this.foreignWritable(s,i))throw new Error(`\'@${e}.${t}\' is read-only`);s.resolver.set(i,r)}foreignWritable(e,t){return e.resolver.set?e.decls.get(t)?.writable??e.scopeWritable:!1}listProperties(){let e=[];for(let[t,r]of this.scopes)if(r.kind==="owned")for(let s of r.bag.rows())e.push({scope:t,...s});else for(let s of r.decls.values())e.push({scope:t,...X(s,r.resolver.get(s.name.toLowerCase()),this.foreignWritable(r,s.name.toLowerCase()))});return e}toEvalContext(e){let t={};for(let[r,s]of this.scopes)t[r]=s.kind==="owned"?s.bag.values:s.resolver;return{scopes:t,host:e}}toSchema(){let e=new Map;for(let[t,r]of this.scopes){let s=r.kind==="owned"?r.bag.declarations():[...r.decls.values()];if(s.length===0)continue;let i=new Map;for(let o of s)i.set(o.name.toLowerCase(),{type:o.type,enumValues:o.values});e.set(t,i)}return{properties:e}}save(){let e={};for(let[t,r]of this.scopes)r.kind==="owned"&&(e[t]=r.bag.save());return e}load(e){for(let[t,r]of Object.entries(e)){let s=this.scopes.get(t);s?.kind==="owned"&&s.bag.load(r)}}saveFragment(){return{version:F,scopes:this.save()}}loadFragment(e){if(e.version!==F)throw new Error(`unsupported owned-state fragment version ${e.version} (supported: ${F})`);this.load(e.scopes)}assertFree(e){if(this.scopes.has(e))throw new Error(`scope \'@${e}\' is already registered`)}};function Y(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"enum":return n.values?.[0]??"";case"flags":return[]}}function k(n){return n.ctx.host??{}}var T={defaultScope:"patter",scopes:[{token:"patter"},{token:"scene"}],functions:{random:{minArgs:2,maxArgs:2,returnType:"number",eval(n,e){if(n.length!==2)throw new p("random(a, b) requires exactly 2 arguments");let t=k(e).nextRandom;if(!t)throw new p("random() called without a PRNG in context");let r=e.evaluate(n[0]),s=e.evaluate(n[1]);if(typeof r!="number"||typeof s!="number")throw new p("random(a, b) arguments must be numbers");if(!Number.isInteger(r)||!Number.isInteger(s))throw new p("random(a, b) arguments must be integers");let i=Math.min(r,s),o=Math.max(r,s);return Math.floor(t()*(o-i+1))+i}},check_flags:{minArgs:1,returnType:"boolean",flagDeltaArgs:!0,validate:Q("check_flags"),eval(n,e){let t=Z(n[0],e,"check_flags");for(let r=1;r<n.length;r++){let s=n[r];if(s.kind!=="flagdelta")throw new p("check_flags() flag args must be +flagName or -flagName");if(s.sign==="+"?!t.includes(s.name):t.includes(s.name))return!1}return!0}},set_flags:{minArgs:1,returnType:"flags",flagDeltaArgs:!0,validate:Q("set_flags"),eval(n,e){let t=[...Z(n[0],e,"set_flags")];for(let r=1;r<n.length;r++){let s=n[r];if(s.kind!=="flagdelta")throw new p("set_flags() flag args must be +flagName or -flagName");if(s.sign==="+")t.includes(s.name)||t.push(s.name);else{let i=t.indexOf(s.name);i>=0&&t.splice(i,1)}}return t}},visits:{minArgs:1,maxArgs:1,returnType:"number",validate:I("visits"),eval:(n,e)=>k(e).visits?.(D(n,e,"visits"))??0},seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:I("seen"),eval:(n,e)=>(k(e).visits?.(D(n,e,"seen"))??0)>0},patter_visits:{minArgs:1,maxArgs:1,returnType:"number",validate:I("patter_visits"),eval:(n,e)=>k(e).patterVisits?.(D(n,e,"patter_visits"))??0},patter_seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:I("patter_seen"),eval:(n,e)=>(k(e).patterVisits?.(D(n,e,"patter_seen"))??0)>0}}};function V(n,e){let t=n.replace(/^@/,"").split(".");return t.length===2&&e(t[0])?{scope:t[0],name:t[1].toLowerCase()}:{scope:"patter",name:t.join(".").toLowerCase()}}function D(n,e,t){let r=e.evaluate(n[0]);if(typeof r!="string")throw new p(`${t}(id) requires a string node id`);return r}function I(n){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:`${n}(id): the argument must be a string id literal (a scene / block / node id)`})}}function Z(n,e,t){if(!n)throw new p(`${t}() requires at least one argument (the flags variable)`);let r=e.evaluate(n);if(Array.isArray(r))return r;if(r===!1||r===null||r===void 0)return[];throw new p(`${t}() first argument must be a flags property`)}function Q(n){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:`${n}(): first argument must be a flags property reference (@name or @scope.name)`});return}let s=t.schema.properties.get(r.scope)?.get(r.name);if(s&&s.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:`${n}(): \'@${i}\' is not a flags property (got ${s.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:`${n}(): argument ${i+1} must be +flagName or -flagName`}):s?.type==="flags"&&s.enumValues&&!s.enumValues.includes(o.name)&&t.report({path:[...t.path,"args",i],kind:"unknown-flag-name",severity:"error",message:`${n}(): unknown flag \'${o.name}\'`,reference:o.name})}}}var ke=/^@[A-Za-z0-9_.]+$/;function*Ce(n){let e="",t=0;for(;t<n.length;){let r=n[t];if(r==="{"&&n[t+1]==="{"){e+="{",t+=2;continue}if(r==="}"&&n[t+1]==="}"){e+="}",t+=2;continue}if(r==="{"){let s=n.indexOf("}",t+1);if(s!==-1){let i=n.slice(t,s+1),o=n.slice(t+1,s).trim();if(o.startsWith("@")){e&&(yield{kind:"text",value:e},e=""),yield{kind:"slot",raw:i,inner:o,ref:ke.test(o)?o:void 0},t=s+1;continue}e+=i,t=s+1;continue}}e+=r,t+=1}e&&(yield{kind:"text",value:e})}function Ee(n){return Array.isArray(n)?n.join(", "):typeof n=="boolean"?n?"true":"false":String(n)}function De(n){return n===" "||n===" "||n===`\n`||n==="\\r"||n==="\\f"||n==="\\v"}function Ie(n){let e="",t=!1;for(let r of n){if(De(r)){t=!0;continue}t&&e.length>0&&(e+=" "),t=!1,e+=r}return e}function ee(n,e,t){if(e.length===0||n.indexOf(e)<0)return n;let r="",s=0,i=!1;for(;s<n.length;){if(n.startsWith(e,s)){let o=n.indexOf(t,s+e.length);if(o>=0){s=o+t.length,i=!0;continue}r+=n.slice(s);break}r+=n[s],s+=1}return i?Ie(r):n}function te(n,e){if(n.indexOf("{")<0)return n;let t="";for(let r of Ce(n)){if(r.kind==="text"){t+=r.value;continue}if(!r.ref){t+=r.raw;continue}let s=e(r.ref);t+=s===void 0?"":Ee(s)}return t}function b(n,e){for(let t of n){e(t);let r=t.children;r&&b(r,e)}}function Re(n){return n.toLowerCase().replace(/[\'\u2019]/g,"").replace(/[^a-z0-9-]+/g,"-").replace(/-+/g,"-").replace(/^-+|-+$/g,"")}function g(n){let e=n.gameId?.trim();return e||Re(n.name)}function M(n){return`cast:${n}`}var j={open:"[",close:"]"},re="SFX";function R(n){let e=new Set,t=[];for(let r of n)e.has(r)||(e.add(r),t.push(r));return t}function B(n){let e=new Map,t=(r,s)=>{let i=R([...s,...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,R([...i,...o.tags??[]]))};for(let r of Object.values(n.scenes)){let s=R(r.tags??[]);e.set(r.id,s);for(let i of r.blocks){let o=R([...s,...i.tags??[]]);e.set(i.id,o);for(let a of i.children)t(a,o)}}return e}var ne=new WeakMap,O=class n{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,s=e.strings;this.allStrings=s,this.currentLocale=r;let i=s[r]??{},o=s[e.locales.default]??{},a=e.localisation,c=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 l=new Map;for(let d of e.cast??[])d.displayName&&l.set(d.name,d.displayName);this.defaultSeed=(t.seed??2654435769)>>>0;let u=new Map,f=new Map,H=new Map;for(let[d,m]of Object.entries(e.scenes)){this.sceneGameIdToId.set(g(m),d);let v=new Map;for(let h of m.blocks)f.set(h.id,{sceneId:d}),H.set(h.id,h),v.set(g(h),h.id),b(h.children,K=>u.set(K.id,K));this.blockGameIdToId.set(d,v)}let q=e.properties??[],P=q.filter(d=>d.shared??!0).map(ie),ge=q.filter(d=>!(d.shared??!0)).map(ie),fe=new Set(P.map(d=>d.name.toLowerCase())),N=new x().defineOwned("patter",P),U=new Set;if(t.world){let d=e.scopeRegistry?.scopes.find(v=>v.token==="world"),m=(d?.declarations??[]).map(oe);N.defineForeign("world",t.world,m,d?.writable??!0),U.add("world")}for(let d of e.scopeRegistry?.scopes??[]){if(U.has(d.token))continue;let m=(d.declarations??[]).map(oe);N.defineForeign(d.token,Pe(d.declarations??[]),m,d.writable??!0)}let W=new Map;for(let[d,m]of Object.entries(e.scenes)){let v=new Set((m.sceneProps??[]).filter(h=>h.shared??!1).map(h=>h.name.toLowerCase()));W.set(d,v)}this.host={bundle:e,emitIds:c,strings:i,defaultStrings:o,castDisplay:l,nodeIndex:u,blockIndex:f,blockById:H,sceneGameIdToId:this.sceneGameIdToId,blockGameIdToId:this.blockGameIdToId,tagIndex:B(e),shared:N,patterSharedDecls:P,patterLocalDecls:ge,patterSharedNames:fe,sceneSharedNames:W,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??j).open,captionClose:(e.closedCaptions??j).close,captionCharacter:e.closedCaptions?.character||re,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),s=new n(e,this.creationOptions);try{return s.loadGame(t),r(s)}catch{let i=new n(e,this.creationOptions);for(let[o,a]of Object.entries(t.flows)){let c=a.cursor.currentSceneId;try{i.openFlow(o,c!==null?{scene:c}:{})}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),s=this.resolveBlockRef(r,t.block);this.flowsById.get(e)?.close();let i=new w(e,this.host,t.seed??this.defaultSeed);return this.flowsById.set(e,i),i.start(r,s),i}runFlow(e,t,r){let s=this.flowsById.get(e);if(!s)return this.openFlow(e,{scene:t,block:r}).advanceToStop().played;if(!s.goto(t,r))throw new Error(`runFlow: address not found: ${t}${r===void 0?"":` / ${r}`}`);return s.advanceToStop().played}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?g(t):void 0}blockAddress(e){let t=this.host.blockById.get(e);return t?g(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),s=this.resolveBlockRef(r,t);return(s!=null?this.host.tagIndex.get(s):void 0)??[]}getCast(){let e=[];for(let t of this.host.bundle.cast??[])t?.name&&e.push(t.name);return e}castForScene(e){let t=this.resolveSceneRef(e),r=t!=null?this.host.bundle.scenes[t]:void 0;if(!r)return[];let s=new Set;for(let i of r.blocks)se(i.children,s);return[...s]}castForBlock(e,t){let r=this.resolveSceneRef(e),s=this.resolveBlockRef(r,t),i=s!=null?this.host.blockById.get(s):void 0;if(!i)return[];let o=new Set;return se(i.children,o),[...o]}getOutline(){return Object.values(this.host.bundle.scenes).map(e=>({id:e.id,...g(e)?{gameId:g(e)}:{},name:e.name,...this.tagsField(e.id),blocks:e.blocks.map(t=>({id:t.id,...g(t)?{gameId:g(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)b(r.children,s=>{if(s.type==="snippet")for(let i of s.beats??[])e.push({sceneId:t.id,blockId:r.id,snippetId:s.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 s=this.host.defaultStrings[M(e.character)]??this.host.castDisplay.get(e.character);s!==void 0&&(r.characterName=s)}e.direction!==void 0&&(r.direction=e.direction)}if(e.kind==="line"||e.kind==="text"){let s=this.host.defaultStrings[e.id];s!==void 0&&(r.text=s)}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.get(e)?.close(),this.flowsById.delete(e)}reset(){for(let e of this.flowsById.values())e.close();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:s}=this.splitShared(e);this.host.shared.set(r,s,t)}listProperties(){return this.host.patterSharedDecls.map(e=>({ref:`@${e.name}`,type:e.type,values:e.values,value:this.getProperty(`@${e.name}`),default:Be(e)}))}splitShared(e){let t=this.host.refSplitCache.get(e);if(t||(t=V(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:ce(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 le(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 s=new w(t,this.host,this.defaultSeed);s.restore(r),this.flowsById.set(t,s)}}},w=class{id;host;local;rngState;started=!1;flowEnded=!1;closed=!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 s=this.host.sceneSharedNames.get(r)?.has(e)?this.host.stageBags.get(r):this.sceneBags.get(r);s&&(s[e]=t)}};evalCtx;constructor(e,t,r){this.id=e,this.host=t,this.rngState=r>>>0,this.local=this.freshLocal();let s={...t.shared.toEvalContext().scopes};s.patter=this.patterResolver,s.scene=this.sceneResolver,this.evalCtx={scopes:s,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],s=r?this.host.bundle.scenes[r]:void 0;if(!s)throw new Error(r?`unknown scene: ${r}`:"no scenes in bundle");this.enterSceneSetup(r);let i=s.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)}goto(e,t){if(this.closed)return!1;if(e==="END")return this.started=!0,this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null,this.activeSnippet=null,this.beatIndex=0,this.flowEnded=!0,this.stack=[],!0;let r=this.host.sceneGameIdToId.get(e)??(this.host.bundle.scenes[e]?e:void 0);if(r===void 0)return!1;let s;return t!==void 0&&(s=this.host.blockGameIdToId.get(r)?.get(t)??(this.host.blockIndex.get(t)?.sceneId===r?t:void 0),s===void 0)?!1:this.started?(this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null,this.activeSnippet=null,this.beatIndex=0,this.flowEnded=!1,this.enterTarget(s??r,"jump"),this.settle(),!0):(this.start(r,s),!0)}close(){this.closed=!0,this.flowEnded=!0,this.stack=[],this.activeSnippet=null,this.beatIndex=0,this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null}get isClosed(){return this.closed}get currentScene(){return this.currentSceneId}advance(){if(this.closed)return{type:"end"};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 s=this.activeSnippet.jump;this.activeSnippet=null,this.beatIndex=0,this.resolveJump(s);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 s=t.byId.get(e);this.pendingChoice=null,this.pendingPromptBeat=this.host.replayPromptOnChoose?this.promptBeatOf(s)??null:null,this.pendingPromptOwnerId=this.pendingPromptBeat?s.id:null,this.enterChild(s)}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:s}=this.splitRef(e);if(r==="patter")this.patterResolver.set(s,t);else if(r==="scene"){if(this.currentSceneId===null)throw new Error(`\'${e}\': the flow has not entered a scene yet`);this.sceneResolver.set(s,t)}else this.host.shared.set(r,s,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:ce(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:s,...i}=r;if(s!==void 0){let o=this.childrenOf(i.containerId)?.findIndex(a=>a.id===s)??-1;if(o>=0)return{...i,index:o}}return{...i}}),this.sceneBags=new Map(Object.entries(e.sceneBags??{}).map(([r,s])=>[r,{...s}])),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=le(t.selectors),this.pendingChoice=null,t.pendingChoice!==null){let r=new Map,s=[];for(let i of t.pendingChoice.options){let o=this.host.nodeIndex.get(i.id);o&&(r.set(i.id,o),s.push({...i}))}s.length>0&&(this.pendingChoice={groupId:t.pendingChoice.groupId,options:s,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,s=[];for(let o of e.children){if(o.fallback===!0){s.push(o);continue}if(o.sticky!==!0&&(this.visitCounts.get(o.id)??0)>=1)continue;let a=this.eligible(o),c=o.secretUntilEligible===!0;!a&&c||(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=s.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,s,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,s=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,s=e}this.enter(s);let o={sceneId:r,containerId:s,index:0};t==="call"?this.stack.push(o):this.stack=[o]}selectChild(e){let t=e.children.filter(s=>this.eligible(s));if(t.length===0)return null;let r=this.selectorState(e);switch(e.selector){case"branch":return t[0];case"sequence":{let s=e.options?.order??"sequential",i=e.options?.exhaust??"once";return s==="shuffle"?this.pickShuffle(t,i,r):s==="specificity"?this.pickSpecificity(t,i,r):this.pickSequential(t,i,r)}default:return null}}pickSequential(e,t,r){let s=e.length,i=r.seq??0;return r.seq=i+1,t==="repeat"?e[i%s]:i<s?e[i]:t==="stick"?e[s-1]:null}pickShuffle(e,t,r){let s=e.length,i=t==="stick",o=()=>(i?e.slice(0,s-1):e).map(f=>f.id);if(r.bag===void 0&&(r.bag=o()),r.bag.length===0){if(t==="once")return null;if(i){let f=e[s-1];return r.last=f.id,f}r.bag=o()}let a=r.bag,c=r.last!==void 0&&a.length>1?a.indexOf(r.last):-1,l=Math.floor(this.rng()*(c>=0?a.length-1:a.length));c>=0&&l>=c&&l++;let u=a[l];return a.splice(l,1),r.last=u,e.find(f=>f.id===u)}pickSpecificity(e,t,r){let s=e;if(t!=="repeat"){r.bag===void 0&&(r.bag=e.map(u=>u.id));let l=new Set(r.bag);if(s=e.filter(u=>l.has(u.id)),s.length===0)return t==="stick"&&r.last!==void 0?e.find(u=>u.id===r.last)??null:null}let i=-1,a=s.map(l=>{let u=this.specScore(l);return u>i&&(i=u),{c:l,s:u}}).filter(l=>l.s===i).map(l=>l.c),c;if(a.length===1)c=a[0];else{let l=r.last!==void 0?a.findIndex(f=>f.id===r.last):-1,u=Math.floor(this.rng()*(l>=0?a.length-1:a.length));l>=0&&u>=l&&u++,c=a[u]}return t!=="repeat"&&(r.bag=r.bag.filter(l=>l!==c.id)),r.last=c.id,c}specScore(e){return e.condition?this.matchedSpec(this.conditionAst(e.condition),!0):0}matchedSpec(e,t){return z(e,s=>ae(C(s,this.evalCtx,T)),{want:t})}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?ae(this.evalExpr(e.condition)):!0}evalExpr(e){return C(this.conditionAst(e),this.evalCtx,T)}conditionAst(e){let t=ne.get(e);return t||(t=S(e.ast),ne.set(e,t)),t}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 s=this.resolveString(e.id),i=!this.host.captionsOn,a=i&&e.character===this.host.captionCharacter?"":this.captionLine(this.host.bundle.voiced?s:this.interpolate(s)),c=i&&a.length===0;return{type:"line",id:e.id,text:a,character:c?void 0:e.character,characterName:c?void 0:this.resolveCharacterName(e.character),direction:c?void 0:e.direction,gameData:e.gameData,...r}}}}interpolate(e){return te(e,t=>this.getProperty(t))}stripCaptions(e){return ee(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 b(e,r=>{!t&&r.type==="snippet"&&(r.beats??[]).some(s=>s.kind==="line"||s.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=M(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=V(e,r=>r==="scene"||this.host.shared.has(r)),this.host.refSplitCache.set(e,t)),t}freshLocal(){return new x().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 s of e.sceneProps??[]){let i=s.name.toLowerCase();t.has(i)||(r[i]=L(s))}this.sceneBags.set(e.id,r)}if(!this.host.stageBags.has(e.id)){let r={};for(let s of e.sceneProps??[]){let i=s.name.toLowerCase();t.has(i)&&(r[i]=L(s))}this.host.stageBags.set(e.id,r)}for(let r of e.sceneProps??[]){if(!r.temporary)continue;let s=r.name.toLowerCase(),i=t.has(s)?this.host.stageBags.get(e.id):this.sceneBags.get(e.id);i&&(i[s]=L(r))}}};function se(n,e){b(n,t=>{if(t.type==="group"){t.prompt?.kind==="line"&&t.prompt.character&&e.add(t.prompt.character);return}for(let r of t.beats??[])r.kind==="line"&&r.character&&e.add(r.character)})}function ce(n){let e={};for(let[t,r]of n){let s={};r.seq!==void 0&&(s.seq=r.seq),r.bag&&(s.bag=[...r.bag]),r.last!==void 0&&(s.last=r.last),e[t]=s}return e}function le(n){let e=new Map;for(let[t,r]of Object.entries(n??{})){let s={};r.seq!==void 0&&(s.seq=r.seq),r.bag&&(s.bag=[...r.bag]),r.last!==void 0&&(s.last=r.last),e.set(t,s)}return e}function ie(n){return{name:n.name,type:n.type,values:n.values,default:n.default}}function Be(n){if(n.default!==void 0)return n.default;switch(n.type){case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??"";default:return!1}}function oe(n){return{name:n.name,type:n.type,values:n.values,default:n.default,writable:n.writable}}function Oe(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??""}}function Pe(n){let e=r=>r.toLowerCase(),t=new Map;for(let r of n)t.set(e(r.name),Oe(r));return{get:r=>t.get(e(r)),set:(r,s)=>{t.set(e(r),s)}}}function L(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??""}}function ae(n){return typeof n=="boolean"?n:typeof n=="number"?n!==0:typeof n=="string"?n!=="":n.length>0}var Ne=(n,e)=>n.shared??e;function $(n,e){return{name:n.name,type:n.type,hasDefault:n.default!==void 0,...n.default!==void 0?{default:n.default}:{},shared:Ne(n,e)}}function Ae(n){return{name:n.name,type:n.type,hasDefault:n.default!==void 0,...n.values?{values:[...n.values]}:{},...n.purpose?{purpose:n.purpose}:{}}}function Fe(n,e){e.blocks++;let t=[...n.children];for(;t.length;){let r=t.pop();if(r.type==="group"){e.groups++,r.prompt&&e.prompts++,t.push(...r.children);continue}e.snippets++;for(let s of r.beats??[])e.beats++,s.kind==="gameEvent"&&e.gameEvents++}}function pe(n){let e={scenes:0,blocks:0,groups:0,snippets:0,beats:0,prompts:0,gameEvents:0,cast:n.cast?.length??0},t=[],r=[];for(let o of Object.values(n.scenes)){e.scenes++;let a=g(o);t.push({gameId:a,name:o.name,blocks:o.blocks.map(c=>({gameId:g(c),name:c.name}))});for(let c of o.blocks)Fe(c,e);o.sceneProps?.length&&r.push({gameId:a,properties:o.sceneProps.map(c=>$(c,!1))})}let s=(n.scopeRegistry?.scopes??[]).map(o=>({token:o.token,writable:o.writable??!0,opaque:o.declarations===void 0,properties:(o.declarations??[]).map(a=>$(a,!0))})),i=Object.entries(n.gameDataFields??{}).filter(([,o])=>(o?.length??0)>0).map(([o,a])=>({kind:o,fields:(a??[]).map(Ae)}));return{identity:{schema:n.schema,project:n.content.project,...n.content.version!==void 0?{version:n.content.version}:{},...n.content.hash!==void 0?{hash:n.content.hash}:{},...n.content.structureHash!==void 0?{structureHash:n.content.structureHash}:{},voiced:n.voiced,defaultLocale:n.locales.default,locales:[...n.locales.included],localisation:n.localisation?.mode??"embedded",sourceDebug:n.localisation?.sourceDebug??!1},addresses:t,hostScopes:s,properties:{patter:(n.properties??[]).map(o=>$(o,!0)),scene:r},gameData:i,counts:e}}function de(n,e){return n.gameDataFields?.[e]??[]}function _(n,e,t){return e&&Object.prototype.hasOwnProperty.call(e,t)?e[t]:n.find(r=>r.name===t)?.default}function ue(n,e){let t={};for(let r of n){let s=_(n,e,r.name);s!==void 0&&(t[r.name]=s)}for(let[r,s]of Object.entries(e??{}))r in t||(t[r]=s);return t}return be(Ge);})();\n//# sourceMappingURL=patterplay.min.js.map';
357
-
358
- // src/export-html.ts
359
- var esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
360
- var PLAYER_JS = String.raw`
361
- (function () {
362
- var Engine = window.Patterplay.Engine;
363
- var BUNDLE = window.PATTER_BUNDLE;
364
- var startScene = (BUNDLE.start && BUNDLE.start.scene) || Object.keys(BUNDLE.scenes)[0];
365
- var SAVE_KEY = "patter.play." + ((BUNDLE.content && BUNDLE.content.hash) || "x");
366
-
367
- var stage = document.getElementById("stage");
368
- var controls = document.getElementById("controls");
369
- var engine, flow;
370
-
371
- // Stable per-character hue (same idea as the editor's colour-by-character), tinted for a light page.
372
- function hueOf(name) { var h = 0; for (var i = 0; i < (name || "").length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; return h; }
373
- function add(cls, html) { var d = document.createElement("div"); d.className = cls; d.innerHTML = html; stage.appendChild(d); return d; }
374
- function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); }
375
-
376
- // Pacing: a source-only reader carries no audio, so each line is held on screen for a reading-length delay
377
- // (proportional to its word count) before the next appears, and every line fades in (see CSS). The Speed
378
- // control sets the reading pace (words/min); "Instant" drops the delay entirely. The choice is persisted.
379
- // A run token cancels an in-flight paced read the moment the player restarts, loads, or picks a choice.
380
- var SPEED_WPM = { slow: 300, normal: 500, fast: 800, instant: 0 }, runToken = 0;
381
- var speedKey = (function () { try { return localStorage.getItem("patter.playSpeed") || "normal"; } catch (e) { return "normal"; } })();
382
- if (SPEED_WPM[speedKey] === undefined) speedKey = "normal";
383
- function fakeDuration(text) {
384
- var wpm = SPEED_WPM[speedKey];
385
- if (!wpm) return 0; // "Instant" - reveal the next line immediately
386
- var words = String(text == null ? "" : text).trim().split(/\s+/).filter(Boolean).length;
387
- return Math.max(450, Math.round((words / wpm) * 60000)); // a beat needs to land
438
+ var CHILD_KEYS = ["blocks", "children", "beats"];
439
+ function toTree(raw) {
440
+ const childKey = CHILD_KEYS.find((k) => Array.isArray(raw[k]));
441
+ const fields = {};
442
+ for (const [k, v] of Object.entries(raw)) if (k !== childKey) fields[k] = v;
443
+ const children = childKey ? raw[childKey].filter(isObj).map(toTree) : [];
444
+ return { id: typeof raw.id === "string" ? raw.id : "", fields, childKey, children };
445
+ }
446
+ function fromTree(n) {
447
+ const out = { ...n.fields };
448
+ if (n.childKey) out[n.childKey] = n.children.map(fromTree);
449
+ return out;
450
+ }
451
+ function mergeFlow(base, ours, theirs) {
452
+ const conflicts = [];
453
+ const warnings = [];
454
+ const merged = {};
455
+ merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
456
+ const bScene = isObj(base.scene) ? toTree(base.scene) : void 0;
457
+ const oScene = toTree(asMap(ours.scene));
458
+ const tScene = isObj(theirs.scene) ? toTree(theirs.scene) : void 0;
459
+ const mergedScene = mergeNode(bScene, oScene, tScene, "scene", conflicts, warnings);
460
+ merged.scene = fromTree(mergedScene);
461
+ checkDuplicateIds(mergedScene, conflicts);
462
+ return { type: "flow", merged, conflicts, warnings };
463
+ }
464
+ function mergeNode(b, o, t, path, conflicts, warnings) {
465
+ const fields = mergeFields(b?.fields ?? {}, o.fields, t?.fields ?? {}, o.id, path, conflicts);
466
+ const childKey = o.childKey ?? t?.childKey ?? b?.childKey;
467
+ const children = childKey ? mergeChildren(b?.children ?? [], o.children, t?.children ?? [], `${path}.${childKey}`, conflicts, warnings) : [];
468
+ return { id: o.id, fields, childKey, children };
469
+ }
470
+ function mergeFields(b, o, t, id, path, conflicts) {
471
+ const out = {};
472
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
473
+ const v = merge3(b[k], o[k], t[k], id, `${path}.${k}`, conflicts);
474
+ if (v !== void 0) out[k] = v;
388
475
  }
389
- function scrollDown() { window.scrollTo(0, document.body.scrollHeight); }
390
- var speedSel = document.getElementById("speed");
391
- if (speedSel) {
392
- speedSel.value = speedKey; // a fresh fakeDuration() reads speedKey each line, so a change applies next line
393
- speedSel.onchange = function () { speedKey = speedSel.value; try { localStorage.setItem("patter.playSpeed", speedKey); } catch (e) {} };
476
+ return out;
477
+ }
478
+ function mergeChildren(B, O, T, path, conflicts, warnings) {
479
+ const Bm = idMap(B), Om = idMap(O), Tm = idMap(T);
480
+ const Bset = new Set(Bm.keys()), Oset = new Set(Om.keys()), Tset = new Set(Tm.keys());
481
+ const merged = /* @__PURE__ */ new Map();
482
+ const here = (id) => `${path}[${id}]`;
483
+ for (const id of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
484
+ const b = Bm.get(id), o = Om.get(id), t = Tm.get(id);
485
+ const inB = Bset.has(id), inO = Oset.has(id), inT = Tset.has(id);
486
+ if (inO && inT) {
487
+ if (!inB && !eq(fromTree(o), fromTree(t))) {
488
+ conflicts.push({ id, path: here(id), base: void 0, ours: fromTree(o), theirs: fromTree(t), kind: "added-both" });
489
+ merged.set(id, o);
490
+ } else {
491
+ merged.set(id, mergeNode(b, o, t, here(id), conflicts, warnings));
492
+ }
493
+ } else if (inO && !inT) {
494
+ if (inB && !eq(fromTree(o), fromTree(b))) {
495
+ conflicts.push({ id, path: here(id), base: fromTree(b), ours: fromTree(o), theirs: void 0, kind: "delete-vs-edit" });
496
+ merged.set(id, o);
497
+ } else if (!inB) {
498
+ merged.set(id, o);
499
+ }
500
+ } else if (!inO && inT) {
501
+ if (inB && !eq(fromTree(t), fromTree(b))) {
502
+ conflicts.push({ id, path: here(id), base: fromTree(b), ours: void 0, theirs: fromTree(t), kind: "delete-vs-edit" });
503
+ } else if (!inB) {
504
+ merged.set(id, t);
505
+ }
506
+ }
394
507
  }
395
-
396
- function build() { engine = new Engine(BUNDLE); flow = engine.openFlow("main", { scene: startScene }); }
397
-
398
- function newGame() { stage.innerHTML = ""; controls.innerHTML = ""; build(); run(); }
399
-
400
- function run() {
401
- controls.innerHTML = "";
402
- var token = ++runToken;
403
- (function step() {
404
- if (token !== runToken) return; // superseded by a restart / load / choice pick
405
- var s = flow.advance();
406
- if (!s) { add("end", "The End"); return; } // defensive: a step should always be returned; never hard-crash
407
- if (s.type === "line") {
408
- var who = s.characterName || s.character || "";
409
- var dir = s.direction ? '<em class="dir">(' + esc(s.direction) + ')</em> ' : "";
410
- add("line", '<span class="who" style="color:hsl(' + hueOf(s.character || who) + ',55%,38%)">' + esc(who) + '</span>' + dir + esc(s.text));
411
- scrollDown(); setTimeout(step, fakeDuration(s.text));
412
- } else if (s.type === "text") {
413
- add("text", esc(s.text)); scrollDown(); setTimeout(step, fakeDuration(s.text));
414
- } else if (s.type === "choice") { renderChoice(s); }
415
- else if (s.type === "end") { add("end", "The End"); }
416
- else { step(); } // game-event beat: a host cue with no player-facing text - skip it, advance at once
417
- })();
508
+ return orderChildren(merged, B, O, T, path, conflicts, warnings);
509
+ }
510
+ function orderChildren(merged, B, O, T, path, conflicts, warnings) {
511
+ const Bids = B.map((n) => n.id), Oids = O.map((n) => n.id), Tids = T.map((n) => n.id);
512
+ const Oidset = new Set(Oids);
513
+ const common = new Set(Bids.filter((id) => merged.has(id)));
514
+ const restrict = (ids) => ids.filter((id) => common.has(id));
515
+ const relB = restrict(Bids), relO = restrict(Oids), relT = restrict(Tids);
516
+ const fullO = relO.length === common.size, fullT = relT.length === common.size;
517
+ let commonOrder;
518
+ if (!fullO && !fullT) commonOrder = relB;
519
+ else if (!fullO) commonOrder = relT;
520
+ else if (!fullT) commonOrder = relO;
521
+ else if (arrEq(relO, relB)) commonOrder = relT;
522
+ else if (arrEq(relT, relB)) commonOrder = relO;
523
+ else if (arrEq(relO, relT)) commonOrder = relO;
524
+ else {
525
+ conflicts.push({ id: "", path, base: relB, ours: relO, theirs: relT, kind: "moved" });
526
+ commonOrder = relO;
418
527
  }
419
-
420
- function renderChoice(step) {
421
- var opts = step.options || [];
422
- if (!opts.length) { add("end", "The End"); return; }
423
- for (var i = 0; i < opts.length; i++) (function (opt, idx) {
424
- var b = document.createElement("button");
425
- b.className = "choice"; b.textContent = (opt.prompt && opt.prompt.text) || "(continue)";
426
- b.disabled = opt.eligible === false;
427
- b.style.animationDelay = (idx * 60) + "ms"; // gentle stagger so the options fade in one after another
428
- b.onclick = function () { flow.choose(opt.id); scrollDown(); run(); };
429
- controls.appendChild(b);
430
- })(opts[i], i);
431
- scrollDown();
528
+ const anchorIn = (ids, id) => {
529
+ for (let i = ids.indexOf(id) - 1; i >= 0; i--) if (common.has(ids[i])) return ids[i];
530
+ return null;
531
+ };
532
+ const byAnchor = /* @__PURE__ */ new Map();
533
+ const group = (a) => {
534
+ let g = byAnchor.get(a);
535
+ if (!g) {
536
+ g = { ours: [], theirs: [] };
537
+ byAnchor.set(a, g);
538
+ }
539
+ return g;
540
+ };
541
+ for (const n of O) if (merged.has(n.id) && !common.has(n.id)) group(anchorIn(Oids, n.id)).ours.push(n.id);
542
+ 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);
543
+ const out = [];
544
+ const emit = (anchor) => {
545
+ const g = byAnchor.get(anchor);
546
+ if (!g) return;
547
+ 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" });
548
+ out.push(...g.ours, ...g.theirs);
549
+ };
550
+ emit(null);
551
+ for (const id of commonOrder) {
552
+ out.push(id);
553
+ emit(id);
432
554
  }
433
-
434
- document.getElementById("restart").onclick = newGame;
435
- document.getElementById("save").onclick = function () { try { localStorage.setItem(SAVE_KEY, JSON.stringify(engine.saveGame())); flash("Saved"); } catch (e) {} };
436
- document.getElementById("load").onclick = function () {
437
- var blob; try { blob = localStorage.getItem(SAVE_KEY); } catch (e) {}
438
- if (!blob) { flash("No save yet"); return; }
439
- engine = new Engine(BUNDLE);
440
- engine.loadGame(JSON.parse(blob)); flow = engine.getFlow("main");
441
- stage.innerHTML = ""; add("text", "(resumed from save)"); run();
555
+ return out.map((id) => merged.get(id));
556
+ }
557
+ function checkDuplicateIds(root, conflicts) {
558
+ const seen = /* @__PURE__ */ new Set(), dup = /* @__PURE__ */ new Set();
559
+ const walk = (n) => {
560
+ if (n.id) {
561
+ if (seen.has(n.id)) dup.add(n.id);
562
+ else seen.add(n.id);
563
+ }
564
+ n.children.forEach(walk);
442
565
  };
443
- function flash(msg) { var f = document.getElementById("flash"); f.textContent = msg; f.style.opacity = "1"; setTimeout(function () { f.style.opacity = "0"; }, 1100); }
566
+ walk(root);
567
+ for (const id of dup) conflicts.push({ id, path: "scene", base: void 0, ours: void 0, theirs: void 0, kind: "structural" });
568
+ }
569
+ var idMap = (nodes) => new Map(nodes.filter((n) => n.id).map((n) => [n.id, n]));
570
+ var arrEq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
444
571
 
445
- newGame();
446
- })();
447
- `;
448
- var STYLE = String.raw`
449
- :root { color-scheme: light; }
450
- * { box-sizing: border-box; }
451
- body { margin: 0; background: #f6f1e7; color: #2b2620; font: 18px/1.65 Georgia, "Times New Roman", serif; }
452
- .wrap { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 6rem; }
453
- header { display: flex; align-items: baseline; gap: 0.75rem; border-bottom: 1px solid #e0d7c5; padding-bottom: 0.75rem; margin-bottom: 1.25rem; }
454
- h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
455
- header .by { color: #9a8f78; font-size: 0.8rem; font-family: ui-sans-serif, system-ui, sans-serif; margin-left: auto; }
456
- #stage > div { margin: 0.55rem 0; animation: fade 0.45s ease-out both; }
457
- @keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
458
- @media (prefers-reduced-motion: reduce) { #stage > div, .choice { animation: none; } }
459
- .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; }
460
- .line .dir { color: #9a8f78; font-style: italic; }
461
- .text { color: #4a4334; }
462
- .end { color: #9a8f78; font-style: italic; text-align: center; margin-top: 1.5rem; }
463
- #controls { margin: 1.25rem 0; display: flex; flex-direction: column; gap: 0.5rem; }
464
- .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; }
465
- .choice:hover:not(:disabled) { border-color: #b8956a; background: #fffaf0; }
466
- .choice:disabled { color: #b8af9b; cursor: default; }
467
- .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); }
468
- .bar .inner { max-width: 42rem; margin: 0 auto; padding: 0.6rem 1.25rem; display: flex; gap: 0.5rem; align-items: center; }
469
- .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; }
470
- .bar button:hover { border-color: #b8956a; }
471
- .bar .speedlabel { font: 0.8rem ui-sans-serif, system-ui, sans-serif; color: #9a8f78; display: flex; align-items: center; gap: 0.3rem; }
472
- .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; }
572
+ // src/validate.ts
573
+ import { validateProject, parseSource as parseSource2 } from "@patterkit/core";
574
+ import { validateConditions, validateInterpolation, exportBundle, hostScopesToSpec } from "@patterkit/compiler";
575
+ function runValidate(loaded) {
576
+ const { project, scenes, locales } = loaded;
577
+ const structural = validateProject({ project, scenes, authoring: loaded.authoring });
578
+ const foreignScopes = hostScopesToSpec(project.scopeRegistry);
579
+ const conditions = validateConditions({ project, scenes }, { foreignScopes });
580
+ const interpolation = validateInterpolation({ project, scenes, locales }, { foreignScopes });
581
+ const hygiene = checkHygiene([loaded.projectFile, ...Object.values(loaded.sceneFiles), ...loaded.localeFiles, ...loaded.authoringFiles]);
582
+ const staleBundles = checkBundles(loaded);
583
+ const unresolvedMerges = sidecarIssues(walkFiles(loaded.root, CONFLICT_SIDECAR));
584
+ const orphans = orphanShards(loaded);
585
+ return {
586
+ structural,
587
+ conditions,
588
+ interpolation,
589
+ hygiene,
590
+ staleBundles,
591
+ unresolvedMerges,
592
+ orphans,
593
+ ok: structural.length === 0 && conditions.length === 0 && interpolation.length === 0 && hygiene.length === 0 && staleBundles.length === 0 && unresolvedMerges.length === 0 && orphans.length === 0
594
+ };
595
+ }
596
+ function orphanShards(loaded) {
597
+ const collected = /* @__PURE__ */ new Set([
598
+ loaded.projectFile,
599
+ ...Object.values(loaded.sceneFiles),
600
+ ...loaded.localeFiles,
601
+ ...loaded.authoringFiles
602
+ ]);
603
+ const kind = {
604
+ ".patterflow": "scene",
605
+ ".patterloc": "locale",
606
+ ".patterx": "authoring",
607
+ ".patterproj": "project"
608
+ };
609
+ const out = [];
610
+ for (const [ext, what] of Object.entries(kind)) {
611
+ for (const file of walkFiles(loaded.root, ext)) {
612
+ if (collected.has(file)) continue;
613
+ out.push({ file, message: `a ${what} shard outside the project's layout - nothing loads it, so its content is not in the project (move it under the configured folder, or delete it)` });
614
+ }
615
+ }
616
+ return out;
617
+ }
618
+ function checkBundles(loaded) {
619
+ const issues = [];
620
+ const bundles = walkFiles(loaded.root, ".patterc");
621
+ if (bundles.length === 0) return issues;
622
+ let fresh;
623
+ try {
624
+ fresh = exportBundle({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales }).content.hash;
625
+ } catch {
626
+ return issues;
627
+ }
628
+ for (const file of bundles) {
629
+ let hash;
630
+ try {
631
+ const parsed = parseSource2(readFileSync2(file, "utf8"));
632
+ hash = parsed?.content?.hash;
633
+ } catch {
634
+ issues.push({ file, message: "compiled bundle is unparseable - run `patter export`" });
635
+ continue;
636
+ }
637
+ if (hash !== fresh) {
638
+ issues.push({ file, message: "compiled bundle is stale (does not match current source) - run `patter export`" });
639
+ }
640
+ }
641
+ return issues;
642
+ }
643
+ var hygieneCache = /* @__PURE__ */ new Map();
644
+ function checkHygiene(files) {
645
+ const issues = [];
646
+ for (const file of files) {
647
+ let mtimeMs;
648
+ try {
649
+ mtimeMs = statSync2(file).mtimeMs;
650
+ } catch {
651
+ continue;
652
+ }
653
+ const hit = hygieneCache.get(file);
654
+ if (hit && hit.mtimeMs === mtimeMs) {
655
+ issues.push(...hit.issues);
656
+ continue;
657
+ }
658
+ const fileIssues = [];
659
+ try {
660
+ const text = readFileSync2(file, "utf8");
661
+ if (text.charCodeAt(0) === 65279) {
662
+ fileIssues.push({ file, message: "file starts with a UTF-8 BOM (canonical form is UTF-8 without BOM - run `patter format`)" });
663
+ }
664
+ if (text.includes("\r")) {
665
+ fileIssues.push({ file, message: "file contains CRLF line endings (canonical form is LF - run `patter format`)" });
666
+ }
667
+ } catch {
668
+ continue;
669
+ }
670
+ hygieneCache.set(file, { mtimeMs, issues: fileIssues });
671
+ issues.push(...fileIssues);
672
+ }
673
+ return issues;
674
+ }
675
+
676
+ // src/export.ts
677
+ import { basename as basename2, isAbsolute, join as join2 } from "path";
678
+ import { exportBundle as exportBundle2 } from "@patterkit/compiler";
679
+ import { isContentlessBeat } from "@patterkit/model";
680
+ function compileScenes(loaded) {
681
+ const def = loaded.project.locales.default;
682
+ const written = /* @__PURE__ */ new Set();
683
+ for (const l of loaded.locales) if (l.locale === def) {
684
+ for (const [k, v] of Object.entries(l.strings)) if (v) written.add(k);
685
+ }
686
+ const clean = (n) => {
687
+ if (n.type === "group") return { ...n, children: (n.children ?? []).map(clean) };
688
+ if (!n.beats) return n;
689
+ const kept = n.beats.filter((b) => !isContentlessBeat(b, written.has(b.id)));
690
+ if (kept.length === n.beats.length) return n;
691
+ const { beats, ...rest } = n;
692
+ return kept.length ? { ...rest, beats: kept } : rest;
693
+ };
694
+ return loaded.scenes.map((s) => ({
695
+ ...s,
696
+ blocks: s.blocks.map((b) => ({ ...b, children: b.children.map(clean) }))
697
+ }));
698
+ }
699
+ function refuseUnresolvedMerge(loaded) {
700
+ const issues = sidecarIssues(walkFiles(loaded.root, CONFLICT_SIDECAR));
701
+ if (!issues.length) return;
702
+ throw new Error(
703
+ `${issues.length} unresolved merge conflict(s) - resolve them and delete the .patterconflict sidecar(s) first:
704
+ ` + issues.map((i) => ` ${i.file}`).join("\n")
705
+ );
706
+ }
707
+ function runExport(loaded) {
708
+ refuseUnresolvedMerge(loaded);
709
+ const { project, locales } = loaded;
710
+ const full = exportBundle2({ project, scenes: compileScenes(loaded), locales });
711
+ const loc = project.export?.localisation;
712
+ if (!loc || loc.mode === "embedded") return full;
713
+ const strings = loc.sourceDebug ? { [full.locales.default]: full.strings[full.locales.default] ?? {} } : {};
714
+ return { ...full, strings, localisation: { mode: "ids", ...loc.sourceDebug ? { sourceDebug: true } : {} } };
715
+ }
716
+ function runExportFull(loaded) {
717
+ refuseUnresolvedMerge(loaded);
718
+ const { project, locales } = loaded;
719
+ return exportBundle2({ project, scenes: compileScenes(loaded), locales });
720
+ }
721
+ function bundleOutputPath(loaded) {
722
+ const override = loaded.project.export?.bundle;
723
+ if (override) return isAbsolute(override) ? override : join2(loaded.root, override);
724
+ const stem = basename2(loaded.projectFile).replace(/\.patterproj$/, "");
725
+ return join2(loaded.root, "dist", `${stem}.patterc`);
726
+ }
727
+
728
+ // src/export-html.ts
729
+ import { canonicalStringify as canonicalStringify2 } from "@patterkit/core";
730
+
731
+ // src/playable-runtime.ts
732
+ var PLAYABLE_RUNTIME_JS = '"use strict";var Patterplay=(()=>{var G=Object.defineProperty;var Se=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var ve=Object.prototype.hasOwnProperty;var be=(n,e)=>{for(var t in e)G(n,t,{get:e[t],enumerable:!0})},we=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of ye(e))!ve.call(n,s)&&s!==t&&G(n,s,{get:()=>e[s],enumerable:!(r=Se(e,s))||r.enumerable});return n};var xe=n=>we(G({},"__esModule",{value:!0}),n);var Me={};be(Me,{Engine:()=>N,Flow:()=>k,buildTagIndex:()=>P,describeBundle:()=>ue,effectiveGameData:()=>fe,gameDataFields:()=>ge,gameDataValue:()=>U});function v(n){switch(n[0]){case"b":return{kind:"bool",value:n[1]};case"n":return{kind:"number",value:n[1]};case"s":return{kind:"string",value:n[1]};case"sv":return{kind:"scopedvar",scope:n[1],name:n[2]};case"u":return{kind:"unary",op:n[1],operand:v(n[2])};case"bin":return{kind:"binary",op:n[1],left:v(n[2]),right:v(n[3])};case"call":{let e=n.slice(2).map(v);return{kind:"call",name:n[1],args:e}}case"fd":return{kind:"flagdelta",sign:n[1],name:n[2]}}}var l=class extends Error{constructor(e){super(e),this.name="EvalError"}};function D(n,e,t){let r=new Map(t.scopes.map(i=>[i.token,i.missing??"false"])),s=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 l(`@${i.scope}.${i.name} is not declared on the current ${i.scope}.`);return!1}return a}case"call":{if(i.name==="advance"&&!t.functions[i.name]){let a=i.args[0];if(i.args.length!==1||a===void 0)throw new l(`advance() takes exactly 1 argument, got ${i.args.length}`);let c=T(a,e);if(c===void 0)throw new l("advance() needs a quality reference (@scope.name of a quality property)");let d=S(s(a),c,"advance");return c[Math.min(d+1,c.length-1)]}let o=t.functions[i.name];if(!o)throw new l(`unknown function \'${i.name}\'`);return o.eval(i.args,{evaluate:s,ctx:e})}case"flagdelta":throw new l("flagdelta node is only valid as an argument to a flag-delta function");case"unary":{if(i.op==="not"){let a=s(i.operand);if(typeof a!="boolean")throw new l(`\'not\' requires a boolean operand, got ${typeof a}`);return!a}let o=s(i.operand);if(typeof o!="number")throw new l(`unary \'-\' requires a numeric operand, got ${typeof o}`);return-o}case"binary":{if(i.op==="and"){let g=s(i.left);if(typeof g!="boolean")throw new l(`\'and\' requires boolean operands, left is ${typeof g}`);if(!g)return!1;let h=s(i.right);if(typeof h!="boolean")throw new l(`\'and\' requires boolean operands, right is ${typeof h}`);return h}if(i.op==="or"){let g=s(i.left);if(typeof g!="boolean")throw new l(`\'or\' requires boolean operands, left is ${typeof g}`);if(g)return!0;let h=s(i.right);if(typeof h!="boolean")throw new l(`\'or\' requires boolean operands, right is ${typeof h}`);return h}let o=s(i.left),a=s(i.right),c=T(i.left,e),d=T(i.right,e),p=c??d;if(p!==void 0){if(c&&d&&!ke(c,d)&&(i.op===">"||i.op===">="||i.op==="<"||i.op==="<="))throw new l(`\'${i.op}\' compares two different qualities, whose stage orders are unrelated`);switch(i.op){case">":return S(o,p,">")>S(a,p,">");case">=":return S(o,p,">=")>=S(a,p,">=");case"<":return S(o,p,"<")<S(a,p,"<");case"<=":return S(o,p,"<=")<=S(a,p,"<=");case"+":case"-":case"*":case"/":throw new l(`\'${i.op}\' cannot be applied to a quality - a stage is a position, not a number; use advance() to move it`);default:break}}switch(i.op){case"==":return X(o,a);case"!=":return!X(o,a);case">":return b(o,a,">"),o>a;case">=":return b(o,a,">="),o>=a;case"<":return b(o,a,"<"),o<a;case"<=":return b(o,a,"<="),o<=a;case"+":if(typeof o=="number"&&typeof a=="number"||typeof o=="string"&&typeof a=="string")return o+a;throw new l(`\'+\' requires two numbers or two strings, got ${typeof o} and ${typeof a}`);case"-":return b(o,a,"-"),o-a;case"*":return b(o,a,"*"),o*a;case"/":if(b(o,a,"/"),a===0)throw new l("division by zero");return o/a}}}};return s(n)}function X(n,e){if(Array.isArray(n)||Array.isArray(e)){if(!Array.isArray(n)||!Array.isArray(e)||n.length!==e.length)return!1;for(let t=0;t<n.length;t++)if(n[t]!==e[t])return!1;return!0}return n===e}function b(n,e,t){if(typeof n!="number"||typeof e!="number")throw new l(`\'${t}\' requires numeric operands, got ${typeof n} and ${typeof e}`)}function T(n,e){if(!(n.kind!=="scopedvar"||e.qualities===void 0))return e.qualities(n.scope,n.name)}function S(n,e,t){if(typeof n!="string")throw new l(`\'${t}\' on a quality compares stages, got ${typeof n}`);let r=e.indexOf(n);if(r<0)throw new l(`"${n}" is not a stage of this quality (stages: ${e.join(", ")})`);return r}var ke=(n,e)=>n.length===e.length&&n.every((t,r)=>t===e[r]);var Ce={name:"check_flags",count:n=>Math.max(1,n.args.length-1)},Ee=[Ce];function Y(n,e,t){let r=t?.countingCalls??Ee;return I(n,t?.want??!0,e,r)}function I(n,e,t,r){if(n.kind==="binary"&&(n.op==="and"||n.op==="or")){let s=I(n.left,e,t,r),i=I(n.right,e,t,r);return n.op==="and"===e?s>0&&i>0?s+i:0:Math.max(s,i)}if(n.kind==="unary"&&n.op==="not")return I(n.operand,!e,t,r);if(n.kind==="call"){let s=r.find(i=>i.name===n.name);if(s){let i=s.count(n),o=t(n);return e?o?i:0:o?0:1}}return t(n)===e?1:0}var M=class n{values={};decls=new Map;subscribers=new Set;auditors=new Set;norm;constructor(e=[],t){this.norm=t?.normalise??(r=>r.toLowerCase()),this.seed(e)}seed(e){for(let t of e){let r=this.norm(t.name);this.decls.set(r,t),this.values[r]=structuredClone(t.default??Q(t))}}get(e){return this.values[this.norm(e)]}set(e,t,r){let s=this.norm(e);if(this.decls.get(s)?.writable===!1)throw new Error(`\'${e}\' is read-only`);let i={name:s,prev:this.values[s],next:t,silent:r?.silent??!1,reason:r?.reason};this.values[s]=t;for(let o of this.auditors)o(i);if(!i.silent)for(let o of this.subscribers)o(i);return i}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}onAudit(e){return this.auditors.add(e),()=>this.auditors.delete(e)}rows(){return[...this.decls.entries()].map(([e,t])=>Z(t,this.get(e),void 0,e))}declarations(){return[...this.decls.values()]}clone(){let e=new n([],{normalise:this.norm});return e.decls=new Map(this.decls),Object.assign(e.values,structuredClone(this.values)),e}reseed(e){for(let t of Object.keys(this.values))delete this.values[t];this.decls.clear(),this.seed(e)}save(){return structuredClone(this.values)}load(e){for(let[t,r]of Object.entries(e))this.values[this.norm(t)]=r}};function Z(n,e,t,r){return{name:r??n.name.toLowerCase(),type:n.type,value:e,default:n.default??Q(n),...n.values!==void 0?{values:n.values}:{},writable:t??n.writable??!0}}var V=1,C=class{scopes=new Map;defineOwned(e,t){return this.mountOwned(e,new M(t))}mountOwned(e,t){return this.assertFree(e),this.scopes.set(e,{kind:"owned",bag:t}),this}ownedBag(e){let t=this.scopes.get(e);if(!t||t.kind!=="owned")throw new Error(`\'@${e}\' is not an owned scope`);return t.bag}reseedOwned(e,t){return this.ownedBag(e).reseed(t),this}defineForeign(e,t,r=[],s=!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:s}),this}has(e){return this.scopes.has(e)}get(e,t){let r=this.scopes.get(e);if(r)return r.kind==="owned"?r.bag.get(t):r.resolver.get(t.toLowerCase())}set(e,t,r){let s=this.scopes.get(e);if(!s)throw new Error(`unknown scope \'@${e}\'`);if(s.kind==="owned"){try{s.bag.set(t,r)}catch{throw new Error(`\'@${e}.${t}\' is read-only`)}return}let i=t.toLowerCase();if(!this.foreignWritable(s,i))throw new Error(`\'@${e}.${t}\' is read-only`);s.resolver.set(i,r)}foreignWritable(e,t){return e.resolver.set?e.decls.get(t)?.writable??e.scopeWritable:!1}listProperties(){let e=[];for(let[t,r]of this.scopes)if(r.kind==="owned")for(let s of r.bag.rows())e.push({scope:t,...s});else for(let s of r.decls.values())e.push({scope:t,...Z(s,r.resolver.get(s.name.toLowerCase()),this.foreignWritable(r,s.name.toLowerCase()))});return e}toEvalContext(e){let t={};for(let[s,i]of this.scopes)t[s]=i.kind==="owned"?i.bag.values:i.resolver;let r=this.qualityLadders();return r.size===0?{scopes:t,host:e}:{scopes:t,host:e,qualities:(s,i)=>r.get(s)?.get(i.toLowerCase())}}qualityLadders(){let e=new Map;for(let[t,r]of this.scopes){let s=r.kind==="owned"?r.bag.declarations():[...r.decls.values()];for(let i of s){if(i.type!=="quality"||i.stages===void 0)continue;let o=e.get(t);o||(o=new Map,e.set(t,o)),o.set(i.name.toLowerCase(),i.stages)}}return e}toSchema(){let e=new Map;for(let[t,r]of this.scopes){let s=r.kind==="owned"?r.bag.declarations():[...r.decls.values()];if(s.length===0)continue;let i=new Map;for(let o of s)i.set(o.name.toLowerCase(),{type:o.type,enumValues:o.values,...o.stages!==void 0?{stages:o.stages}:{}});e.set(t,i)}return{properties:e}}save(){let e={};for(let[t,r]of this.scopes)r.kind==="owned"&&(e[t]=r.bag.save());return e}load(e){for(let[t,r]of Object.entries(e)){let s=this.scopes.get(t);s?.kind==="owned"&&s.bag.load(r)}}saveFragment(){return{version:V,scopes:this.save()}}loadFragment(e){if(e.version!==V)throw new Error(`unsupported owned-state fragment version ${e.version} (supported: ${V})`);this.load(e.scopes)}assertFree(e){if(this.scopes.has(e))throw new Error(`scope \'@${e}\' is already registered`)}};function Q(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"enum":return n.values?.[0]??"";case"flags":return[];case"quality":return n.stages?.[0]??""}}function E(n){return n.ctx.host??{}}var L={defaultScope:"patter",scopes:[{token:"patter"},{token:"scene"}],functions:{random:{minArgs:2,maxArgs:2,returnType:"number",eval(n,e){if(n.length!==2)throw new l("random(a, b) requires exactly 2 arguments");let t=E(e).nextRandom;if(!t)throw new l("random() called without a PRNG in context");let r=e.evaluate(n[0]),s=e.evaluate(n[1]);if(typeof r!="number"||typeof s!="number")throw new l("random(a, b) arguments must be numbers");if(!Number.isInteger(r)||!Number.isInteger(s))throw new l("random(a, b) arguments must be integers");let i=Math.min(r,s),o=Math.max(r,s);return Math.floor(t()*(o-i+1))+i}},check_flags:{minArgs:1,returnType:"boolean",flagDeltaArgs:!0,validate:te("check_flags"),eval(n,e){let t=ee(n[0],e,"check_flags");for(let r=1;r<n.length;r++){let s=n[r];if(s.kind!=="flagdelta")throw new l("check_flags() flag args must be +flagName or -flagName");if(s.sign==="+"?!t.includes(s.name):t.includes(s.name))return!1}return!0}},set_flags:{minArgs:1,returnType:"flags",flagDeltaArgs:!0,validate:te("set_flags"),eval(n,e){let t=[...ee(n[0],e,"set_flags")];for(let r=1;r<n.length;r++){let s=n[r];if(s.kind!=="flagdelta")throw new l("set_flags() flag args must be +flagName or -flagName");if(s.sign==="+")t.includes(s.name)||t.push(s.name);else{let i=t.indexOf(s.name);i>=0&&t.splice(i,1)}}return t}},visits:{minArgs:1,maxArgs:1,returnType:"number",validate:B("visits"),eval:(n,e)=>E(e).visits?.(R(n,e,"visits"))??0},seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:B("seen"),eval:(n,e)=>(E(e).visits?.(R(n,e,"seen"))??0)>0},patter_visits:{minArgs:1,maxArgs:1,returnType:"number",validate:B("patter_visits"),eval:(n,e)=>E(e).patterVisits?.(R(n,e,"patter_visits"))??0},patter_seen:{minArgs:1,maxArgs:1,returnType:"boolean",validate:B("patter_seen"),eval:(n,e)=>(E(e).patterVisits?.(R(n,e,"patter_seen"))??0)>0}}};function j(n,e){let t=n.replace(/^@/,"").split(".");return t.length===2&&e(t[0])?{scope:t[0],name:t[1].toLowerCase()}:{scope:"patter",name:t.join(".").toLowerCase()}}function R(n,e,t){let r=e.evaluate(n[0]);if(typeof r!="string")throw new l(`${t}(id) requires a string node id`);return r}function B(n){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:`${n}(id): the argument must be a string id literal (a scene / block / node id)`})}}function ee(n,e,t){if(!n)throw new l(`${t}() requires at least one argument (the flags variable)`);let r=e.evaluate(n);if(Array.isArray(r))return r;if(r===!1||r===null||r===void 0)return[];throw new l(`${t}() first argument must be a flags property`)}function te(n){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:`${n}(): first argument must be a flags property reference (@name or @scope.name)`});return}let s=t.schema.properties.get(r.scope)?.get(r.name);if(s&&s.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:`${n}(): \'@${i}\' is not a flags property (got ${s.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:`${n}(): argument ${i+1} must be +flagName or -flagName`}):s?.type==="flags"&&s.enumValues&&!s.enumValues.includes(o.name)&&t.report({path:[...t.path,"args",i],kind:"unknown-flag-name",severity:"error",message:`${n}(): unknown flag \'${o.name}\'`,reference:o.name})}}}var De=/^@[A-Za-z0-9_.]+$/;function*Ie(n){let e="",t=0;for(;t<n.length;){let r=n[t];if(r==="{"&&n[t+1]==="{"){e+="{",t+=2;continue}if(r==="}"&&n[t+1]==="}"){e+="}",t+=2;continue}if(r==="{"){let s=n.indexOf("}",t+1);if(s!==-1){let i=n.slice(t,s+1),o=n.slice(t+1,s).trim();if(o.startsWith("@")){e&&(yield{kind:"text",value:e},e=""),yield{kind:"slot",raw:i,inner:o,ref:De.test(o)?o:void 0},t=s+1;continue}e+=i,t=s+1;continue}}e+=r,t+=1}e&&(yield{kind:"text",value:e})}function Re(n){return Array.isArray(n)?n.join(", "):typeof n=="boolean"?n?"true":"false":String(n)}function Be(n){return n===" "||n===" "||n===`\n`||n==="\\r"||n==="\\f"||n==="\\v"}function Oe(n){let e="",t=!1;for(let r of n){if(Be(r)){t=!0;continue}t&&e.length>0&&(e+=" "),t=!1,e+=r}return e}function re(n,e,t){if(e.length===0||n.indexOf(e)<0)return n;let r="",s=0,i=!1;for(;s<n.length;){if(n.startsWith(e,s)){let o=n.indexOf(t,s+e.length);if(o>=0){s=o+t.length,i=!0;continue}r+=n.slice(s);break}r+=n[s],s+=1}return i?Oe(r):n}function ne(n,e){if(n.indexOf("{")<0)return n;let t="";for(let r of Ie(n)){if(r.kind==="text"){t+=r.value;continue}if(!r.ref){t+=r.raw;continue}let s=e(r.ref);t+=s===void 0?"":Re(s)}return t}function x(n,e){for(let t of n){e(t);let r=t.children;r&&x(r,e)}}function Pe(n){return n.toLowerCase().replace(/[\'\u2019]/g,"").replace(/[^a-z0-9-]+/g,"-").replace(/-+/g,"-").replace(/^-+|-+$/g,"")}function f(n){let e=n.gameId?.trim();return e||Pe(n.name)}function $(n){return`cast:${n}`}var _={open:"[",close:"]"},se="SFX";function O(n){let e=new Set,t=[];for(let r of n)e.has(r)||(e.add(r),t.push(r));return t}function P(n){let e=new Map,t=(r,s)=>{let i=O([...s,...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,O([...i,...o.tags??[]]))};for(let r of Object.values(n.scenes)){let s=O(r.tags??[]);e.set(r.id,s);for(let i of r.blocks){let o=O([...s,...i.tags??[]]);e.set(i.id,o);for(let a of i.children)t(a,o)}}return e}var ie=new WeakMap,N=class n{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,s=e.strings;this.allStrings=s,this.currentLocale=r;let i=s[r]??{},o=s[e.locales.default]??{},a=e.localisation,c=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 u of e.cast??[])u.displayName&&d.set(u.name,u.displayName);this.defaultSeed=(t.seed??2654435769)>>>0;let p=new Map,g=new Map,h=new Map;for(let[u,y]of Object.entries(e.scenes)){this.sceneGameIdToId.set(f(y),u);let w=new Map;for(let m of y.blocks)g.set(m.id,{sceneId:u}),h.set(m.id,m),w.set(f(m),m.id),x(m.children,J=>p.set(J.id,J));this.blockGameIdToId.set(u,w)}let W=e.properties??[],A=W.filter(u=>u.shared??!0).map(ae),he=W.filter(u=>!(u.shared??!0)).map(ae),me=new Set(A.map(u=>u.name.toLowerCase())),F=new C().defineOwned("patter",A),K=new Set;if(t.world){let u=e.scopeRegistry?.scopes.find(w=>w.token==="world"),y=(u?.declarations??[]).map(ce);F.defineForeign("world",t.world,y,u?.writable??!0),K.add("world")}for(let u of e.scopeRegistry?.scopes??[]){if(K.has(u.token))continue;let y=(u.declarations??[]).map(ce);F.defineForeign(u.token,Fe(u.declarations??[]),y,u.writable??!0)}let z=new Map;for(let[u,y]of Object.entries(e.scenes)){let w=new Set((y.sceneProps??[]).filter(m=>m.shared??!1).map(m=>m.name.toLowerCase()));z.set(u,w)}this.host={bundle:e,emitIds:c,strings:i,defaultStrings:o,castDisplay:d,nodeIndex:p,blockIndex:g,blockById:h,sceneGameIdToId:this.sceneGameIdToId,blockGameIdToId:this.blockGameIdToId,tagIndex:P(e),shared:F,patterSharedDecls:A,patterLocalDecls:he,patterSharedNames:me,sceneSharedNames:z,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??_).open,captionClose:(e.closedCaptions??_).close,captionCharacter:e.closedCaptions?.character||se,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),s=new n(e,this.creationOptions);try{return s.loadGame(t),r(s)}catch{let i=new n(e,this.creationOptions);for(let[o,a]of Object.entries(t.flows)){let c=a.cursor.currentSceneId;try{i.openFlow(o,c!==null?{scene:c}:{})}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),s=this.resolveBlockRef(r,t.block);this.flowsById.get(e)?.close();let i=new k(e,this.host,t.seed??this.defaultSeed);return this.flowsById.set(e,i),i.start(r,s),i}runFlow(e,t,r){let s=this.flowsById.get(e);if(!s)return this.openFlow(e,{scene:t,block:r}).advanceToStop().played;if(!s.goto(t,r))throw new Error(`runFlow: address not found: ${t}${r===void 0?"":` / ${r}`}`);return s.advanceToStop().played}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),s=this.resolveBlockRef(r,t);return(s!=null?this.host.tagIndex.get(s):void 0)??[]}getCast(){let e=[];for(let t of this.host.bundle.cast??[])t?.name&&e.push(t.name);return e}castForScene(e){let t=this.resolveSceneRef(e),r=t!=null?this.host.bundle.scenes[t]:void 0;if(!r)return[];let s=new Set;for(let i of r.blocks)oe(i.children,s);return[...s]}castForBlock(e,t){let r=this.resolveSceneRef(e),s=this.resolveBlockRef(r,t),i=s!=null?this.host.blockById.get(s):void 0;if(!i)return[];let o=new Set;return oe(i.children,o),[...o]}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)x(r.children,s=>{if(s.type==="snippet")for(let i of s.beats??[])e.push({sceneId:t.id,blockId:r.id,snippetId:s.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 s=this.host.defaultStrings[$(e.character)]??this.host.castDisplay.get(e.character);s!==void 0&&(r.characterName=s)}e.direction!==void 0&&(r.direction=e.direction)}if(e.kind==="line"||e.kind==="text"){let s=this.host.defaultStrings[e.id];s!==void 0&&(r.text=s)}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.get(e)?.close(),this.flowsById.delete(e)}reset(){for(let e of this.flowsById.values())e.close();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:s}=this.splitShared(e);this.host.shared.set(r,s,t)}listProperties(){return this.host.patterSharedDecls.map(e=>({ref:`@${e.name}`,type:e.type,values:e.values,stages:e.stages,value:this.getProperty(`@${e.name}`),default:Ne(e)}))}splitShared(e){let t=this.host.refSplitCache.get(e);if(t||(t=j(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:pe(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 de(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 s=new k(t,this.host,this.defaultSeed);s.restore(r),this.flowsById.set(t,s)}}},k=class{id;host;local;rngState;started=!1;flowEnded=!1;closed=!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 s=this.host.sceneSharedNames.get(r)?.has(e)?this.host.stageBags.get(r):this.sceneBags.get(r);s&&(s[e]=t)}};evalCtx;constructor(e,t,r){this.id=e,this.host=t,this.rngState=r>>>0,this.local=this.freshLocal();let s={...t.shared.toEvalContext().scopes};s.patter=this.patterResolver,s.scene=this.sceneResolver,this.evalCtx={scopes:s,host:{nextRandom:this.rng,visits:i=>this.visitCounts.get(i)??0,patterVisits:i=>this.host.sharedVisits.get(i)??0},qualities:(i,o)=>this.stagesFor(i,o)}}stagesFor(e,t){let r=t.toLowerCase(),s=i=>i?.find(o=>o.name.toLowerCase()===r&&o.type==="quality")?.stages;if(e==="patter")return s(this.host.patterSharedDecls)??s(this.host.patterLocalDecls);if(e==="scene"){let i=this.currentSceneId!=null?this.host.bundle.scenes[this.currentSceneId]:void 0;return s(i?.sceneProps)}return s(this.host.bundle.scopeRegistry?.scopes.find(i=>i.token===e)?.declarations)}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],s=r?this.host.bundle.scenes[r]:void 0;if(!s)throw new Error(r?`unknown scene: ${r}`:"no scenes in bundle");this.enterSceneSetup(r);let i=s.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)}goto(e,t){if(this.closed)return!1;if(e==="END")return this.started=!0,this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null,this.activeSnippet=null,this.beatIndex=0,this.flowEnded=!0,this.stack=[],!0;let r=this.host.sceneGameIdToId.get(e)??(this.host.bundle.scenes[e]?e:void 0);if(r===void 0)return!1;let s;return t!==void 0&&(s=this.host.blockGameIdToId.get(r)?.get(t)??(this.host.blockIndex.get(t)?.sceneId===r?t:void 0),s===void 0)?!1:this.started?(this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null,this.activeSnippet=null,this.beatIndex=0,this.flowEnded=!1,this.enterTarget(s??r,"jump"),this.settle(),!0):(this.start(r,s),!0)}close(){this.closed=!0,this.flowEnded=!0,this.stack=[],this.activeSnippet=null,this.beatIndex=0,this.pendingChoice=null,this.pendingPromptBeat=null,this.pendingPromptOwnerId=null}get isClosed(){return this.closed}get currentScene(){return this.currentSceneId}advance(){if(this.closed)return{type:"end"};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 s=this.activeSnippet.jump;this.activeSnippet=null,this.beatIndex=0,this.resolveJump(s);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 s=t.byId.get(e);this.pendingChoice=null,this.pendingPromptBeat=this.host.replayPromptOnChoose?this.promptBeatOf(s)??null:null,this.pendingPromptOwnerId=this.pendingPromptBeat?s.id:null,this.enterChild(s)}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:s}=this.splitRef(e);if(r==="patter")this.patterResolver.set(s,t);else if(r==="scene"){if(this.currentSceneId===null)throw new Error(`\'${e}\': the flow has not entered a scene yet`);this.sceneResolver.set(s,t)}else this.host.shared.set(r,s,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:pe(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:s,...i}=r;if(s!==void 0){let o=this.childrenOf(i.containerId)?.findIndex(a=>a.id===s)??-1;if(o>=0)return{...i,index:o}}return{...i}}),this.sceneBags=new Map(Object.entries(e.sceneBags??{}).map(([r,s])=>[r,{...s}])),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=de(t.selectors),this.pendingChoice=null,t.pendingChoice!==null){let r=new Map,s=[];for(let i of t.pendingChoice.options){let o=this.host.nodeIndex.get(i.id);o&&(r.set(i.id,o),s.push({...i}))}s.length>0&&(this.pendingChoice={groupId:t.pendingChoice.groupId,options:s,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,s=[];for(let o of e.children){if(o.fallback===!0){s.push(o);continue}if(o.sticky!==!0&&(this.visitCounts.get(o.id)??0)>=1)continue;let a=this.eligible(o),c=o.secretUntilEligible===!0;!a&&c||(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=s.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,s,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,s=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,s=e}this.enter(s);let o={sceneId:r,containerId:s,index:0};t==="call"?this.stack.push(o):this.stack=[o]}selectChild(e){let t=e.children.filter(s=>this.eligible(s));if(t.length===0)return null;let r=this.selectorState(e);switch(e.selector){case"branch":return t[0];case"sequence":{let s=e.options?.order??"sequential",i=e.options?.exhaust??"once";return s==="shuffle"?this.pickShuffle(t,i,r):s==="specificity"?this.pickSpecificity(t,i,r):this.pickSequential(t,i,r)}default:return null}}pickSequential(e,t,r){let s=e.length,i=r.seq??0;return r.seq=i+1,t==="repeat"?e[i%s]:i<s?e[i]:t==="stick"?e[s-1]:null}pickShuffle(e,t,r){let s=e.length,i=t==="stick",o=()=>(i?e.slice(0,s-1):e).map(g=>g.id);if(r.bag===void 0&&(r.bag=o()),r.bag.length===0){if(t==="once")return null;if(i){let g=e[s-1];return r.last=g.id,g}r.bag=o()}let a=r.bag,c=r.last!==void 0&&a.length>1?a.indexOf(r.last):-1,d=Math.floor(this.rng()*(c>=0?a.length-1:a.length));c>=0&&d>=c&&d++;let p=a[d];return a.splice(d,1),r.last=p,e.find(g=>g.id===p)}pickSpecificity(e,t,r){let s=e;if(t!=="repeat"){r.bag===void 0&&(r.bag=e.map(p=>p.id));let d=new Set(r.bag);if(s=e.filter(p=>d.has(p.id)),s.length===0)return t==="stick"&&r.last!==void 0?e.find(p=>p.id===r.last)??null:null}let i=-1,a=s.map(d=>{let p=this.specScore(d);return p>i&&(i=p),{c:d,s:p}}).filter(d=>d.s===i).map(d=>d.c),c;if(a.length===1)c=a[0];else{let d=r.last!==void 0?a.findIndex(g=>g.id===r.last):-1,p=Math.floor(this.rng()*(d>=0?a.length-1:a.length));d>=0&&p>=d&&p++,c=a[p]}return t!=="repeat"&&(r.bag=r.bag.filter(d=>d!==c.id)),r.last=c.id,c}specScore(e){return e.condition?this.matchedSpec(this.conditionAst(e.condition),!0):0}matchedSpec(e,t){return Y(e,s=>le(D(s,this.evalCtx,L)),{want:t})}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?le(this.evalExpr(e.condition)):!0}evalExpr(e){return D(this.conditionAst(e),this.evalCtx,L)}conditionAst(e){let t=ie.get(e);return t||(t=v(e.ast),ie.set(e,t)),t}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 s=this.resolveString(e.id),i=!this.host.captionsOn,a=i&&e.character===this.host.captionCharacter?"":this.captionLine(this.host.bundle.voiced?s:this.interpolate(s)),c=i&&a.length===0;return{type:"line",id:e.id,text:a,character:c?void 0:e.character,characterName:c?void 0:this.resolveCharacterName(e.character),direction:c?void 0:e.direction,gameData:e.gameData,...r}}}}interpolate(e){return ne(e,t=>this.getProperty(t))}stripCaptions(e){return re(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 x(e,r=>{!t&&r.type==="snippet"&&(r.beats??[]).some(s=>s.kind==="line"||s.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=$(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=j(e,r=>r==="scene"||this.host.shared.has(r)),this.host.refSplitCache.set(e,t)),t}freshLocal(){return new C().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 s of e.sceneProps??[]){let i=s.name.toLowerCase();t.has(i)||(r[i]=q(s))}this.sceneBags.set(e.id,r)}if(!this.host.stageBags.has(e.id)){let r={};for(let s of e.sceneProps??[]){let i=s.name.toLowerCase();t.has(i)&&(r[i]=q(s))}this.host.stageBags.set(e.id,r)}for(let r of e.sceneProps??[]){if(!r.temporary)continue;let s=r.name.toLowerCase(),i=t.has(s)?this.host.stageBags.get(e.id):this.sceneBags.get(e.id);i&&(i[s]=q(r))}}};function oe(n,e){x(n,t=>{if(t.type==="group"){t.prompt?.kind==="line"&&t.prompt.character&&e.add(t.prompt.character);return}for(let r of t.beats??[])r.kind==="line"&&r.character&&e.add(r.character)})}function pe(n){let e={};for(let[t,r]of n){let s={};r.seq!==void 0&&(s.seq=r.seq),r.bag&&(s.bag=[...r.bag]),r.last!==void 0&&(s.last=r.last),e[t]=s}return e}function de(n){let e=new Map;for(let[t,r]of Object.entries(n??{})){let s={};r.seq!==void 0&&(s.seq=r.seq),r.bag&&(s.bag=[...r.bag]),r.last!==void 0&&(s.last=r.last),e.set(t,s)}return e}function ae(n){return{name:n.name,type:n.type,values:n.values,stages:n.stages,default:n.default}}function Ne(n){if(n.default!==void 0)return n.default;switch(n.type){case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??"";case"quality":return n.stages?.[0]??"";default:return!1}}function ce(n){return{name:n.name,type:n.type,values:n.values,stages:n.stages,default:n.default,writable:n.writable}}function Ae(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??"";case"quality":return n.stages?.[0]??""}}function Fe(n){let e=r=>r.toLowerCase(),t=new Map;for(let r of n)t.set(e(r.name),Ae(r));return{get:r=>t.get(e(r)),set:(r,s)=>{t.set(e(r),s)}}}function q(n){if(n.default!==void 0)return n.default;switch(n.type){case"boolean":return!1;case"number":return 0;case"string":return"";case"flags":return[];case"enum":return n.values?.[0]??"";case"quality":return n.stages?.[0]??""}}function le(n){return typeof n=="boolean"?n:typeof n=="number"?n!==0:typeof n=="string"?n!=="":n.length>0}var Ge=(n,e)=>n.shared??e;function H(n,e){return{name:n.name,type:n.type,hasDefault:n.default!==void 0,...n.default!==void 0?{default:n.default}:{},shared:Ge(n,e)}}function Te(n){return{name:n.name,type:n.type,hasDefault:n.default!==void 0,...n.values?{values:[...n.values]}:{},...n.purpose?{purpose:n.purpose}:{}}}function Ve(n,e){e.blocks++;let t=[...n.children];for(;t.length;){let r=t.pop();if(r.type==="group"){e.groups++,r.prompt&&e.prompts++,t.push(...r.children);continue}e.snippets++;for(let s of r.beats??[])e.beats++,s.kind==="gameEvent"&&e.gameEvents++}}function ue(n){let e={scenes:0,blocks:0,groups:0,snippets:0,beats:0,prompts:0,gameEvents:0,cast:n.cast?.length??0},t=[],r=[];for(let o of Object.values(n.scenes)){e.scenes++;let a=f(o);t.push({gameId:a,name:o.name,blocks:o.blocks.map(c=>({gameId:f(c),name:c.name}))});for(let c of o.blocks)Ve(c,e);o.sceneProps?.length&&r.push({gameId:a,properties:o.sceneProps.map(c=>H(c,!1))})}let s=(n.scopeRegistry?.scopes??[]).map(o=>({token:o.token,writable:o.writable??!0,opaque:o.declarations===void 0,properties:(o.declarations??[]).map(a=>H(a,!0))})),i=Object.entries(n.gameDataFields??{}).filter(([,o])=>(o?.length??0)>0).map(([o,a])=>({kind:o,fields:(a??[]).map(Te)}));return{identity:{schema:n.schema,project:n.content.project,...n.content.version!==void 0?{version:n.content.version}:{},...n.content.hash!==void 0?{hash:n.content.hash}:{},...n.content.structureHash!==void 0?{structureHash:n.content.structureHash}:{},voiced:n.voiced,defaultLocale:n.locales.default,locales:[...n.locales.included],localisation:n.localisation?.mode??"embedded",sourceDebug:n.localisation?.sourceDebug??!1},addresses:t,hostScopes:s,properties:{patter:(n.properties??[]).map(o=>H(o,!0)),scene:r},gameData:i,counts:e}}function ge(n,e){return n.gameDataFields?.[e]??[]}function U(n,e,t){return e&&Object.prototype.hasOwnProperty.call(e,t)?e[t]:n.find(r=>r.name===t)?.default}function fe(n,e){let t={};for(let r of n){let s=U(n,e,r.name);s!==void 0&&(t[r.name]=s)}for(let[r,s]of Object.entries(e??{}))r in t||(t[r]=s);return t}return xe(Me);})();\n//# sourceMappingURL=patterplay.min.js.map';
733
+
734
+ // src/export-html.ts
735
+ var esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
736
+ var PLAYER_JS = String.raw`
737
+ (function () {
738
+ var Engine = window.Patterplay.Engine;
739
+ var BUNDLE = window.PATTER_BUNDLE;
740
+ var startScene = (BUNDLE.start && BUNDLE.start.scene) || Object.keys(BUNDLE.scenes)[0];
741
+ var SAVE_KEY = "patter.play." + ((BUNDLE.content && BUNDLE.content.hash) || "x");
742
+
743
+ var stage = document.getElementById("stage");
744
+ var controls = document.getElementById("controls");
745
+ var engine, flow;
746
+
747
+ // Stable per-character hue (same idea as the editor's colour-by-character), tinted for a light page.
748
+ function hueOf(name) { var h = 0; for (var i = 0; i < (name || "").length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; return h; }
749
+ function add(cls, html) { var d = document.createElement("div"); d.className = cls; d.innerHTML = html; stage.appendChild(d); return d; }
750
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); }
751
+
752
+ // Pacing: a source-only reader carries no audio, so each line is held on screen for a reading-length delay
753
+ // (proportional to its word count) before the next appears, and every line fades in (see CSS). The Speed
754
+ // control sets the reading pace (words/min); "Instant" drops the delay entirely. The choice is persisted.
755
+ // A run token cancels an in-flight paced read the moment the player restarts, loads, or picks a choice.
756
+ var SPEED_WPM = { slow: 300, normal: 500, fast: 800, instant: 0 }, runToken = 0;
757
+ var speedKey = (function () { try { return localStorage.getItem("patter.playSpeed") || "normal"; } catch (e) { return "normal"; } })();
758
+ if (SPEED_WPM[speedKey] === undefined) speedKey = "normal";
759
+ function fakeDuration(text) {
760
+ var wpm = SPEED_WPM[speedKey];
761
+ if (!wpm) return 0; // "Instant" - reveal the next line immediately
762
+ var words = String(text == null ? "" : text).trim().split(/\s+/).filter(Boolean).length;
763
+ return Math.max(450, Math.round((words / wpm) * 60000)); // a beat needs to land
764
+ }
765
+ function scrollDown() { window.scrollTo(0, document.body.scrollHeight); }
766
+ var speedSel = document.getElementById("speed");
767
+ if (speedSel) {
768
+ speedSel.value = speedKey; // a fresh fakeDuration() reads speedKey each line, so a change applies next line
769
+ speedSel.onchange = function () { speedKey = speedSel.value; try { localStorage.setItem("patter.playSpeed", speedKey); } catch (e) {} };
770
+ }
771
+
772
+ function build() { engine = new Engine(BUNDLE); flow = engine.openFlow("main", { scene: startScene }); }
773
+
774
+ function newGame() { stage.innerHTML = ""; controls.innerHTML = ""; build(); run(); }
775
+
776
+ function run() {
777
+ controls.innerHTML = "";
778
+ var token = ++runToken;
779
+ (function step() {
780
+ if (token !== runToken) return; // superseded by a restart / load / choice pick
781
+ var s = flow.advance();
782
+ if (!s) { add("end", "The End"); return; } // defensive: a step should always be returned; never hard-crash
783
+ if (s.type === "line") {
784
+ var who = s.characterName || s.character || "";
785
+ var dir = s.direction ? '<em class="dir">(' + esc(s.direction) + ')</em> ' : "";
786
+ add("line", '<span class="who" style="color:hsl(' + hueOf(s.character || who) + ',55%,38%)">' + esc(who) + '</span>' + dir + esc(s.text));
787
+ scrollDown(); setTimeout(step, fakeDuration(s.text));
788
+ } else if (s.type === "text") {
789
+ add("text", esc(s.text)); scrollDown(); setTimeout(step, fakeDuration(s.text));
790
+ } else if (s.type === "choice") { renderChoice(s); }
791
+ else if (s.type === "end") { add("end", "The End"); }
792
+ else { step(); } // game-event beat: a host cue with no player-facing text - skip it, advance at once
793
+ })();
794
+ }
795
+
796
+ function renderChoice(step) {
797
+ var opts = step.options || [];
798
+ if (!opts.length) { add("end", "The End"); return; }
799
+ for (var i = 0; i < opts.length; i++) (function (opt, idx) {
800
+ var b = document.createElement("button");
801
+ b.className = "choice"; b.textContent = (opt.prompt && opt.prompt.text) || "(continue)";
802
+ b.disabled = opt.eligible === false;
803
+ b.style.animationDelay = (idx * 60) + "ms"; // gentle stagger so the options fade in one after another
804
+ b.onclick = function () { flow.choose(opt.id); scrollDown(); run(); };
805
+ controls.appendChild(b);
806
+ })(opts[i], i);
807
+ scrollDown();
808
+ }
809
+
810
+ document.getElementById("restart").onclick = newGame;
811
+ document.getElementById("save").onclick = function () { try { localStorage.setItem(SAVE_KEY, JSON.stringify(engine.saveGame())); flash("Saved"); } catch (e) {} };
812
+ document.getElementById("load").onclick = function () {
813
+ var blob; try { blob = localStorage.getItem(SAVE_KEY); } catch (e) {}
814
+ if (!blob) { flash("No save yet"); return; }
815
+ engine = new Engine(BUNDLE);
816
+ engine.loadGame(JSON.parse(blob)); flow = engine.getFlow("main");
817
+ stage.innerHTML = ""; add("text", "(resumed from save)"); run();
818
+ };
819
+ function flash(msg) { var f = document.getElementById("flash"); f.textContent = msg; f.style.opacity = "1"; setTimeout(function () { f.style.opacity = "0"; }, 1100); }
820
+
821
+ newGame();
822
+ })();
823
+ `;
824
+ var STYLE = String.raw`
825
+ :root { color-scheme: light; }
826
+ * { box-sizing: border-box; }
827
+ body { margin: 0; background: #f6f1e7; color: #2b2620; font: 18px/1.65 Georgia, "Times New Roman", serif; }
828
+ .wrap { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 6rem; }
829
+ header { display: flex; align-items: baseline; gap: 0.75rem; border-bottom: 1px solid #e0d7c5; padding-bottom: 0.75rem; margin-bottom: 1.25rem; }
830
+ h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
831
+ header .by { color: #9a8f78; font-size: 0.8rem; font-family: ui-sans-serif, system-ui, sans-serif; margin-left: auto; }
832
+ #stage > div { margin: 0.55rem 0; animation: fade 0.45s ease-out both; }
833
+ @keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
834
+ @media (prefers-reduced-motion: reduce) { #stage > div, .choice { animation: none; } }
835
+ .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; }
836
+ .line .dir { color: #9a8f78; font-style: italic; }
837
+ .text { color: #4a4334; }
838
+ .end { color: #9a8f78; font-style: italic; text-align: center; margin-top: 1.5rem; }
839
+ #controls { margin: 1.25rem 0; display: flex; flex-direction: column; gap: 0.5rem; }
840
+ .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; }
841
+ .choice:hover:not(:disabled) { border-color: #b8956a; background: #fffaf0; }
842
+ .choice:disabled { color: #b8af9b; cursor: default; }
843
+ .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); }
844
+ .bar .inner { max-width: 42rem; margin: 0 auto; padding: 0.6rem 1.25rem; display: flex; gap: 0.5rem; align-items: center; }
845
+ .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; }
846
+ .bar button:hover { border-color: #b8956a; }
847
+ .bar .speedlabel { font: 0.8rem ui-sans-serif, system-ui, sans-serif; color: #9a8f78; display: flex; align-items: center; gap: 0.3rem; }
848
+ .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; }
473
849
  .bar #speed:hover { border-color: #b8956a; }
474
850
  #flash { margin-left: auto; color: #9a8f78; font: 0.8rem ui-sans-serif, system-ui, sans-serif; opacity: 0; transition: opacity .2s; }
475
851
  `;
@@ -478,7 +854,7 @@ function sourceStory(loaded) {
478
854
  const def = full.locales?.default ?? Object.keys(full.strings)[0] ?? "en";
479
855
  const bundle = { ...full, locales: { default: def, included: [def] }, strings: { [def]: full.strings[def] ?? {} } };
480
856
  const title = loaded.project.project.name?.trim() || "A Patter story";
481
- const bundleJson = canonicalStringify(bundle, { trailingComma: false }).replace(/</g, "\\u003c");
857
+ const bundleJson = canonicalStringify2(bundle, { trailingComma: false }).replace(/</g, "\\u003c");
482
858
  return { title, lang: def, bundleJson };
483
859
  }
484
860
  function page(title, lang, head, tail) {
@@ -607,7 +983,7 @@ function stringsByLocale(loaded) {
607
983
  }
608
984
  return byLocale;
609
985
  }
610
- function mergeAuthoring(loaded) {
986
+ function mergeAuthoring2(loaded) {
611
987
  const writing = /* @__PURE__ */ new Map();
612
988
  const recording = /* @__PURE__ */ new Map();
613
989
  const cut = /* @__PURE__ */ new Set();
@@ -808,7 +1184,7 @@ function proposeCoverageDrivers(loaded) {
808
1184
  const { written, proposals } = analyzeHostScopes(bundle, hostTokens);
809
1185
  const declByRef = /* @__PURE__ */ new Map();
810
1186
  for (const s of loaded.project.scopeRegistry?.scopes ?? []) {
811
- for (const d of s.declarations ?? []) declByRef.set(`@${s.token}.${d.name}`, { type: d.type, values: d.values });
1187
+ for (const d of s.declarations ?? []) declByRef.set(`@${s.token}.${d.name}`, { type: d.type, values: d.values, stages: d.stages });
812
1188
  }
813
1189
  const drivers = [];
814
1190
  for (const [ref, pool] of proposals) {
@@ -818,6 +1194,7 @@ function proposeCoverageDrivers(loaded) {
818
1194
  if (values.length === 0 && decl) {
819
1195
  if (decl.type === "boolean") values = [true, false];
820
1196
  else if (decl.type === "enum" && decl.values) values = [...decl.values];
1197
+ else if (decl.type === "quality" && decl.stages) values = [...decl.stages];
821
1198
  }
822
1199
  if (values.length === 0) continue;
823
1200
  drivers.push({ ref, kind: "recurring", cadence: "sometimes", values: sortValues(values) });
@@ -1020,13 +1397,13 @@ function renderCoverageText(report, sceneName = (id) => id) {
1020
1397
 
1021
1398
  // src/format.ts
1022
1399
  import { readFileSync as readFileSync3 } from "fs";
1023
- import { canonicalStringify as canonicalStringify2, parseSource as parseSource3 } from "@patterkit/core";
1400
+ import { canonicalStringify as canonicalStringify3, parseSource as parseSource3 } from "@patterkit/core";
1024
1401
  function runFormat(files) {
1025
1402
  return files.map((file) => {
1026
1403
  const original = readFileSync3(file, "utf8");
1027
1404
  let canonical;
1028
1405
  try {
1029
- canonical = canonicalStringify2(parseSource3(original));
1406
+ canonical = canonicalStringify3(parseSource3(original));
1030
1407
  } catch (e) {
1031
1408
  throw new Error(`${file}: ${e instanceof Error ? e.message : String(e)}`);
1032
1409
  }
@@ -1066,7 +1443,7 @@ function runReport(loaded, recordingOverride) {
1066
1443
  const thresholdIdx = estimatingOn ? writingIndex.get(est?.thresholdStatus ?? "") ?? 0 : -1;
1067
1444
  const defaultLines = est?.defaultLines ?? 0;
1068
1445
  const tagMap = new Map((est?.tagEstimates ?? []).map((t) => [t.tag, t.lines]));
1069
- const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, rerecord: rerecordSet, edits: editsOf } = mergeAuthoring(loaded);
1446
+ const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, rerecord: rerecordSet, edits: editsOf } = mergeAuthoring2(loaded);
1070
1447
  const recordingBase = recordingOverride ?? manualRecordingOf;
1071
1448
  const recordingOf = (id) => effectiveRecording(id, recordingBase, rerecordSet, recordingLadder[0]);
1072
1449
  const byLocale = stringsByLocale(loaded);
@@ -1411,7 +1788,7 @@ async function runReportXlsx(data) {
1411
1788
 
1412
1789
  // src/localisation.ts
1413
1790
  import { basename as basename4, join as join3, sep } from "path";
1414
- import { canonicalStringify as canonicalStringify3 } from "@patterkit/core";
1791
+ import { canonicalStringify as canonicalStringify4 } from "@patterkit/core";
1415
1792
  import { walkNodes as walkNodes3, castStringKey, PROJECT_LOCALE_SCENE } from "@patterkit/model";
1416
1793
 
1417
1794
  // src/documentation.ts
@@ -1426,7 +1803,7 @@ function classesForChannel(classes, channel) {
1426
1803
  function resolveDocumentation(loaded, channel) {
1427
1804
  const classes = loaded.project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES;
1428
1805
  const allowed = classesForChannel(classes, channel);
1429
- const docsOf = mergeAuthoring(loaded).documentation;
1806
+ const docsOf = mergeAuthoring2(loaded).documentation;
1430
1807
  const own = (id) => (docsOf.get(id) ?? []).filter((l) => l.type !== void 0 && allowed.has(l.type));
1431
1808
  const out = /* @__PURE__ */ new Map();
1432
1809
  const visit = (id, inherited) => {
@@ -1462,7 +1839,7 @@ function extractLoc(loaded, opts = {}) {
1462
1839
  const targetLocale = isTemplate ? void 0 : opts.locale;
1463
1840
  const source = tableFor(loaded, defaultLocale);
1464
1841
  const target = targetLocale ? tableFor(loaded, targetLocale) : {};
1465
- const edits = mergeAuthoring(loaded).edits;
1842
+ const edits = mergeAuthoring2(loaded).edits;
1466
1843
  const docs = resolveDocumentation(loaded, "loc");
1467
1844
  const commentsOf = (id) => (docs.get(id) ?? []).map((d) => d.text);
1468
1845
  const staleFor = (id, translation) => {
@@ -1569,7 +1946,7 @@ function applyLoc(loaded, catalog, opts = {}) {
1569
1946
  merged[id] = text;
1570
1947
  }
1571
1948
  const file = { schema: existing?.schema ?? "patter/strings@0", scene, locale, strings: merged };
1572
- writes.push({ path, content: canonicalStringify3(file) });
1949
+ writes.push({ path, content: canonicalStringify4(file) });
1573
1950
  const fresh = bySceneFresh.get(scene);
1574
1951
  if (!fresh?.size) continue;
1575
1952
  const aPath = authoringPath(loaded, scene);
@@ -1578,7 +1955,7 @@ function applyLoc(loaded, catalog, opts = {}) {
1578
1955
  const editsMap = { ...authoring.edits ?? {} };
1579
1956
  for (const id of fresh) editsMap[id] = { ...editsMap[id], localisedAt: { ...editsMap[id]?.localisedAt, [locale]: now } };
1580
1957
  authoring.edits = editsMap;
1581
- writes.push({ path: aPath, content: canonicalStringify3(authoring) });
1958
+ writes.push({ path: aPath, content: canonicalStringify4(authoring) });
1582
1959
  }
1583
1960
  return { writes, stats: { updated, files: bySceneText.size } };
1584
1961
  }
@@ -1630,1660 +2007,1347 @@ function catalogToPo(catalog) {
1630
2007
  out.push(`#: ${e.scene}`);
1631
2008
  if (e.stale) out.push("#, fuzzy");
1632
2009
  out.push(poField("msgctxt", e.id), poField("msgid", e.source), poField("msgstr", e.translation), "");
1633
- }
1634
- return out.join("\n");
1635
- }
1636
- function readQuoted(lines, start) {
1637
- const first = lines[start];
1638
- let value = poUnescape(first.slice(first.indexOf('"') + 1, first.lastIndexOf('"')));
1639
- let i = start + 1;
1640
- while (i < lines.length && lines[i].trimStart().startsWith('"')) {
1641
- const l = lines[i].trim();
1642
- value += poUnescape(l.slice(1, l.lastIndexOf('"')));
1643
- i++;
1644
- }
1645
- return { value, next: i };
1646
- }
1647
- function poToCatalog(text) {
1648
- const lines = text.split(/\r?\n/);
1649
- const entries = [];
1650
- let locale;
1651
- let project = "";
1652
- let comments = [];
1653
- let scene = "";
1654
- let stale = false;
1655
- let ctxt;
1656
- let msgid = "";
1657
- let i = 0;
1658
- const flush = (msgstr) => {
1659
- if (ctxt !== void 0) {
1660
- entries.push({ id: ctxt, scene, source: msgid, translation: msgstr, comments, stale });
1661
- } else if (msgid === "") {
1662
- const lang = /Language:\s*([^\\\n]*)/.exec(msgstr);
1663
- if (lang?.[1]?.trim()) locale = lang[1].trim();
1664
- const pid = /Project-Id-Version:\s*([^\\\n]*)/.exec(msgstr);
1665
- if (pid?.[1]?.trim()) project = pid[1].trim();
1666
- }
1667
- comments = [];
1668
- scene = "";
1669
- stale = false;
1670
- ctxt = void 0;
1671
- msgid = "";
1672
- };
1673
- while (i < lines.length) {
1674
- const line = lines[i];
1675
- const t = line.trim();
1676
- if (t === "") {
1677
- i++;
1678
- continue;
1679
- }
1680
- if (t.startsWith("#.")) {
1681
- comments.push(t.slice(2).trim());
1682
- i++;
1683
- continue;
1684
- }
1685
- if (t.startsWith("#:")) {
1686
- scene = t.slice(2).trim().split(/\s+/)[0] ?? "";
1687
- i++;
1688
- continue;
1689
- }
1690
- if (t.startsWith("#,")) {
1691
- if (t.includes("fuzzy")) stale = true;
1692
- i++;
1693
- continue;
1694
- }
1695
- if (t.startsWith("#")) {
1696
- i++;
1697
- continue;
1698
- }
1699
- if (t.startsWith("msgctxt")) {
1700
- const r = readQuoted(lines, i);
1701
- ctxt = r.value;
1702
- i = r.next;
1703
- continue;
1704
- }
1705
- if (t.startsWith("msgid")) {
1706
- const r = readQuoted(lines, i);
1707
- msgid = r.value;
1708
- i = r.next;
1709
- continue;
1710
- }
1711
- if (t.startsWith("msgstr")) {
1712
- const r = readQuoted(lines, i);
1713
- flush(r.value);
1714
- i = r.next;
1715
- continue;
1716
- }
1717
- i++;
1718
- }
1719
- return { project, defaultLocale: locale ?? "", locale, entries };
1720
- }
1721
-
1722
- // src/loc-xlsx.ts
1723
- var HEADERS = ["ID", "Source", "Translation", "Comments", "Status", "Gender"];
1724
- var sheetName = (scene) => scene.replace(/[:\\/?*[\]]/g, "-").slice(0, 31);
1725
- async function catalogToXlsx(catalog) {
1726
- const { default: ExcelJS } = await import("exceljs");
1727
- const wb = new ExcelJS.Workbook();
1728
- wb.creator = "patter";
1729
- const byScene = /* @__PURE__ */ new Map();
1730
- for (const e of catalog.entries) (byScene.get(e.scene) ?? byScene.set(e.scene, []).get(e.scene)).push(e);
1731
- for (const [scene, entries] of byScene) {
1732
- const ws = wb.addWorksheet(sheetName(scene));
1733
- ws.columns = [
1734
- { header: "ID", key: "id", width: 22 },
1735
- { header: "Source", key: "source", width: 40 },
1736
- { header: "Translation", key: "translation", width: 40 },
1737
- { header: "Comments", key: "comments", width: 30 },
1738
- { header: "Status", key: "status", width: 10 },
1739
- { header: "Gender", key: "gender", width: 12 }
1740
- ];
1741
- ws.getRow(1).font = { bold: true };
1742
- ws.views = [{ state: "frozen", ySplit: 1 }];
1743
- for (const e of entries) {
1744
- ws.addRow({
1745
- id: e.id,
1746
- source: e.source,
1747
- translation: e.translation,
1748
- comments: e.comments.join("\n"),
1749
- status: e.stale ? "stale" : e.translation ? "translated" : "",
1750
- gender: e.context?.gender ?? ""
1751
- });
1752
- }
1753
- }
1754
- return Buffer.from(await wb.xlsx.writeBuffer());
1755
- }
1756
- async function xlsxToCatalog(buffer) {
1757
- const { default: ExcelJS } = await import("exceljs");
1758
- const wb = new ExcelJS.Workbook();
1759
- await wb.xlsx.load(buffer);
1760
- const entries = [];
1761
- for (const ws of wb.worksheets) {
1762
- const scene = ws.name;
1763
- const header = ws.getRow(1).values.slice(1).map((v) => String(v ?? ""));
1764
- if (header[0] !== HEADERS[0]) continue;
1765
- ws.eachRow((row, n) => {
1766
- if (n === 1) return;
1767
- const cell = (c) => {
1768
- const v = row.getCell(c).value;
1769
- return v == null ? "" : String(typeof v === "object" && "text" in v ? v.text : v);
1770
- };
1771
- const id = cell(1).trim();
1772
- if (!id) return;
1773
- const status = cell(5).trim().toLowerCase();
1774
- entries.push({
1775
- id,
1776
- scene,
1777
- source: cell(2),
1778
- translation: cell(3),
1779
- comments: cell(4) ? cell(4).split("\n").filter(Boolean) : [],
1780
- stale: status === "stale"
1781
- });
1782
- });
1783
- }
1784
- return { project: "", defaultLocale: "", locale: void 0, entries };
1785
- }
1786
-
1787
- // src/voice-script.ts
1788
- 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";
1789
- function plainVoice(text) {
1790
- return text.replace(/<\/?(?:b|i|bi)>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1791
- }
1792
- function groupLabel(node, source) {
1793
- if (node.prompt) return plainVoice(source[node.prompt.id]?.trim() || "option");
1794
- if (node.selector === "choice") return "";
1795
- return node.selector ?? "group";
1796
- }
1797
- function runVoiceScript(loaded, opts = {}) {
1798
- const { project } = loaded;
1799
- const everything = !!opts.everything;
1800
- const ladderDecls = project.writingStatuses ?? DEFAULT_WRITING_STATUSES2;
1801
- const writingLadder = ladderDecls.map((s) => s.name);
1802
- const recordingLadder = (project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES3).map((s) => s.name);
1803
- const stub = writingLadder[0];
1804
- const recordThreshold = ladderDecls.findIndex((s) => s.readyToRecord);
1805
- const writingIndex = new Map(writingLadder.map((name, i) => [name, i]));
1806
- const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, rerecord: rerecordSet, documentation: docsOf } = mergeAuthoring(loaded);
1807
- const recordingBase = opts.recordingOverride ?? manualRecordingOf;
1808
- const recordingOf = (id) => effectiveRecording(id, recordingBase, rerecordSet, recordingLadder[0]);
1809
- const source = sourceStrings(loaded);
1810
- const actorOf = /* @__PURE__ */ new Map();
1811
- for (const c of project.cast ?? []) if (c.actor) actorOf.set(c.name, c.actor);
1812
- const voClasses = classesForChannel(project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES2, "vo");
1813
- const ownVo = (id) => (docsOf.get(id) ?? []).filter((d) => d.type !== void 0 && voClasses.has(d.type)).map((d) => d.text);
1814
- const lines = [];
1815
- const walkNode = (node, scope, ancestorVo) => {
1816
- if (node.type === "group") {
1817
- if (cutSet.has(node.id)) return;
1818
- const label = groupLabel(node, source);
1819
- const childScope = label ? [...scope, label] : scope;
1820
- const childVo = [...ancestorVo, ...ownVo(node.id)];
1821
- for (const child of node.children ?? []) walkNode(child, childScope, childVo);
1822
- return;
1823
- }
1824
- if (cutSet.has(node.id)) return;
1825
- let leading = true;
1826
- for (const beat of node.beats ?? []) {
1827
- if (beat.kind !== "line" || cutSet.has(beat.id)) continue;
1828
- const ws = writingOf.get(beat.id) ?? stub;
1829
- if (!everything && recordThreshold !== -1 && (writingIndex.get(ws) ?? 0) < recordThreshold) continue;
1830
- const own = ownVo(beat.id);
1831
- lines.push({
1832
- scope: scope.join(" \u203A "),
1833
- id: beat.id,
1834
- character: beat.character ?? "",
1835
- actor: beat.character ? actorOf.get(beat.character) : void 0,
1836
- text: plainVoice(source[beat.id] ?? ""),
1837
- comments: leading ? [...ancestorVo, ...own] : own,
1838
- // first line of the run gets the enclosing context
1839
- recordingStatus: recordingOf(beat.id)
1840
- });
1841
- leading = false;
1842
- }
1843
- };
1844
- for (const scene of loaded.scenes) {
1845
- if (cutSet.has(scene.id)) continue;
1846
- const sceneVo = ownVo(scene.id);
1847
- for (const block of scene.blocks) {
1848
- if (cutSet.has(block.id)) continue;
1849
- const scope = [scene.name, block.name].filter(Boolean);
1850
- const blockVo = [...sceneVo, ...ownVo(block.id)];
1851
- for (const child of block.children ?? []) walkNode(child, scope, blockVo);
1852
- }
1853
- }
1854
- return { project: project.project.id, voiced: project.voiced ?? false, recordingLadder, everything, lines };
1855
- }
1856
-
1857
- // src/voice-script-xlsx.ts
1858
- async function voiceScriptToXlsx(data) {
1859
- const { default: ExcelJS } = await import("exceljs");
1860
- const wb = new ExcelJS.Workbook();
1861
- wb.creator = "patter";
1862
- const ws = wb.addWorksheet("Voice Script");
1863
- ws.columns = [
1864
- { header: "Scope", key: "scope", width: 28 },
1865
- { header: "Line ID", key: "id", width: 18 },
1866
- { header: "Character", key: "character", width: 16 },
1867
- { header: "Actor", key: "actor", width: 16 },
1868
- { header: "Text", key: "text", width: 60 },
1869
- { header: "Comments", key: "comments", width: 36 },
1870
- { header: "Status", key: "status", width: 12 }
1871
- ];
1872
- ws.getRow(1).font = { bold: true };
1873
- ws.views = [{ state: "frozen", ySplit: 1 }];
1874
- for (const l of data.lines) {
1875
- const row = ws.addRow({
1876
- scope: l.scope,
1877
- id: l.id,
1878
- character: l.character,
1879
- actor: l.actor ?? "",
1880
- text: l.text,
1881
- comments: l.comments.join("\n"),
1882
- status: l.recordingStatus
1883
- });
1884
- row.getCell("text").alignment = { wrapText: true, vertical: "top" };
1885
- row.getCell("comments").alignment = { wrapText: true, vertical: "top" };
1886
- }
1887
- return Buffer.from(await wb.xlsx.writeBuffer());
1888
- }
1889
-
1890
- // src/script-doc.ts
1891
- var TOKENS = {
1892
- ink: "26221c",
1893
- // near-black warm ink (headings)
1894
- inkRead: "3a352d",
1895
- // dialogue body (ink ~84% + muted)
1896
- inkSoft: "5c554b",
1897
- // narration (ink ~68% + muted) - a step softer than dialogue
1898
- muted: "8c8479",
1899
- // labels, quiet tags
1900
- accent: "8a5a34",
1901
- // structural marks: conditions, options, jumps, game events
1902
- line: "e4dfd5"
1903
- // hairlines / rules / the group rail
1904
- };
1905
- var CHAR_PALETTE = [
1906
- "b23b3b",
1907
- "b5642b",
1908
- "927524",
1909
- "6b7a31",
1910
- "3f7d49",
1911
- "2c7d6e",
1912
- "2d7791",
1913
- "3a6aa8",
1914
- "5a5ba6",
1915
- "7e4e9c",
1916
- "9c4486",
1917
- "a83e5c"
1918
- ];
1919
- function colourIndex(name) {
1920
- let h = 2166136261;
1921
- for (let i = 0; i < name.length; i++) {
1922
- h ^= name.charCodeAt(i);
1923
- h = Math.imul(h, 16777619);
1924
- }
1925
- h ^= h >>> 16;
1926
- h = Math.imul(h, 2246822507);
1927
- h ^= h >>> 13;
1928
- h = Math.imul(h, 3266489909);
1929
- h ^= h >>> 16;
1930
- return (h >>> 0) % 12;
1931
- }
1932
- function characterColour(name) {
1933
- return CHAR_PALETTE[colourIndex(name)];
1934
- }
1935
- var VISIT_FN_RE = /\b(patter_visits|patter_seen|visits|seen)\s*\(\s*(['"])(.*?)\2\s*\)/g;
1936
- function humanizeCondition(cond, label) {
1937
- return cond.replace(VISIT_FN_RE, (_m, fn, _q, id) => `${fn}(${label(id)})`);
1938
- }
1939
- var MARKUP_RE = /<(b|i|bi)>([\s\S]*?)<\/\1>/g;
1940
- var INTERP_RE = /\{@[^}]*\}/g;
1941
- function textRuns(text) {
1942
- const marked = [];
1943
- let last = 0;
1944
- let m;
1945
- MARKUP_RE.lastIndex = 0;
1946
- while ((m = MARKUP_RE.exec(text)) !== null) {
1947
- if (m.index > last) marked.push({ text: text.slice(last, m.index), bold: false, italic: false });
1948
- const tag = m[1];
1949
- marked.push({ text: m[2], bold: tag === "b" || tag === "bi", italic: tag === "i" || tag === "bi" });
1950
- last = MARKUP_RE.lastIndex;
1951
- }
1952
- if (last < text.length) marked.push({ text: text.slice(last), bold: false, italic: false });
1953
- if (!marked.length) marked.push({ text, bold: false, italic: false });
1954
- const out = [];
1955
- for (const r of marked) {
1956
- let li = 0;
1957
- let im;
1958
- INTERP_RE.lastIndex = 0;
1959
- while ((im = INTERP_RE.exec(r.text)) !== null) {
1960
- if (im.index > li) out.push({ text: r.text.slice(li, im.index), bold: r.bold, italic: r.italic, code: false });
1961
- out.push({ text: im[0], bold: r.bold, italic: r.italic, code: true });
1962
- li = INTERP_RE.lastIndex;
1963
- }
1964
- if (li < r.text.length) out.push({ text: r.text.slice(li), bold: r.bold, italic: r.italic, code: false });
1965
- }
1966
- return out.length ? out : [{ text, bold: false, italic: false, code: false }];
1967
- }
1968
- function targetLabel(to, sceneOf, blockTrail) {
1969
- if (to === "END") return "END";
1970
- return blockTrail.get(to) ?? sceneOf.get(to) ?? to;
1971
- }
1972
- function gameEventLabel(beat) {
1973
- const gd = beat.gameData;
1974
- if (gd && typeof gd === "object") {
1975
- const parts = Object.entries(gd).map(([k, v]) => {
1976
- const val = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
1977
- return val === "" ? k : `${k}: ${val}`;
1978
- });
1979
- if (parts.length) return `game event \xB7 ${parts.join(", ")}`;
1980
- }
1981
- return "game event";
1982
- }
1983
- function optionTag(node) {
1984
- const parts = [];
1985
- if (node.fallback) parts.push("fallback");
1986
- parts.push(node.sticky ? "repeatable" : "once only");
1987
- if (node.secretUntilEligible) parts.push("hidden");
1988
- return parts.join(" \xB7 ");
1989
- }
1990
- function sequenceLabel(node) {
1991
- const o = node.options ?? {};
1992
- const bits = [];
1993
- if ((o.order ?? "sequential") === "shuffle") bits.push("shuffled");
1994
- const ex = o.exhaust ?? "once";
1995
- if (ex === "repeat") bits.push("repeating");
1996
- else if (ex === "stick") bits.push("holds on the last");
1997
- return bits.length ? `In sequence \xB7 ${bits.join(", ")}` : "In sequence";
1998
- }
1999
- function runScriptDoc(loaded) {
2000
- const { project } = loaded;
2001
- const source = sourceStrings(loaded);
2002
- const { cut } = mergeAuthoring(loaded);
2003
- const sceneOf = /* @__PURE__ */ new Map();
2004
- const blockTrail = /* @__PURE__ */ new Map();
2005
- const blockName = /* @__PURE__ */ new Map();
2006
- for (const scene of loaded.scenes) {
2007
- sceneOf.set(scene.id, scene.name);
2008
- for (const block of scene.blocks) {
2009
- blockTrail.set(block.id, `${scene.name} \u203A ${block.name}`);
2010
- blockName.set(block.id, block.name);
2011
- }
2012
- }
2013
- const humanize = (cond) => humanizeCondition(cond, (id) => blockName.get(id) ?? sceneOf.get(id) ?? id);
2014
- const els = [];
2015
- const textOf = (id) => source[id] ?? "";
2016
- const jumpText = (j) => {
2017
- const dest = targetLabel(j.to, sceneOf, blockTrail);
2018
- return j.mode === "call" ? `${dest} (and return)` : dest;
2019
- };
2020
- let snippetSeq = 0;
2021
- const nextSid = () => ++snippetSeq;
2022
- const emitBeats = (node, indent, sid) => {
2023
- for (const beat of node.beats ?? []) {
2024
- if (cut.has(beat.id)) continue;
2025
- if (beat.kind === "line") els.push({ kind: "line", indent, snippet: sid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
2026
- else if (beat.kind === "text") els.push({ kind: "narration", indent, snippet: sid, runs: textRuns(textOf(beat.id)) });
2027
- else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent, snippet: sid, text: gameEventLabel(beat) });
2028
- }
2029
- if (node.jump) els.push({ kind: "jump", indent, snippet: sid, text: jumpText(node.jump) });
2030
- };
2031
- const walk = (node, indent, sid) => {
2032
- if (cut.has(node.id)) return;
2033
- if (node.type === "snippet") {
2034
- if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
2035
- emitBeats(node, indent, sid);
2036
- return;
2037
- }
2038
- if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
2039
- if (node.selector === "choice") {
2040
- els.push({ kind: "group", indent, label: "Choose" });
2041
- for (const child of node.children ?? []) {
2042
- if (cut.has(child.id)) continue;
2043
- const cid = nextSid();
2044
- if (child.type === "group" && child.prompt) {
2045
- if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
2046
- els.push({ kind: "option", indent, snippet: cid, runs: textRuns(textOf(child.prompt.id) || "(option)"), tag: optionTag(child) });
2047
- for (const c of child.children ?? []) walk(c, indent + 1, cid);
2048
- } else if (child.type === "snippet") {
2049
- const beats = child.beats ?? [];
2050
- const first = beats.find((b) => b.kind === "line" || b.kind === "text");
2051
- if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
2052
- els.push({ kind: "option", indent, snippet: cid, runs: textRuns(first ? textOf(first.id) || "(option)" : "(option)"), tag: optionTag(child) });
2053
- let seenPrompt = false;
2054
- for (const beat of beats) {
2055
- if (!seenPrompt && beat === first) {
2056
- seenPrompt = true;
2057
- continue;
2058
- }
2059
- if (cut.has(beat.id)) continue;
2060
- if (beat.kind === "line") els.push({ kind: "line", indent: indent + 1, snippet: cid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
2061
- else if (beat.kind === "text") els.push({ kind: "narration", indent: indent + 1, snippet: cid, runs: textRuns(textOf(beat.id)) });
2062
- else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent: indent + 1, snippet: cid, text: gameEventLabel(beat) });
2063
- }
2064
- if (child.jump) els.push({ kind: "jump", indent: indent + 1, snippet: cid, text: jumpText(child.jump) });
2065
- } else {
2066
- walk(child, indent + 1, cid);
2067
- }
2068
- }
2069
- return;
2070
- }
2071
- if (node.selector === "branch") {
2072
- els.push({ kind: "group", indent, label: "One of \xB7 first match wins" });
2073
- const kids = (node.children ?? []).filter((c) => !cut.has(c.id));
2074
- kids.forEach((child, i) => {
2075
- const isCatchAll = i === kids.length - 1 && kids.length > 1 && !("condition" in child && child.condition);
2076
- if (isCatchAll) els.push({ kind: "else", indent });
2077
- walk(child, indent, nextSid());
2078
- });
2079
- return;
2080
- }
2081
- if (node.selector === "sequence") {
2082
- els.push({ kind: "group", indent, label: sequenceLabel(node) });
2083
- for (const child of node.children ?? []) walk(child, indent, nextSid());
2084
- return;
2085
- }
2086
- for (const child of node.children ?? []) walk(child, indent, sid);
2087
- };
2088
- for (const scene of loaded.scenes) {
2089
- if (cut.has(scene.id)) continue;
2090
- els.push({ kind: "scene", text: scene.name });
2091
- for (const block of scene.blocks) {
2092
- if (cut.has(block.id)) continue;
2093
- els.push({ kind: "block", text: block.name });
2094
- for (const child of block.children ?? []) walk(child, 0, void 0);
2095
- }
2096
- }
2097
- return { project: project.project.name, elements: els };
2098
- }
2099
-
2100
- // src/script-docx.ts
2101
- import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, TabStopType, TextRun } from "docx";
2102
- var SERIF = "Georgia";
2103
- var SANS = "Calibri";
2104
- var MONO = "Consolas";
2105
- var STEP = 360;
2106
- var SNIPPET_INSET = 220;
2107
- var DIALOGUE_INDENT = 300;
2108
- var HANG = 340;
2109
- var RIGHT_TAB = 9020;
2110
- var SNIP_GAP = 170;
2111
- var WITHIN = 30;
2112
- var S = { h1: 32, h2: 26, body: 22, cue: 18, label: 16, tag: 15, mech: 18 };
2113
- function bodyRuns(runs, color, base = {}) {
2114
- 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 }));
2115
- }
2116
- var leftOf = (el) => ("indent" in el ? el.indent * STEP : 0) + ("snippet" in el && el.snippet !== void 0 ? SNIPPET_INSET : 0);
2117
- function paragraph(el, prev) {
2118
- const sid = "snippet" in el ? el.snippet : void 0;
2119
- const startsSnippet = sid !== void 0 && (!prev || !("snippet" in prev) || prev.snippet !== sid);
2120
- const before = sid !== void 0 ? startsSnippet ? SNIP_GAP : WITHIN : 100;
2121
- switch (el.kind) {
2122
- case "scene":
2123
- return new Paragraph({
2124
- heading: HeadingLevel.HEADING_1,
2125
- keepNext: true,
2126
- spacing: { before: 480, after: 200 },
2127
- border: { bottom: { style: BorderStyle.SINGLE, size: 14, color: TOKENS.accent, space: 6 } },
2128
- children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h1 })]
2129
- });
2130
- case "block":
2131
- return new Paragraph({
2132
- heading: HeadingLevel.HEADING_2,
2133
- keepNext: true,
2134
- spacing: { before: 360, after: 160 },
2135
- border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: TOKENS.line, space: 4 } },
2136
- children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h2 })]
2137
- });
2138
- case "line": {
2139
- const kids = [];
2140
- 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 }));
2141
- if (el.direction) kids.push(new TextRun({ text: `(${el.direction}) `, font: SERIF, italics: true, color: TOKENS.muted, size: S.body }));
2142
- kids.push(...bodyRuns(el.runs, TOKENS.inkRead));
2143
- return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) + DIALOGUE_INDENT + HANG, hanging: HANG }, children: kids });
2144
- }
2145
- case "narration":
2146
- return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) }, children: bodyRuns(el.runs, TOKENS.inkSoft) });
2147
- case "condition":
2148
- 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 })] });
2149
- case "group":
2150
- 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 })] });
2151
- case "else":
2152
- 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 })] });
2153
- case "option": {
2154
- const kids = [new TextRun({ text: "\u25C7 ", color: TOKENS.accent, size: S.body }), ...bodyRuns(el.runs, TOKENS.ink)];
2155
- 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 }));
2156
- 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 });
2157
- }
2158
- case "jump":
2159
- 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 })] });
2160
- case "gameEvent":
2161
- 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 })] });
2162
- }
2163
- }
2164
- async function scriptToDocx(doc) {
2165
- const body = [
2166
- new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: "Readable script", font: SANS, bold: true, allCaps: true, color: TOKENS.accent, size: 16, characterSpacing: 30 })] }),
2167
- new Paragraph({ spacing: { after: 560 }, children: [new TextRun({ text: doc.project, font: SERIF, bold: true, color: TOKENS.ink, size: 52 })] }),
2168
- ...doc.elements.map((el, i) => paragraph(el, doc.elements[i - 1]))
2169
- ];
2170
- const document = new Document({ creator: "Patter", title: doc.project, sections: [{ children: body }] });
2171
- return Packer.toBuffer(document);
2172
- }
2173
-
2174
- // src/script-pdf.ts
2175
- import PDFDocument from "pdfkit";
2176
- var MARGIN = 64;
2177
- var INDENT_STEP = 20;
2178
- var EDGE_INSET = 12;
2179
- var EDGE_OFF = 3;
2180
- var DIALOGUE_INDENT2 = 14;
2181
- var HANG2 = 18;
2182
- var CUE_GAP = 7;
2183
- var BODY = 11;
2184
- var CUE = 8.5;
2185
- var LEAD = 3.5;
2186
- var WITHIN2 = 4;
2187
- var BETWEEN = 10;
2188
- var BEAT = 6;
2189
- var TIGHT = 3;
2190
- var hex = (h) => `#${h}`;
2191
- var INK = hex(TOKENS.ink);
2192
- var INK_READ = hex(TOKENS.inkRead);
2193
- var INK_SOFT = hex(TOKENS.inkSoft);
2194
- var MUTED = hex(TOKENS.muted);
2195
- var ACCENT = hex(TOKENS.accent);
2196
- var LINE = hex(TOKENS.line);
2197
- var EDGE = "#d7d0c3";
2198
- var SERIF2 = (bold, italic) => bold && italic ? "Serif-BoldItalic" : bold ? "Serif-Bold" : italic ? "Serif-Italic" : "Serif";
2199
- var isEmoji = (cp) => cp >= 126976 || cp >= 9728 && cp <= 10175;
2200
- async function scriptToPdf(doc) {
2201
- const { scriptFont } = await import("./script-fonts-AQNQOADI.js");
2202
- return new Promise((resolve5, reject) => {
2203
- const pdf = new PDFDocument({ size: "A4", margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN }, info: { Title: doc.project, Author: "Patter" }, font: null });
2204
- pdf.registerFont("Serif", scriptFont("serif"));
2205
- pdf.registerFont("Serif-Bold", scriptFont("serifBold"));
2206
- pdf.registerFont("Serif-Italic", scriptFont("serifItalic"));
2207
- pdf.registerFont("Serif-BoldItalic", scriptFont("serifBoldItalic"));
2208
- pdf.registerFont("Sans", scriptFont("sans"));
2209
- pdf.registerFont("Sans-Bold", scriptFont("sansBold"));
2210
- pdf.registerFont("Mono", scriptFont("mono"));
2211
- pdf.registerFont("Symbol", scriptFont("symbol"));
2212
- pdf.registerFont("Emoji", scriptFont("emoji"));
2213
- const coverage = {};
2214
- const ascender = {};
2215
- for (const name of ["Serif", "Serif-Bold", "Serif-Italic", "Serif-BoldItalic", "Sans", "Sans-Bold", "Mono"]) {
2216
- pdf.font(name);
2217
- const f = pdf._font;
2218
- coverage[name] = (cp) => f.font.hasGlyphForCodePoint(cp);
2219
- ascender[name] = f.ascender;
2220
- }
2221
- const ascentPt = (name, size) => ascender[name] / 1e3 * size;
2222
- const split = (text, primary, color) => {
2223
- const cov = coverage[primary];
2224
- const out = [];
2225
- let buf = "";
2226
- let cur = primary;
2227
- for (const ch of text) {
2228
- const cp = ch.codePointAt(0);
2229
- const f = isEmoji(cp) ? "Emoji" : cov(cp) ? primary : "Symbol";
2230
- if (f !== cur) {
2231
- if (buf) out.push({ text: buf, font: cur, color });
2232
- buf = "";
2233
- cur = f;
2234
- }
2235
- buf += ch;
2236
- }
2237
- if (buf) out.push({ text: buf, font: cur, color });
2238
- return out;
2239
- };
2240
- 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));
2241
- const chunks = [];
2242
- pdf.on("data", (c) => chunks.push(c));
2243
- pdf.on("end", () => resolve5(Buffer.concat(chunks)));
2244
- pdf.on("error", reject);
2245
- const pageRight = pdf.page.width - MARGIN;
2246
- const pageBottom = pdf.page.height - MARGIN;
2247
- const contentW = pdf.page.width - MARGIN * 2;
2248
- const ensure = (space) => {
2249
- if (pdf.y > pageBottom - space) pdf.addPage();
2250
- };
2251
- const draw = (pieces, x, opts) => {
2252
- pieces.forEach((p, i) => {
2253
- pdf.font(p.font).fillColor(p.color);
2254
- const cont = i < pieces.length - 1;
2255
- if (i === 0) pdf.text(p.text, x, pdf.y, { ...opts, continued: cont });
2256
- else pdf.text(p.text, { continued: cont });
2257
- });
2258
- };
2259
- const drawRight = (pieces, size) => {
2260
- let total = 0;
2261
- for (const p of pieces) {
2262
- pdf.font(p.font).fontSize(size);
2263
- total += pdf.widthOfString(p.text);
2264
- }
2265
- const x = Math.max(MARGIN, pageRight - total);
2266
- pieces.forEach((p, i) => {
2267
- pdf.font(p.font).fillColor(p.color).fontSize(size);
2268
- const cont = i < pieces.length - 1;
2269
- if (i === 0) pdf.text(p.text, x, pdf.y, { continued: cont });
2270
- else pdf.text(p.text, { continued: cont });
2271
- });
2272
- };
2273
- pdf.fontSize(9);
2274
- draw(split("READABLE SCRIPT", "Sans-Bold", ACCENT), MARGIN, { characterSpacing: 2 });
2275
- pdf.moveDown(0.4).fontSize(26);
2276
- draw(split(doc.project, "Serif-Bold", INK), MARGIN, { width: contentW });
2277
- pdf.strokeColor(ACCENT).lineWidth(2).moveTo(MARGIN, pdf.y + 10).lineTo(MARGIN + 40, pdf.y + 10).stroke();
2278
- pdf.y += 28;
2279
- const drawContent = (el, cx) => {
2280
- const w = pageRight - cx;
2281
- switch (el.kind) {
2282
- case "scene":
2283
- pdf.fontSize(17);
2284
- draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2285
- pdf.strokeColor(ACCENT).lineWidth(1.5).moveTo(MARGIN, pdf.y + 4).lineTo(MARGIN + 34, pdf.y + 4).stroke();
2286
- break;
2287
- case "block":
2288
- pdf.fontSize(13);
2289
- draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2290
- pdf.strokeColor(LINE).lineWidth(0.75).moveTo(MARGIN, pdf.y + 3).lineTo(pageRight, pdf.y + 3).stroke();
2291
- break;
2292
- case "line": {
2293
- const cueX = cx + DIALOGUE_INDENT2;
2294
- let bodyX = cueX;
2295
- if (el.character) {
2296
- pdf.font("Sans-Bold").fontSize(CUE);
2297
- bodyX = cueX + pdf.widthOfString(el.character.toUpperCase()) + CUE_GAP;
2298
- }
2299
- const yTop = pdf.y;
2300
- if (el.character) {
2301
- 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 });
2302
- pdf.y = yTop;
2303
- }
2304
- pdf.fontSize(BODY);
2305
- draw([...el.direction ? split(`(${el.direction}) `, "Serif-Italic", MUTED) : [], ...bodyPieces(el.runs, INK_READ)], bodyX, { width: Math.max(120, pageRight - bodyX), lineGap: LEAD });
2306
- break;
2307
- }
2308
- case "narration":
2309
- pdf.fontSize(BODY);
2310
- draw(bodyPieces(el.runs, INK_SOFT), cx, { width: w, lineGap: LEAD });
2311
- break;
2312
- case "condition":
2313
- pdf.fontSize(9.5);
2314
- draw(split(`\u2039 ${el.text} \u203A`, "Mono", ACCENT), cx, { width: w });
2315
- break;
2316
- case "group":
2317
- pdf.fontSize(8);
2318
- draw(split(el.label.toUpperCase(), "Sans-Bold", ACCENT), cx, { width: w, characterSpacing: 1.2 });
2319
- break;
2320
- case "else":
2321
- pdf.fontSize(8);
2322
- draw(split("ELSE \xB7 CATCH-ALL", "Sans", MUTED), cx, { width: w, characterSpacing: 1 });
2323
- break;
2324
- case "option": {
2325
- const ox = cx + HANG2;
2326
- const tag = el.tag ? el.tag.toUpperCase() : "";
2327
- const tagW = tag ? pdf.font("Sans").fontSize(7.5).widthOfString(tag) + 8 : 0;
2328
- const yStart = pdf.y;
2329
- if (tag) {
2330
- pdf.font("Sans").fontSize(7.5).fillColor(MUTED).text(tag, pageRight - tagW, yStart, { width: tagW, align: "right", lineBreak: false });
2331
- pdf.y = yStart;
2332
- }
2333
- pdf.fontSize(BODY);
2334
- draw([...split("\u25C7 ", "Serif", ACCENT), ...bodyPieces(el.runs, INK)], ox, { width: Math.max(80, pageRight - ox - tagW), indent: -HANG2, lineGap: LEAD });
2335
- break;
2336
- }
2337
- case "jump":
2338
- drawRight(split(`\u21AA ${el.text}`, "Sans-Bold", ACCENT), 9.5);
2339
- break;
2340
- case "gameEvent": {
2341
- const pieces = split(`\u2699 ${el.text}`, "Mono", ACCENT);
2342
- pdf.fontSize(9);
2343
- let total = 0;
2344
- for (const p of pieces) {
2345
- pdf.font(p.font);
2346
- total += pdf.widthOfString(p.text);
2347
- }
2348
- if (total <= pageRight - cx) drawRight(pieces, 9);
2349
- else draw(pieces, cx, { width: pageRight - cx });
2350
- break;
2351
- }
2352
- }
2353
- };
2354
- const leadOf = (k) => k === "scene" ? 24 : k === "block" ? 16 : k === "group" ? 13 : k === "else" ? 9 : 0;
2355
- const els = doc.elements;
2356
- let curSid;
2357
- let curBase = 0;
2358
- for (let i = 0; i < els.length; i++) {
2359
- const el = els[i];
2360
- const next = els[i + 1];
2361
- const sid = "snippet" in el ? el.snippet : void 0;
2362
- const sameNext = sid !== void 0 && next !== void 0 && "snippet" in next && next.snippet === sid;
2363
- if (el.kind === "scene") ensure(90);
2364
- else if (el.kind === "block") ensure(70);
2365
- else ensure(leadOf(el.kind) + BODY + LEAD + 14);
2366
- pdf.y += leadOf(el.kind);
2367
- const base = "indent" in el ? MARGIN + el.indent * INDENT_STEP : MARGIN;
2368
- const cx = base + (sid !== void 0 ? EDGE_INSET : 0);
2369
- const yTop = pdf.y;
2370
- const pageBefore = pdf.page;
2371
- drawContent(el, cx);
2372
- const bottom = pdf.y;
2373
- 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;
2374
- if (sid !== void 0) {
2375
- if (sid !== curSid) {
2376
- curSid = sid;
2377
- curBase = "indent" in el ? el.indent : 0;
2378
- }
2379
- const ex = MARGIN + curBase * INDENT_STEP + EDGE_OFF;
2380
- const top = pdf.page === pageBefore ? yTop : MARGIN;
2381
- pdf.strokeColor(EDGE).lineWidth(0.75).moveTo(ex, top).lineTo(ex, bottom + (sameNext ? trail : 0)).stroke();
2382
- }
2383
- pdf.y = bottom + trail;
2384
- }
2385
- pdf.end();
2386
- });
2010
+ }
2011
+ return out.join("\n");
2387
2012
  }
2388
-
2389
- // src/init.ts
2390
- import { join as join4, basename as basename5, resolve as resolve3 } from "path";
2391
- import { readdirSync as readdirSync3, existsSync as existsSync2 } from "fs";
2392
- import { newId, slug, canonicalStringify as canonicalStringify4 } from "@patterkit/core";
2393
- function runInit(opts) {
2394
- const dir = resolve3(opts.dir);
2395
- const name = opts.name?.trim() || basename5(dir).replace(/\.patter$/, "");
2396
- const fileSlug = slug(name);
2397
- let existing = [];
2398
- try {
2399
- existing = readdirSync3(dir).filter((f) => f.endsWith(".patterproj"));
2400
- } catch {
2013
+ function readQuoted(lines, start) {
2014
+ const first = lines[start];
2015
+ let value = poUnescape(first.slice(first.indexOf('"') + 1, first.lastIndexOf('"')));
2016
+ let i = start + 1;
2017
+ while (i < lines.length && lines[i].trimStart().startsWith('"')) {
2018
+ const l = lines[i].trim();
2019
+ value += poUnescape(l.slice(1, l.lastIndexOf('"')));
2020
+ i++;
2401
2021
  }
2402
- if (existing.length > 0) throw new Error(`a project already exists here: ${existing[0]}`);
2403
- const sceneId = newId("scn");
2404
- const beatId = newId("T");
2405
- const project = {
2406
- schema: "patter/project@0",
2407
- project: { id: newId("proj"), name },
2408
- locales: { default: "en", all: ["en"] },
2409
- ...opts.vcs ? { vcs: opts.vcs } : {},
2410
- // record the chosen VCS so it can be read / switched later
2411
- voiced: true
2412
- };
2413
- const flow = {
2414
- schema: "patter/flow@0",
2415
- scene: {
2416
- id: sceneId,
2417
- type: "scene",
2418
- name: "Start",
2419
- blocks: [{
2420
- id: newId("blk"),
2421
- type: "block",
2422
- name: "Main",
2423
- children: [{
2424
- id: newId("sn"),
2425
- type: "snippet",
2426
- beats: [{ id: beatId, kind: "text" }],
2427
- jump: { to: "END" }
2428
- }]
2429
- }]
2022
+ return { value, next: i };
2023
+ }
2024
+ function poToCatalog(text) {
2025
+ const lines = text.split(/\r?\n/);
2026
+ const entries = [];
2027
+ let locale;
2028
+ let project = "";
2029
+ let comments = [];
2030
+ let scene = "";
2031
+ let stale = false;
2032
+ let ctxt;
2033
+ let msgid = "";
2034
+ let i = 0;
2035
+ const flush = (msgstr) => {
2036
+ if (ctxt !== void 0) {
2037
+ entries.push({ id: ctxt, scene, source: msgid, translation: msgstr, comments, stale });
2038
+ } else if (msgid === "") {
2039
+ const lang = /Language:\s*([^\\\n]*)/.exec(msgstr);
2040
+ if (lang?.[1]?.trim()) locale = lang[1].trim();
2041
+ const pid = /Project-Id-Version:\s*([^\\\n]*)/.exec(msgstr);
2042
+ if (pid?.[1]?.trim()) project = pid[1].trim();
2430
2043
  }
2044
+ comments = [];
2045
+ scene = "";
2046
+ stale = false;
2047
+ ctxt = void 0;
2048
+ msgid = "";
2431
2049
  };
2432
- const locale = {
2433
- schema: "patter/strings@0",
2434
- scene: sceneId,
2435
- locale: "en",
2436
- default: true,
2437
- // The starter line is STORY content the writer replaces, so it must read the same however the
2438
- // project was made: no file paths, no commands. (Front-ends give their own next step - `patter init`
2439
- // prints "try: patter play <dir>" on the terminal, and Patterpad just opens the scene for editing.)
2440
- strings: { [beatId]: `Welcome to ${name}. This is the first line of your story - replace it with your own.` }
2441
- };
2442
- const bundle = opts.bundle ?? "commit";
2443
- const projectFile = join4(dir, `${fileSlug}.patterproj`);
2444
- const writes = [
2445
- { path: projectFile, content: canonicalStringify4(project) },
2446
- { path: join4(dir, "scenes", "start.patterflow"), content: canonicalStringify4(flow) },
2447
- { path: join4(dir, "loc", "en", "start.patterloc"), content: canonicalStringify4(locale) },
2448
- { path: join4(dir, ".editorconfig"), content: EDITORCONFIG },
2449
- ...vcsConfigWrites(dir, opts.vcs, bundle)
2450
- ];
2451
- const collisions = writes.map((w) => w.path).filter((p) => existsSync2(p));
2452
- if (collisions.length > 0) {
2453
- throw new Error(`refusing to overwrite existing file(s): ${collisions.join(", ")}`);
2050
+ while (i < lines.length) {
2051
+ const line = lines[i];
2052
+ const t = line.trim();
2053
+ if (t === "") {
2054
+ i++;
2055
+ continue;
2056
+ }
2057
+ if (t.startsWith("#.")) {
2058
+ comments.push(t.slice(2).trim());
2059
+ i++;
2060
+ continue;
2061
+ }
2062
+ if (t.startsWith("#:")) {
2063
+ scene = t.slice(2).trim().split(/\s+/)[0] ?? "";
2064
+ i++;
2065
+ continue;
2066
+ }
2067
+ if (t.startsWith("#,")) {
2068
+ if (t.includes("fuzzy")) stale = true;
2069
+ i++;
2070
+ continue;
2071
+ }
2072
+ if (t.startsWith("#")) {
2073
+ i++;
2074
+ continue;
2075
+ }
2076
+ if (t.startsWith("msgctxt")) {
2077
+ const r = readQuoted(lines, i);
2078
+ ctxt = r.value;
2079
+ i = r.next;
2080
+ continue;
2081
+ }
2082
+ if (t.startsWith("msgid")) {
2083
+ const r = readQuoted(lines, i);
2084
+ msgid = r.value;
2085
+ i = r.next;
2086
+ continue;
2087
+ }
2088
+ if (t.startsWith("msgstr")) {
2089
+ const r = readQuoted(lines, i);
2090
+ flush(r.value);
2091
+ i = r.next;
2092
+ continue;
2093
+ }
2094
+ i++;
2454
2095
  }
2455
- return { writes, projectFile, name };
2456
- }
2457
- function vcsConfigWrites(dir, vcs, bundle = "commit") {
2458
- const writes = [{ path: join4(dir, "vcs-setup.md"), content: vcsSetup(vcs, bundle) }];
2459
- if (vcs === "git") writes.push({ path: join4(dir, ".gitattributes"), content: GITATTRIBUTES });
2460
- const ignore = ignoreFileFor(vcs);
2461
- if (ignore) writes.push({ path: join4(dir, ignore), content: ignoreContent(bundle) });
2462
- return writes;
2096
+ return { project, defaultLocale: locale ?? "", locale, entries };
2463
2097
  }
2464
- var EDITORCONFIG = `# Patter projects are UTF-8 + LF, always (spec - patter format enforces this).
2465
- root = true
2466
-
2467
- [*]
2468
- charset = utf-8
2469
- end_of_line = lf
2470
- insert_final_newline = true
2471
-
2472
- [*.{patterflow,patterloc,patterx,patterproj}]
2473
- indent_style = space
2474
- indent_size = 2
2475
- `;
2476
- var GITATTRIBUTES = `# Patter source is UTF-8 + LF text (pinned; never let autocrlf touch it).
2477
- *.patterflow text eol=lf
2478
- *.patterloc text eol=lf
2479
- *.patterx text eol=lf
2480
- *.patterproj text eol=lf
2481
-
2482
- # Id-keyed structured merge for Patter source (the 'patter merge' driver).
2483
- # Register it once per clone (see vcs-setup.md); until then git falls back to a
2484
- # normal text merge for these - no harm, just not structure-aware.
2485
- *.patterflow merge=patter
2486
- *.patterloc merge=patter
2487
- *.patterx merge=patter
2488
- *.patterproj merge=patter
2489
2098
 
2490
- # Generated artifacts (spec \xA711). The compiled bundle is committed but
2491
- # REGENERATED, never hand-merged - keep ours on conflict and rebuild (the
2492
- # validate staleness gate catches a stale one). Needs a one-time
2493
- # 'git config merge.ours.driver true' (see vcs-setup.md).
2494
- *.patterc text eol=lf merge=ours
2495
- # The packed document (.patterpack) is a binary zip envelope - kept out of VCS (.gitignore).
2496
- *.patterpack binary
2497
- `;
2498
- function ignoreFileFor(vcs) {
2499
- switch (vcs) {
2500
- case "git":
2501
- return ".gitignore";
2502
- case "perforce":
2503
- return ".p4ignore";
2504
- case "plastic":
2505
- return "ignore.conf";
2506
- default:
2507
- return void 0;
2099
+ // src/loc-xlsx.ts
2100
+ var HEADERS = ["ID", "Source", "Translation", "Comments", "Status", "Gender"];
2101
+ var sheetName = (scene) => scene.replace(/[:\\/?*[\]]/g, "-").slice(0, 31);
2102
+ async function catalogToXlsx(catalog) {
2103
+ const { default: ExcelJS } = await import("exceljs");
2104
+ const wb = new ExcelJS.Workbook();
2105
+ wb.creator = "patter";
2106
+ const byScene = /* @__PURE__ */ new Map();
2107
+ for (const e of catalog.entries) (byScene.get(e.scene) ?? byScene.set(e.scene, []).get(e.scene)).push(e);
2108
+ for (const [scene, entries] of byScene) {
2109
+ const ws = wb.addWorksheet(sheetName(scene));
2110
+ ws.columns = [
2111
+ { header: "ID", key: "id", width: 22 },
2112
+ { header: "Source", key: "source", width: 40 },
2113
+ { header: "Translation", key: "translation", width: 40 },
2114
+ { header: "Comments", key: "comments", width: 30 },
2115
+ { header: "Status", key: "status", width: 10 },
2116
+ { header: "Gender", key: "gender", width: 12 }
2117
+ ];
2118
+ ws.getRow(1).font = { bold: true };
2119
+ ws.views = [{ state: "frozen", ySplit: 1 }];
2120
+ for (const e of entries) {
2121
+ ws.addRow({
2122
+ id: e.id,
2123
+ source: e.source,
2124
+ translation: e.translation,
2125
+ comments: e.comments.join("\n"),
2126
+ status: e.stale ? "stale" : e.translation ? "translated" : "",
2127
+ gender: e.context?.gender ?? ""
2128
+ });
2129
+ }
2508
2130
  }
2131
+ return Buffer.from(await wb.xlsx.writeBuffer());
2509
2132
  }
2510
- function ignoreContent(bundle) {
2511
- const lines = [
2512
- "# Patter generated artifacts - not source (see vcs-setup.md).",
2513
- "# Packed document (the send-and-return envelope):",
2514
- "*.patterpack",
2515
- "# Unresolved merge sidecar:",
2516
- "*.patterconflict"
2517
- ];
2518
- if (bundle === "ignore") {
2519
- lines.push("# Compiled bundle (built in CI, not committed under this posture):", "*.patterc");
2133
+ async function xlsxToCatalog(buffer) {
2134
+ const { default: ExcelJS } = await import("exceljs");
2135
+ const wb = new ExcelJS.Workbook();
2136
+ await wb.xlsx.load(buffer);
2137
+ const entries = [];
2138
+ for (const ws of wb.worksheets) {
2139
+ const scene = ws.name;
2140
+ const header = ws.getRow(1).values.slice(1).map((v) => String(v ?? ""));
2141
+ if (header[0] !== HEADERS[0]) continue;
2142
+ ws.eachRow((row, n) => {
2143
+ if (n === 1) return;
2144
+ const cell = (c) => {
2145
+ const v = row.getCell(c).value;
2146
+ return v == null ? "" : String(typeof v === "object" && "text" in v ? v.text : v);
2147
+ };
2148
+ const id = cell(1).trim();
2149
+ if (!id) return;
2150
+ const status = cell(5).trim().toLowerCase();
2151
+ entries.push({
2152
+ id,
2153
+ scene,
2154
+ source: cell(2),
2155
+ translation: cell(3),
2156
+ comments: cell(4) ? cell(4).split("\n").filter(Boolean) : [],
2157
+ stale: status === "stale"
2158
+ });
2159
+ });
2520
2160
  }
2521
- return lines.join("\n") + "\n";
2161
+ return { project: "", defaultLocale: "", locale: void 0, entries };
2522
2162
  }
2523
- function vcsSetup(vcs, bundle) {
2524
- const sections = {
2525
- git: `## git
2526
-
2527
- \`.gitattributes\` (already emitted) pins Patter source shards to UTF-8 + LF text
2528
- and marks the generated artifacts (the \`.patterc\` bundle \`merge=ours\`, the
2529
- \`.patterpack\` document \`binary\`). \`.gitignore\` (already emitted) keeps the document
2530
- (and the bundle, under the "ignore" posture) out of source control.
2531
-
2532
- Recommended pre-commit hook (\`.git/hooks/pre-commit\`, executable):
2533
-
2534
- #!/bin/sh
2535
- patter validate || exit 1
2536
-
2537
- The \`merge=patter\` rules in \`.gitattributes\` are already active; register the
2538
- drivers once per clone (git config is not repo-tracked) - until then git falls
2539
- back to a normal text merge for those files:
2540
-
2541
- git config merge.patter.name "Patter structured merge"
2542
- git config merge.patter.driver "patter merge %O %A %B -o %A"
2543
- git config merge.ours.driver true
2544
-
2545
- git invokes the per-path driver directly (no \`mergetool\` wrapper needed). \`%O %A
2546
- %B\` are base / ours / theirs; the merged result is written back to \`%A\`. On a
2547
- conflict \`patter merge\` exits non-zero and writes a \`.patterconflict\` sidecar
2548
- beside the file, so the merge stays unresolved.
2549
- `,
2550
- perforce: `## Perforce
2551
-
2552
- Add Patter extensions to the typemap (\`p4 typemap\`): source shards and the
2553
- compiled bundle are TEXT with LF; the packed document is BINARY:
2554
-
2555
- text //....patterflow
2556
- text //....patterloc
2557
- text //....patterx
2558
- text //....patterproj
2559
- text //....patterc
2560
- binary //....patterpack
2561
-
2562
- On a unicode-mode server, set \`P4CHARSET=utf8\`. For the lock-based workflow
2563
- (spec: one scene per file = one lock per scene), add \`+l\` to make checkouts
2564
- exclusive. A \`.p4ignore\` (already emitted) keeps the packed document out of the
2565
- depot.
2566
-
2567
- Perforce allows ONE global merge tool, so set \`patter mergetool\` as it - it
2568
- runs the structured merge for Patter source and hands everything else to your
2569
- normal tool. Map Perforce's variables into the order BASE THEIRS OURS OUT:
2570
-
2571
- patter mergetool --fallback "<your merge tool>" %b %t %y %r
2572
-
2573
- (\`%b\` base, \`%t\` theirs, \`%y\` yours/ours, \`%r\` result - adjust to your P4
2574
- client's variable names; the argument ORDER is what matters.)
2575
- `,
2576
- plastic: `## Plastic SCM (Unity VC)
2577
-
2578
- Patter source shards are plain UTF-8 text; Plastic handles them as-is. The
2579
- \`ignore.conf\` (already emitted) keeps the packed document out of the repo. For
2580
- the lock-based workflow, configure exclusive checkout (lock.conf) for the shard
2581
- extensions.
2582
-
2583
- In Preferences > Merge tools, add an external tool (a global entry is fine -
2584
- the wrapper sniffs the path) with the arguments in BASE THEIRS OURS OUT order:
2585
-
2586
- patter mergetool --fallback "<your merge tool>" @basefile @sourcefile @destinationfile @output
2587
- `,
2588
- svn: `## SVN
2589
-
2590
- Set auto-props so Patter source keeps LF (\`~/.subversion/config\` or repo config):
2591
-
2592
- [auto-props]
2593
- *.patterflow = svn:eol-style=LF
2594
- *.patterloc = svn:eol-style=LF
2595
- *.patterx = svn:eol-style=LF
2596
- *.patterproj = svn:eol-style=LF
2597
- *.patterc = svn:eol-style=LF
2598
- *.patterpack = svn:mime-type=application/octet-stream
2599
-
2600
- SVN's ignore is a directory PROPERTY, not a file - set it from the project root:
2601
-
2602
- svn propset svn:ignore "*.patterpack${bundle === "ignore" ? "\\n*.patterc" : ""}\\n*.patterconflict" .
2603
-
2604
- For the lock-based workflow add \`svn:needs-lock\` to the shard patterns.
2605
-
2606
- SVN allows one global merge tool. Point \`[helpers] merge-tool-cmd\` at a small
2607
- wrapper script that forwards SVN's four arguments (base theirs mine merged =
2608
- BASE THEIRS OURS OUT) to \`patter mergetool\`:
2609
2163
 
2610
- #!/bin/sh
2611
- exec patter mergetool --fallback "<your merge tool>" "$1" "$2" "$3" "$4"
2612
- `
2164
+ // src/voice-script.ts
2165
+ 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";
2166
+ function plainVoice(text) {
2167
+ return text.replace(/<\/?(?:b|i|bi)>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
2168
+ }
2169
+ function groupLabel(node, source) {
2170
+ if (node.prompt) return plainVoice(source[node.prompt.id]?.trim() || "option");
2171
+ if (node.selector === "choice") return "";
2172
+ return node.selector ?? "group";
2173
+ }
2174
+ function runVoiceScript(loaded, opts = {}) {
2175
+ const { project } = loaded;
2176
+ const everything = !!opts.everything;
2177
+ const ladderDecls = project.writingStatuses ?? DEFAULT_WRITING_STATUSES2;
2178
+ const writingLadder = ladderDecls.map((s) => s.name);
2179
+ const recordingLadder = (project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES3).map((s) => s.name);
2180
+ const stub = writingLadder[0];
2181
+ const recordThreshold = ladderDecls.findIndex((s) => s.readyToRecord);
2182
+ const writingIndex = new Map(writingLadder.map((name, i) => [name, i]));
2183
+ const { writing: writingOf, recording: manualRecordingOf, cut: cutSet, rerecord: rerecordSet, documentation: docsOf } = mergeAuthoring2(loaded);
2184
+ const recordingBase = opts.recordingOverride ?? manualRecordingOf;
2185
+ const recordingOf = (id) => effectiveRecording(id, recordingBase, rerecordSet, recordingLadder[0]);
2186
+ const source = sourceStrings(loaded);
2187
+ const actorOf = /* @__PURE__ */ new Map();
2188
+ for (const c of project.cast ?? []) if (c.actor) actorOf.set(c.name, c.actor);
2189
+ const voClasses = classesForChannel(project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES2, "vo");
2190
+ const ownVo = (id) => (docsOf.get(id) ?? []).filter((d) => d.type !== void 0 && voClasses.has(d.type)).map((d) => d.text);
2191
+ const lines = [];
2192
+ const walkNode = (node, scope, ancestorVo) => {
2193
+ if (node.type === "group") {
2194
+ if (cutSet.has(node.id)) return;
2195
+ const label = groupLabel(node, source);
2196
+ const childScope = label ? [...scope, label] : scope;
2197
+ const childVo = [...ancestorVo, ...ownVo(node.id)];
2198
+ for (const child of node.children ?? []) walkNode(child, childScope, childVo);
2199
+ return;
2200
+ }
2201
+ if (cutSet.has(node.id)) return;
2202
+ let leading = true;
2203
+ for (const beat of node.beats ?? []) {
2204
+ if (beat.kind !== "line" || cutSet.has(beat.id)) continue;
2205
+ const ws = writingOf.get(beat.id) ?? stub;
2206
+ if (!everything && recordThreshold !== -1 && (writingIndex.get(ws) ?? 0) < recordThreshold) continue;
2207
+ const own = ownVo(beat.id);
2208
+ lines.push({
2209
+ scope: scope.join(" \u203A "),
2210
+ id: beat.id,
2211
+ character: beat.character ?? "",
2212
+ actor: beat.character ? actorOf.get(beat.character) : void 0,
2213
+ text: plainVoice(source[beat.id] ?? ""),
2214
+ comments: leading ? [...ancestorVo, ...own] : own,
2215
+ // first line of the run gets the enclosing context
2216
+ recordingStatus: recordingOf(beat.id)
2217
+ });
2218
+ leading = false;
2219
+ }
2613
2220
  };
2614
- const head = vcs ? sections[vcs] : `No VCS selected (\`patter init --vcs git|perforce|plastic|svn\` emits tailored
2615
- config). Whichever you adopt, the rules are the same: Patter source is UTF-8 +
2616
- LF text, one scene per file, and \`patter validate\` belongs in your pre-commit /
2617
- CI. Sections for every supported VCS:
2618
-
2619
- ${Object.keys(sections).map((k) => sections[k]).join("\n")}`;
2620
- return `# Version-control setup for this Patter project
2621
-
2622
- ${head}
2623
- ${bundleSection(bundle)}`;
2221
+ for (const scene of loaded.scenes) {
2222
+ if (cutSet.has(scene.id)) continue;
2223
+ const sceneVo = ownVo(scene.id);
2224
+ for (const block of scene.blocks) {
2225
+ if (cutSet.has(block.id)) continue;
2226
+ const scope = [scene.name, block.name].filter(Boolean);
2227
+ const blockVo = [...sceneVo, ...ownVo(block.id)];
2228
+ for (const child of block.children ?? []) walkNode(child, scope, blockVo);
2229
+ }
2230
+ }
2231
+ return { project: project.project.id, voiced: project.voiced ?? false, recordingLadder, everything, lines };
2624
2232
  }
2625
- function bundleSection(bundle) {
2626
- const posture = bundle === "commit" ? `This project COMMITS the compiled \`.patterc\` bundle (\`patter init --bundle ignore\`
2627
- to build it in CI instead). The bundle is regenerated, never hand-merged: on a
2628
- conflict, keep ours and re-run \`patter export\`. \`patter validate\` recomputes the
2629
- bundle's embedded hash from source and FAILS if it is stale, so a forgotten
2630
- regenerate cannot ship silently.` : `This project IGNORES the compiled \`.patterc\` bundle (built in CI; \`patter init
2631
- --bundle commit\` to commit it instead). Run \`patter export\` as a build step.`;
2632
- return `## Compiled bundle & packed document
2633
2233
 
2634
- ${posture}
2234
+ // src/voice-script-xlsx.ts
2235
+ async function voiceScriptToXlsx(data) {
2236
+ const { default: ExcelJS } = await import("exceljs");
2237
+ const wb = new ExcelJS.Workbook();
2238
+ wb.creator = "patter";
2239
+ const ws = wb.addWorksheet("Voice Script");
2240
+ ws.columns = [
2241
+ { header: "Scope", key: "scope", width: 28 },
2242
+ { header: "Line ID", key: "id", width: 18 },
2243
+ { header: "Character", key: "character", width: 16 },
2244
+ { header: "Actor", key: "actor", width: 16 },
2245
+ { header: "Text", key: "text", width: 60 },
2246
+ { header: "Comments", key: "comments", width: 36 },
2247
+ { header: "Status", key: "status", width: 12 }
2248
+ ];
2249
+ ws.getRow(1).font = { bold: true };
2250
+ ws.views = [{ state: "frozen", ySplit: 1 }];
2251
+ for (const l of data.lines) {
2252
+ const row = ws.addRow({
2253
+ scope: l.scope,
2254
+ id: l.id,
2255
+ character: l.character,
2256
+ actor: l.actor ?? "",
2257
+ text: l.text,
2258
+ comments: l.comments.join("\n"),
2259
+ status: l.recordingStatus
2260
+ });
2261
+ row.getCell("text").alignment = { wrapText: true, vertical: "top" };
2262
+ row.getCell("comments").alignment = { wrapText: true, vertical: "top" };
2263
+ }
2264
+ return Buffer.from(await wb.xlsx.writeBuffer());
2265
+ }
2635
2266
 
2636
- The packed \`.patterpack\` document (\`patter pack\`) is the send-and-return envelope
2637
- for collaborators without VCS. It is a binary zip and a projection of the
2638
- shards - NOT source - so it stays out of version control (ignored above); edits
2639
- return via \`patter unpack\`.
2640
- `;
2267
+ // src/script-doc.ts
2268
+ var TOKENS = {
2269
+ ink: "26221c",
2270
+ // near-black warm ink (headings)
2271
+ inkRead: "3a352d",
2272
+ // dialogue body (ink ~84% + muted)
2273
+ inkSoft: "5c554b",
2274
+ // narration (ink ~68% + muted) - a step softer than dialogue
2275
+ muted: "8c8479",
2276
+ // labels, quiet tags
2277
+ accent: "8a5a34",
2278
+ // structural marks: conditions, options, jumps, game events
2279
+ line: "e4dfd5"
2280
+ // hairlines / rules / the group rail
2281
+ };
2282
+ var CHAR_PALETTE = [
2283
+ "b23b3b",
2284
+ "b5642b",
2285
+ "927524",
2286
+ "6b7a31",
2287
+ "3f7d49",
2288
+ "2c7d6e",
2289
+ "2d7791",
2290
+ "3a6aa8",
2291
+ "5a5ba6",
2292
+ "7e4e9c",
2293
+ "9c4486",
2294
+ "a83e5c"
2295
+ ];
2296
+ function colourIndex(name) {
2297
+ let h = 2166136261;
2298
+ for (let i = 0; i < name.length; i++) {
2299
+ h ^= name.charCodeAt(i);
2300
+ h = Math.imul(h, 16777619);
2301
+ }
2302
+ h ^= h >>> 16;
2303
+ h = Math.imul(h, 2246822507);
2304
+ h ^= h >>> 13;
2305
+ h = Math.imul(h, 3266489909);
2306
+ h ^= h >>> 16;
2307
+ return (h >>> 0) % 12;
2641
2308
  }
2642
-
2643
- // src/resolve.ts
2644
- import { walkNodes as walkNodes4, effectiveGameId, DEFAULT_WRITING_STATUSES as DEFAULT_WRITING_STATUSES3, DEFAULT_RECORDING_STATUSES as DEFAULT_RECORDING_STATUSES4 } from "@patterkit/model";
2645
- function runResolve(loaded, query) {
2646
- const entries = indexProject(loaded);
2647
- const q = query.trim();
2648
- const ql = q.toLowerCase();
2649
- const tiers = [
2650
- (e) => e.id === q,
2651
- (e) => e.gameId?.toLowerCase() === ql,
2652
- (e) => e.name?.toLowerCase() === ql,
2653
- (e) => e.id.toLowerCase().includes(ql) || (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false)
2654
- ];
2655
- for (const match of tiers) {
2656
- const hits = entries.filter(match);
2657
- if (hits.length > 0) return hits;
2309
+ function characterColour(name) {
2310
+ return CHAR_PALETTE[colourIndex(name)];
2311
+ }
2312
+ var VISIT_FN_RE = /\b(patter_visits|patter_seen|visits|seen)\s*\(\s*(['"])(.*?)\2\s*\)/g;
2313
+ function humanizeCondition(cond, label) {
2314
+ return cond.replace(VISIT_FN_RE, (_m, fn, _q, id) => `${fn}(${label(id)})`);
2315
+ }
2316
+ var MARKUP_RE = /<(b|i|bi)>([\s\S]*?)<\/\1>/g;
2317
+ var INTERP_RE = /\{@[^}]*\}/g;
2318
+ function textRuns(text) {
2319
+ const marked = [];
2320
+ let last = 0;
2321
+ let m;
2322
+ MARKUP_RE.lastIndex = 0;
2323
+ while ((m = MARKUP_RE.exec(text)) !== null) {
2324
+ if (m.index > last) marked.push({ text: text.slice(last, m.index), bold: false, italic: false });
2325
+ const tag = m[1];
2326
+ marked.push({ text: m[2], bold: tag === "b" || tag === "bi", italic: tag === "i" || tag === "bi" });
2327
+ last = MARKUP_RE.lastIndex;
2658
2328
  }
2659
- return [];
2329
+ if (last < text.length) marked.push({ text: text.slice(last), bold: false, italic: false });
2330
+ if (!marked.length) marked.push({ text, bold: false, italic: false });
2331
+ const out = [];
2332
+ for (const r of marked) {
2333
+ let li = 0;
2334
+ let im;
2335
+ INTERP_RE.lastIndex = 0;
2336
+ while ((im = INTERP_RE.exec(r.text)) !== null) {
2337
+ if (im.index > li) out.push({ text: r.text.slice(li, im.index), bold: r.bold, italic: r.italic, code: false });
2338
+ out.push({ text: im[0], bold: r.bold, italic: r.italic, code: true });
2339
+ li = INTERP_RE.lastIndex;
2340
+ }
2341
+ if (li < r.text.length) out.push({ text: r.text.slice(li), bold: r.bold, italic: r.italic, code: false });
2342
+ }
2343
+ return out.length ? out : [{ text, bold: false, italic: false, code: false }];
2660
2344
  }
2661
- function runSearch(loaded, query, focus) {
2662
- const ql = query.trim().toLowerCase();
2663
- if (!ql) return [];
2664
- const entries = indexProject(loaded);
2665
- const inMeta = (e) => (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false);
2666
- const inText = (e) => e.text?.toLowerCase().includes(ql) ?? false;
2667
- const inTech = (e) => e.id.toLowerCase().includes(ql);
2668
- const matched = entries.map((e, i) => ({ e, i })).filter(({ e }) => inMeta(e) || inText(e) || inTech(e));
2669
- if (!focus) {
2670
- const meta = matched.filter(({ e }) => inMeta(e)).map((m) => m.e);
2671
- const text = matched.filter(({ e }) => !inMeta(e) && inText(e)).map((m) => m.e);
2672
- const tech = matched.filter(({ e }) => !inMeta(e) && !inText(e) && inTech(e)).map((m) => m.e);
2673
- return [...meta, ...text, ...tech];
2345
+ function targetLabel(to, sceneOf, blockTrail) {
2346
+ if (to === "END") return "END";
2347
+ return blockTrail.get(to) ?? sceneOf.get(to) ?? to;
2348
+ }
2349
+ function gameEventLabel(beat) {
2350
+ const gd = beat.gameData;
2351
+ if (gd && typeof gd === "object") {
2352
+ const parts = Object.entries(gd).map(([k, v]) => {
2353
+ const val = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
2354
+ return val === "" ? k : `${k}: ${val}`;
2355
+ });
2356
+ if (parts.length) return `game event \xB7 ${parts.join(", ")}`;
2674
2357
  }
2675
- const cut = focus.fromBeatId ? entries.findIndex((e) => e.id === focus.fromBeatId) : -1;
2676
- const group = (e, i) => e.sceneId !== focus.sceneId ? 2 : cut < 0 || i >= cut ? 0 : 1;
2677
- return matched.sort((a, b) => group(a.e, a.i) - group(b.e, b.i) || a.i - b.i).map((m) => m.e);
2358
+ return "game event";
2678
2359
  }
2679
- function runStatusBrowse(loaded, status, dimension = "writing", focus, recordingOverride) {
2680
- const strings = sourceStrings(loaded);
2681
- const merged = mergeAuthoring(loaded);
2682
- const recording = dimension === "recording";
2683
- const statusOf = recording ? recordingOverride ?? merged.recording : merged.writing;
2684
- const ladder = recording ? loaded.project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES4 : loaded.project.writingStatuses ?? DEFAULT_WRITING_STATUSES3;
2685
- const lowest = ladder[0]?.name;
2686
- const statusAt = (id) => recording ? effectiveRecording(id, statusOf, merged.rerecord, lowest ?? "") : statusOf.get(id) ?? lowest;
2687
- const out = [];
2360
+ function optionTag(node) {
2361
+ const parts = [];
2362
+ if (node.fallback) parts.push("fallback");
2363
+ parts.push(node.sticky ? "repeatable" : "once only");
2364
+ if (node.secretUntilEligible) parts.push("hidden");
2365
+ return parts.join(" \xB7 ");
2366
+ }
2367
+ function sequenceLabel(node) {
2368
+ const o = node.options ?? {};
2369
+ const bits = [];
2370
+ if ((o.order ?? "sequential") === "shuffle") bits.push("shuffled");
2371
+ const ex = o.exhaust ?? "once";
2372
+ if (ex === "repeat") bits.push("repeating");
2373
+ else if (ex === "stick") bits.push("holds on the last");
2374
+ return bits.length ? `In sequence \xB7 ${bits.join(", ")}` : "In sequence";
2375
+ }
2376
+ function runScriptDoc(loaded) {
2377
+ const { project } = loaded;
2378
+ const source = sourceStrings(loaded);
2379
+ const { cut } = mergeAuthoring2(loaded);
2380
+ const sceneOf = /* @__PURE__ */ new Map();
2381
+ const blockTrail = /* @__PURE__ */ new Map();
2382
+ const blockName = /* @__PURE__ */ new Map();
2688
2383
  for (const scene of loaded.scenes) {
2689
- const file = loaded.sceneFiles[scene.id];
2384
+ sceneOf.set(scene.id, scene.name);
2690
2385
  for (const block of scene.blocks) {
2691
- const segments = [scene.name, block.name];
2692
- const consider = (beat) => {
2693
- const tracked = recording ? beat.kind === "line" : beat.kind === "line" || beat.kind === "text";
2694
- if (!tracked) return;
2695
- if (statusAt(beat.id) !== status) return;
2696
- out.push({ id: beat.id, kind: "beat", text: strings[beat.id], location: segments, sceneId: scene.id, file });
2697
- };
2698
- walkNodes4(block.children, (node) => {
2699
- if (node.type === "group") {
2700
- if (node.prompt) consider(node.prompt);
2701
- return;
2386
+ blockTrail.set(block.id, `${scene.name} \u203A ${block.name}`);
2387
+ blockName.set(block.id, block.name);
2388
+ }
2389
+ }
2390
+ const humanize = (cond) => humanizeCondition(cond, (id) => blockName.get(id) ?? sceneOf.get(id) ?? id);
2391
+ const els = [];
2392
+ const textOf = (id) => source[id] ?? "";
2393
+ const jumpText = (j) => {
2394
+ const dest = targetLabel(j.to, sceneOf, blockTrail);
2395
+ return j.mode === "call" ? `${dest} (and return)` : dest;
2396
+ };
2397
+ let snippetSeq = 0;
2398
+ const nextSid = () => ++snippetSeq;
2399
+ const emitBeats = (node, indent, sid) => {
2400
+ for (const beat of node.beats ?? []) {
2401
+ if (cut.has(beat.id)) continue;
2402
+ if (beat.kind === "line") els.push({ kind: "line", indent, snippet: sid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
2403
+ else if (beat.kind === "text") els.push({ kind: "narration", indent, snippet: sid, runs: textRuns(textOf(beat.id)) });
2404
+ else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent, snippet: sid, text: gameEventLabel(beat) });
2405
+ }
2406
+ if (node.jump) els.push({ kind: "jump", indent, snippet: sid, text: jumpText(node.jump) });
2407
+ };
2408
+ const walk = (node, indent, sid) => {
2409
+ if (cut.has(node.id)) return;
2410
+ if (node.type === "snippet") {
2411
+ if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
2412
+ emitBeats(node, indent, sid);
2413
+ return;
2414
+ }
2415
+ if (node.condition) els.push({ kind: "condition", indent, snippet: sid, text: `if ${humanize(node.condition)}` });
2416
+ if (node.selector === "choice") {
2417
+ els.push({ kind: "group", indent, label: "Choose" });
2418
+ for (const child of node.children ?? []) {
2419
+ if (cut.has(child.id)) continue;
2420
+ const cid = nextSid();
2421
+ if (child.type === "group" && child.prompt) {
2422
+ if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
2423
+ els.push({ kind: "option", indent, snippet: cid, runs: textRuns(textOf(child.prompt.id) || "(option)"), tag: optionTag(child) });
2424
+ for (const c of child.children ?? []) walk(c, indent + 1, cid);
2425
+ } else if (child.type === "snippet") {
2426
+ const beats = child.beats ?? [];
2427
+ const first = beats.find((b) => b.kind === "line" || b.kind === "text");
2428
+ if (child.condition) els.push({ kind: "condition", indent, snippet: cid, text: `if ${humanize(child.condition)}` });
2429
+ els.push({ kind: "option", indent, snippet: cid, runs: textRuns(first ? textOf(first.id) || "(option)" : "(option)"), tag: optionTag(child) });
2430
+ let seenPrompt = false;
2431
+ for (const beat of beats) {
2432
+ if (!seenPrompt && beat === first) {
2433
+ seenPrompt = true;
2434
+ continue;
2435
+ }
2436
+ if (cut.has(beat.id)) continue;
2437
+ if (beat.kind === "line") els.push({ kind: "line", indent: indent + 1, snippet: cid, character: beat.character ?? "", direction: beat.direction, runs: textRuns(textOf(beat.id)) });
2438
+ else if (beat.kind === "text") els.push({ kind: "narration", indent: indent + 1, snippet: cid, runs: textRuns(textOf(beat.id)) });
2439
+ else if (beat.kind === "gameEvent") els.push({ kind: "gameEvent", indent: indent + 1, snippet: cid, text: gameEventLabel(beat) });
2440
+ }
2441
+ if (child.jump) els.push({ kind: "jump", indent: indent + 1, snippet: cid, text: jumpText(child.jump) });
2442
+ } else {
2443
+ walk(child, indent + 1, cid);
2702
2444
  }
2703
- for (const beat of node.beats ?? []) consider(beat);
2445
+ }
2446
+ return;
2447
+ }
2448
+ if (node.selector === "branch") {
2449
+ els.push({ kind: "group", indent, label: "One of \xB7 first match wins" });
2450
+ const kids = (node.children ?? []).filter((c) => !cut.has(c.id));
2451
+ kids.forEach((child, i) => {
2452
+ const isCatchAll = i === kids.length - 1 && kids.length > 1 && !("condition" in child && child.condition);
2453
+ if (isCatchAll) els.push({ kind: "else", indent });
2454
+ walk(child, indent, nextSid());
2704
2455
  });
2456
+ return;
2705
2457
  }
2706
- }
2707
- if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2708
- return out;
2709
- }
2710
- function listProjectTags(loaded) {
2711
- const counts = /* @__PURE__ */ new Map();
2712
- const add = (tags) => {
2713
- for (const t of tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1);
2458
+ if (node.selector === "sequence") {
2459
+ els.push({ kind: "group", indent, label: sequenceLabel(node) });
2460
+ for (const child of node.children ?? []) walk(child, indent, nextSid());
2461
+ return;
2462
+ }
2463
+ for (const child of node.children ?? []) walk(child, indent, sid);
2714
2464
  };
2715
2465
  for (const scene of loaded.scenes) {
2716
- add(scene.tags);
2466
+ if (cut.has(scene.id)) continue;
2467
+ els.push({ kind: "scene", text: scene.name });
2717
2468
  for (const block of scene.blocks) {
2718
- add(block.tags);
2719
- walkNodes4(block.children, (node) => {
2720
- add(node.tags);
2721
- if (node.type === "group") {
2722
- add(node.prompt?.tags);
2723
- return;
2724
- }
2725
- for (const beat of node.beats ?? []) add(beat.tags);
2469
+ if (cut.has(block.id)) continue;
2470
+ els.push({ kind: "block", text: block.name });
2471
+ for (const child of block.children ?? []) walk(child, 0, void 0);
2472
+ }
2473
+ }
2474
+ return { project: project.project.name, elements: els };
2475
+ }
2476
+
2477
+ // src/script-docx.ts
2478
+ import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, TabStopType, TextRun } from "docx";
2479
+ var SERIF = "Georgia";
2480
+ var SANS = "Calibri";
2481
+ var MONO = "Consolas";
2482
+ var STEP = 360;
2483
+ var SNIPPET_INSET = 220;
2484
+ var DIALOGUE_INDENT = 300;
2485
+ var HANG = 340;
2486
+ var RIGHT_TAB = 9020;
2487
+ var SNIP_GAP = 170;
2488
+ var WITHIN = 30;
2489
+ var S = { h1: 32, h2: 26, body: 22, cue: 18, label: 16, tag: 15, mech: 18 };
2490
+ function bodyRuns(runs, color, base = {}) {
2491
+ 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 }));
2492
+ }
2493
+ var leftOf = (el) => ("indent" in el ? el.indent * STEP : 0) + ("snippet" in el && el.snippet !== void 0 ? SNIPPET_INSET : 0);
2494
+ function paragraph(el, prev) {
2495
+ const sid = "snippet" in el ? el.snippet : void 0;
2496
+ const startsSnippet = sid !== void 0 && (!prev || !("snippet" in prev) || prev.snippet !== sid);
2497
+ const before = sid !== void 0 ? startsSnippet ? SNIP_GAP : WITHIN : 100;
2498
+ switch (el.kind) {
2499
+ case "scene":
2500
+ return new Paragraph({
2501
+ heading: HeadingLevel.HEADING_1,
2502
+ keepNext: true,
2503
+ spacing: { before: 480, after: 200 },
2504
+ border: { bottom: { style: BorderStyle.SINGLE, size: 14, color: TOKENS.accent, space: 6 } },
2505
+ children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h1 })]
2726
2506
  });
2727
- }
2728
- }
2729
- return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
2730
- }
2731
- function runTagBrowse(loaded, tag, focus) {
2732
- if (!tag) return [];
2733
- const strings = sourceStrings(loaded);
2734
- const has = (tags) => !!tags && tags.includes(tag);
2735
- const out = [];
2736
- for (const scene of loaded.scenes) {
2737
- const file = loaded.sceneFiles[scene.id];
2738
- if (has(scene.tags)) out.push({ id: scene.id, kind: "scene", name: scene.name, gameId: effectiveGameId(scene), location: [scene.name], sceneId: scene.id, file });
2739
- for (const block of scene.blocks) {
2740
- const segments = [scene.name, block.name];
2741
- if (has(block.tags)) out.push({ id: block.id, kind: "block", name: block.name, gameId: effectiveGameId(block), location: segments, sceneId: scene.id, file });
2742
- walkNodes4(block.children, (node) => {
2743
- if (has(node.tags)) {
2744
- const preview = node.type === "group" ? node.prompt ? strings[node.prompt.id] : void 0 : strings[node.beats?.[0]?.id ?? ""];
2745
- out.push({ id: node.id, kind: node.type, text: preview, location: segments, sceneId: scene.id, file });
2746
- }
2747
- if (node.type === "group") {
2748
- 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 });
2749
- return;
2750
- }
2751
- 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 });
2507
+ case "block":
2508
+ return new Paragraph({
2509
+ heading: HeadingLevel.HEADING_2,
2510
+ keepNext: true,
2511
+ spacing: { before: 360, after: 160 },
2512
+ border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: TOKENS.line, space: 4 } },
2513
+ children: [new TextRun({ text: el.text, font: SERIF, bold: true, color: TOKENS.ink, size: S.h2 })]
2752
2514
  });
2515
+ case "line": {
2516
+ const kids = [];
2517
+ 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 }));
2518
+ if (el.direction) kids.push(new TextRun({ text: `(${el.direction}) `, font: SERIF, italics: true, color: TOKENS.muted, size: S.body }));
2519
+ kids.push(...bodyRuns(el.runs, TOKENS.inkRead));
2520
+ return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) + DIALOGUE_INDENT + HANG, hanging: HANG }, children: kids });
2521
+ }
2522
+ case "narration":
2523
+ return new Paragraph({ keepLines: true, spacing: { before, after: 20 }, indent: { left: leftOf(el) }, children: bodyRuns(el.runs, TOKENS.inkSoft) });
2524
+ case "condition":
2525
+ 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 })] });
2526
+ case "group":
2527
+ 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 })] });
2528
+ case "else":
2529
+ 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 })] });
2530
+ case "option": {
2531
+ const kids = [new TextRun({ text: "\u25C7 ", color: TOKENS.accent, size: S.body }), ...bodyRuns(el.runs, TOKENS.ink)];
2532
+ 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 }));
2533
+ 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 });
2753
2534
  }
2535
+ case "jump":
2536
+ 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 })] });
2537
+ case "gameEvent":
2538
+ 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 })] });
2754
2539
  }
2755
- if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2756
- return out;
2757
2540
  }
2758
- function runPropertyUsage(loaded, query, focus) {
2759
- const parsed = parsePropertyQuery(query);
2760
- if (!parsed) return [];
2761
- const { regexes, value } = parsed;
2762
- const strings = sourceStrings(loaded);
2763
- const refs = (src) => !!src && regexes.some((re) => re.test(src));
2764
- const valueOk = (src) => !value || src.toLowerCase().includes(value);
2765
- const out = [];
2766
- for (const scene of loaded.scenes) {
2767
- const file = loaded.sceneFiles[scene.id];
2768
- const push = (id, kind, segments, usage) => {
2769
- out.push({ id, kind, text: usage, location: segments, sceneId: scene.id, file });
2770
- };
2771
- for (const e of scene.onEntry ?? []) {
2772
- const src = `${e.target} = ${e.value}`;
2773
- if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(scene.id, "scene", [scene.name], `on entry: set ${src}`);
2541
+ async function scriptToDocx(doc) {
2542
+ const body = [
2543
+ new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: "Readable script", font: SANS, bold: true, allCaps: true, color: TOKENS.accent, size: 16, characterSpacing: 30 })] }),
2544
+ new Paragraph({ spacing: { after: 560 }, children: [new TextRun({ text: doc.project, font: SERIF, bold: true, color: TOKENS.ink, size: 52 })] }),
2545
+ ...doc.elements.map((el, i) => paragraph(el, doc.elements[i - 1]))
2546
+ ];
2547
+ const document = new Document({ creator: "Patter", title: doc.project, sections: [{ children: body }] });
2548
+ return Packer.toBuffer(document);
2549
+ }
2550
+
2551
+ // src/script-pdf.ts
2552
+ import PDFDocument from "pdfkit";
2553
+ var MARGIN = 64;
2554
+ var INDENT_STEP = 20;
2555
+ var EDGE_INSET = 12;
2556
+ var EDGE_OFF = 3;
2557
+ var DIALOGUE_INDENT2 = 14;
2558
+ var HANG2 = 18;
2559
+ var CUE_GAP = 7;
2560
+ var BODY = 11;
2561
+ var CUE = 8.5;
2562
+ var LEAD = 3.5;
2563
+ var WITHIN2 = 4;
2564
+ var BETWEEN = 10;
2565
+ var BEAT = 6;
2566
+ var TIGHT = 3;
2567
+ var hex = (h) => `#${h}`;
2568
+ var INK = hex(TOKENS.ink);
2569
+ var INK_READ = hex(TOKENS.inkRead);
2570
+ var INK_SOFT = hex(TOKENS.inkSoft);
2571
+ var MUTED = hex(TOKENS.muted);
2572
+ var ACCENT = hex(TOKENS.accent);
2573
+ var LINE = hex(TOKENS.line);
2574
+ var EDGE = "#d7d0c3";
2575
+ var SERIF2 = (bold, italic) => bold && italic ? "Serif-BoldItalic" : bold ? "Serif-Bold" : italic ? "Serif-Italic" : "Serif";
2576
+ var isEmoji = (cp) => cp >= 126976 || cp >= 9728 && cp <= 10175;
2577
+ async function scriptToPdf(doc) {
2578
+ const { scriptFont } = await import("./script-fonts-AQNQOADI.js");
2579
+ return new Promise((resolve5, reject) => {
2580
+ const pdf = new PDFDocument({ size: "A4", margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN }, info: { Title: doc.project, Author: "Patter" }, font: null });
2581
+ pdf.registerFont("Serif", scriptFont("serif"));
2582
+ pdf.registerFont("Serif-Bold", scriptFont("serifBold"));
2583
+ pdf.registerFont("Serif-Italic", scriptFont("serifItalic"));
2584
+ pdf.registerFont("Serif-BoldItalic", scriptFont("serifBoldItalic"));
2585
+ pdf.registerFont("Sans", scriptFont("sans"));
2586
+ pdf.registerFont("Sans-Bold", scriptFont("sansBold"));
2587
+ pdf.registerFont("Mono", scriptFont("mono"));
2588
+ pdf.registerFont("Symbol", scriptFont("symbol"));
2589
+ pdf.registerFont("Emoji", scriptFont("emoji"));
2590
+ const coverage = {};
2591
+ const ascender = {};
2592
+ for (const name of ["Serif", "Serif-Bold", "Serif-Italic", "Serif-BoldItalic", "Sans", "Sans-Bold", "Mono"]) {
2593
+ pdf.font(name);
2594
+ const f = pdf._font;
2595
+ coverage[name] = (cp) => f.font.hasGlyphForCodePoint(cp);
2596
+ ascender[name] = f.ascender;
2774
2597
  }
2775
- for (const block of scene.blocks) {
2776
- const segments = [scene.name, block.name];
2777
- walkNodes4(block.children, (node) => {
2778
- if (refs(node.condition) && valueOk(node.condition)) push(node.id, node.type, segments, `if ${node.condition}`);
2779
- if (node.type === "group") {
2780
- if (node.prompt && refs(strings[node.prompt.id]) && valueOk(strings[node.prompt.id])) push(node.prompt.id, "beat", segments, strings[node.prompt.id]);
2781
- return;
2598
+ const ascentPt = (name, size) => ascender[name] / 1e3 * size;
2599
+ const split = (text, primary, color) => {
2600
+ const cov = coverage[primary];
2601
+ const out = [];
2602
+ let buf = "";
2603
+ let cur = primary;
2604
+ for (const ch of text) {
2605
+ const cp = ch.codePointAt(0);
2606
+ const f = isEmoji(cp) ? "Emoji" : cov(cp) ? primary : "Symbol";
2607
+ if (f !== cur) {
2608
+ if (buf) out.push({ text: buf, font: cur, color });
2609
+ buf = "";
2610
+ cur = f;
2782
2611
  }
2783
- for (const phase of ["onEnter", "onExit"]) {
2784
- for (const e of node[phase] ?? []) {
2785
- const src = `${e.target} = ${e.value}`;
2786
- if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(node.id, "snippet", segments, `${phase === "onEnter" ? "on enter" : "on exit"}: set ${src}`);
2612
+ buf += ch;
2613
+ }
2614
+ if (buf) out.push({ text: buf, font: cur, color });
2615
+ return out;
2616
+ };
2617
+ 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));
2618
+ const chunks = [];
2619
+ pdf.on("data", (c) => chunks.push(c));
2620
+ pdf.on("end", () => resolve5(Buffer.concat(chunks)));
2621
+ pdf.on("error", reject);
2622
+ const pageRight = pdf.page.width - MARGIN;
2623
+ const pageBottom = pdf.page.height - MARGIN;
2624
+ const contentW = pdf.page.width - MARGIN * 2;
2625
+ const ensure = (space) => {
2626
+ if (pdf.y > pageBottom - space) pdf.addPage();
2627
+ };
2628
+ const draw = (pieces, x, opts) => {
2629
+ pieces.forEach((p, i) => {
2630
+ pdf.font(p.font).fillColor(p.color);
2631
+ const cont = i < pieces.length - 1;
2632
+ if (i === 0) pdf.text(p.text, x, pdf.y, { ...opts, continued: cont });
2633
+ else pdf.text(p.text, { continued: cont });
2634
+ });
2635
+ };
2636
+ const drawRight = (pieces, size) => {
2637
+ let total = 0;
2638
+ for (const p of pieces) {
2639
+ pdf.font(p.font).fontSize(size);
2640
+ total += pdf.widthOfString(p.text);
2641
+ }
2642
+ const x = Math.max(MARGIN, pageRight - total);
2643
+ pieces.forEach((p, i) => {
2644
+ pdf.font(p.font).fillColor(p.color).fontSize(size);
2645
+ const cont = i < pieces.length - 1;
2646
+ if (i === 0) pdf.text(p.text, x, pdf.y, { continued: cont });
2647
+ else pdf.text(p.text, { continued: cont });
2648
+ });
2649
+ };
2650
+ pdf.fontSize(9);
2651
+ draw(split("READABLE SCRIPT", "Sans-Bold", ACCENT), MARGIN, { characterSpacing: 2 });
2652
+ pdf.moveDown(0.4).fontSize(26);
2653
+ draw(split(doc.project, "Serif-Bold", INK), MARGIN, { width: contentW });
2654
+ pdf.strokeColor(ACCENT).lineWidth(2).moveTo(MARGIN, pdf.y + 10).lineTo(MARGIN + 40, pdf.y + 10).stroke();
2655
+ pdf.y += 28;
2656
+ const drawContent = (el, cx) => {
2657
+ const w = pageRight - cx;
2658
+ switch (el.kind) {
2659
+ case "scene":
2660
+ pdf.fontSize(17);
2661
+ draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2662
+ pdf.strokeColor(ACCENT).lineWidth(1.5).moveTo(MARGIN, pdf.y + 4).lineTo(MARGIN + 34, pdf.y + 4).stroke();
2663
+ break;
2664
+ case "block":
2665
+ pdf.fontSize(13);
2666
+ draw(split(el.text, "Serif-Bold", INK), MARGIN, { width: contentW });
2667
+ pdf.strokeColor(LINE).lineWidth(0.75).moveTo(MARGIN, pdf.y + 3).lineTo(pageRight, pdf.y + 3).stroke();
2668
+ break;
2669
+ case "line": {
2670
+ const cueX = cx + DIALOGUE_INDENT2;
2671
+ let bodyX = cueX;
2672
+ if (el.character) {
2673
+ pdf.font("Sans-Bold").fontSize(CUE);
2674
+ bodyX = cueX + pdf.widthOfString(el.character.toUpperCase()) + CUE_GAP;
2675
+ }
2676
+ const yTop = pdf.y;
2677
+ if (el.character) {
2678
+ 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 });
2679
+ pdf.y = yTop;
2787
2680
  }
2681
+ pdf.fontSize(BODY);
2682
+ draw([...el.direction ? split(`(${el.direction}) `, "Serif-Italic", MUTED) : [], ...bodyPieces(el.runs, INK_READ)], bodyX, { width: Math.max(120, pageRight - bodyX), lineGap: LEAD });
2683
+ break;
2788
2684
  }
2789
- for (const beat of node.beats ?? []) {
2790
- if (refs(strings[beat.id]) && valueOk(strings[beat.id])) push(beat.id, "beat", segments, strings[beat.id]);
2685
+ case "narration":
2686
+ pdf.fontSize(BODY);
2687
+ draw(bodyPieces(el.runs, INK_SOFT), cx, { width: w, lineGap: LEAD });
2688
+ break;
2689
+ case "condition":
2690
+ pdf.fontSize(9.5);
2691
+ draw(split(`\u2039 ${el.text} \u203A`, "Mono", ACCENT), cx, { width: w });
2692
+ break;
2693
+ case "group":
2694
+ pdf.fontSize(8);
2695
+ draw(split(el.label.toUpperCase(), "Sans-Bold", ACCENT), cx, { width: w, characterSpacing: 1.2 });
2696
+ break;
2697
+ case "else":
2698
+ pdf.fontSize(8);
2699
+ draw(split("ELSE \xB7 CATCH-ALL", "Sans", MUTED), cx, { width: w, characterSpacing: 1 });
2700
+ break;
2701
+ case "option": {
2702
+ const ox = cx + HANG2;
2703
+ const tag = el.tag ? el.tag.toUpperCase() : "";
2704
+ const tagW = tag ? pdf.font("Sans").fontSize(7.5).widthOfString(tag) + 8 : 0;
2705
+ const yStart = pdf.y;
2706
+ if (tag) {
2707
+ pdf.font("Sans").fontSize(7.5).fillColor(MUTED).text(tag, pageRight - tagW, yStart, { width: tagW, align: "right", lineBreak: false });
2708
+ pdf.y = yStart;
2709
+ }
2710
+ pdf.fontSize(BODY);
2711
+ draw([...split("\u25C7 ", "Serif", ACCENT), ...bodyPieces(el.runs, INK)], ox, { width: Math.max(80, pageRight - ox - tagW), indent: -HANG2, lineGap: LEAD });
2712
+ break;
2791
2713
  }
2792
- });
2793
- }
2794
- }
2795
- if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
2796
- return out;
2797
- }
2798
- function parsePropertyQuery(query) {
2799
- const m = /^@?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)(.*)$/.exec(query.trim());
2800
- if (!m) return null;
2801
- const prop = m[1];
2802
- const value = m[2].replace(/^[\s=<>!]+/, "").replace(/^["']|["']$/g, "").trim().toLowerCase() || void 0;
2803
- const esc2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2804
- const regexes = prop.includes(".") ? [new RegExp(`@${esc2(prop)}\\b`, "i")] : [new RegExp(`@${esc2(prop)}\\b`, "i"), new RegExp(`@patter\\.${esc2(prop)}\\b`, "i")];
2805
- return { regexes, value };
2806
- }
2807
- var indexCache = null;
2808
- function sameRefs(a, b) {
2809
- if (a.length !== b.length) return false;
2810
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2811
- return true;
2812
- }
2813
- function indexProject(loaded) {
2814
- if (indexCache && indexCache.loaded === loaded && sameRefs(indexCache.scenes, loaded.scenes) && sameRefs(indexCache.locales, loaded.locales)) {
2815
- return indexCache.entries;
2816
- }
2817
- const entries = buildIndex(loaded);
2818
- indexCache = { loaded, scenes: [...loaded.scenes], locales: [...loaded.locales], entries };
2819
- return entries;
2820
- }
2821
- function buildIndex(loaded) {
2822
- const out = [];
2823
- const strings = sourceStrings(loaded);
2824
- for (const scene of loaded.scenes) {
2825
- const file = loaded.sceneFiles[scene.id];
2826
- out.push({
2827
- id: scene.id,
2828
- kind: "scene",
2829
- name: scene.name,
2830
- gameId: effectiveGameId(scene),
2831
- location: [scene.name],
2832
- sceneId: scene.id,
2833
- file
2834
- });
2835
- for (const block of scene.blocks) {
2836
- const segments = [scene.name, block.name];
2837
- out.push({
2838
- id: block.id,
2839
- kind: "block",
2840
- name: block.name,
2841
- gameId: effectiveGameId(block),
2842
- location: segments,
2843
- sceneId: scene.id,
2844
- file
2845
- });
2846
- walkNodes4(block.children, (node) => {
2847
- out.push({
2848
- id: node.id,
2849
- kind: node.type,
2850
- location: segments,
2851
- sceneId: scene.id,
2852
- file
2853
- });
2854
- if (node.type === "group" && node.prompt) {
2855
- out.push({
2856
- id: node.prompt.id,
2857
- kind: "beat",
2858
- text: strings[node.prompt.id],
2859
- location: segments,
2860
- sceneId: scene.id,
2861
- file
2862
- });
2863
- return;
2714
+ case "jump":
2715
+ drawRight(split(`\u21AA ${el.text}`, "Sans-Bold", ACCENT), 9.5);
2716
+ break;
2717
+ case "gameEvent": {
2718
+ const pieces = split(`\u2699 ${el.text}`, "Mono", ACCENT);
2719
+ pdf.fontSize(9);
2720
+ let total = 0;
2721
+ for (const p of pieces) {
2722
+ pdf.font(p.font);
2723
+ total += pdf.widthOfString(p.text);
2724
+ }
2725
+ if (total <= pageRight - cx) drawRight(pieces, 9);
2726
+ else draw(pieces, cx, { width: pageRight - cx });
2727
+ break;
2864
2728
  }
2865
- if (node.type !== "snippet") return;
2866
- for (const beat of node.beats ?? []) {
2867
- out.push({
2868
- id: beat.id,
2869
- kind: "beat",
2870
- text: strings[beat.id],
2871
- location: segments,
2872
- sceneId: scene.id,
2873
- file
2874
- });
2729
+ }
2730
+ };
2731
+ const leadOf = (k) => k === "scene" ? 24 : k === "block" ? 16 : k === "group" ? 13 : k === "else" ? 9 : 0;
2732
+ const els = doc.elements;
2733
+ let curSid;
2734
+ let curBase = 0;
2735
+ for (let i = 0; i < els.length; i++) {
2736
+ const el = els[i];
2737
+ const next = els[i + 1];
2738
+ const sid = "snippet" in el ? el.snippet : void 0;
2739
+ const sameNext = sid !== void 0 && next !== void 0 && "snippet" in next && next.snippet === sid;
2740
+ if (el.kind === "scene") ensure(90);
2741
+ else if (el.kind === "block") ensure(70);
2742
+ else ensure(leadOf(el.kind) + BODY + LEAD + 14);
2743
+ pdf.y += leadOf(el.kind);
2744
+ const base = "indent" in el ? MARGIN + el.indent * INDENT_STEP : MARGIN;
2745
+ const cx = base + (sid !== void 0 ? EDGE_INSET : 0);
2746
+ const yTop = pdf.y;
2747
+ const pageBefore = pdf.page;
2748
+ drawContent(el, cx);
2749
+ const bottom = pdf.y;
2750
+ 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;
2751
+ if (sid !== void 0) {
2752
+ if (sid !== curSid) {
2753
+ curSid = sid;
2754
+ curBase = "indent" in el ? el.indent : 0;
2875
2755
  }
2876
- });
2756
+ const ex = MARGIN + curBase * INDENT_STEP + EDGE_OFF;
2757
+ const top = pdf.page === pageBefore ? yTop : MARGIN;
2758
+ pdf.strokeColor(EDGE).lineWidth(0.75).moveTo(ex, top).lineTo(ex, bottom + (sameNext ? trail : 0)).stroke();
2759
+ }
2760
+ pdf.y = bottom + trail;
2877
2761
  }
2878
- }
2879
- return out;
2762
+ pdf.end();
2763
+ });
2880
2764
  }
2881
2765
 
2882
- // src/replace.ts
2883
- import { canonicalStringify as canonicalStringify5 } from "@patterkit/core";
2884
- import { walkNodes as walkNodes5, PROJECT_LOCALE_SCENE as PROJECT_LOCALE_SCENE2 } from "@patterkit/model";
2885
- function matcher(opts) {
2886
- if (!opts.query) return null;
2887
- const esc2 = opts.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2888
- const body = opts.wholeWord ? `\\b${esc2}\\b` : esc2;
2889
- return new RegExp(body, opts.caseSensitive ? "g" : "gi");
2890
- }
2891
- function runReplace(loaded, opts) {
2892
- const plan = { hits: [], writes: [], shards: [], scenes: 0 };
2893
- const re = matcher(opts);
2894
- if (!re) return plan;
2895
- const def = loaded.project.locales.default;
2896
- const locOf = /* @__PURE__ */ new Map();
2897
- const sceneName = /* @__PURE__ */ new Map();
2898
- for (const scene of loaded.scenes) {
2899
- sceneName.set(scene.id, scene.name);
2900
- for (const block of scene.blocks) {
2901
- const segs = [scene.name, block.name];
2902
- walkNodes5(block.children, (node) => {
2903
- if (node.type === "group") {
2904
- if (node.prompt) locOf.set(node.prompt.id, segs);
2905
- return;
2906
- }
2907
- for (const b of node.beats ?? []) locOf.set(b.id, segs);
2908
- });
2909
- }
2766
+ // src/init.ts
2767
+ import { join as join4, basename as basename5, resolve as resolve3 } from "path";
2768
+ import { readdirSync as readdirSync3, existsSync as existsSync2 } from "fs";
2769
+ import { newId, slug, canonicalStringify as canonicalStringify5 } from "@patterkit/core";
2770
+ function runInit(opts) {
2771
+ const dir = resolve3(opts.dir);
2772
+ const name = opts.name?.trim() || basename5(dir).replace(/\.patter$/, "");
2773
+ const fileSlug = slug(name);
2774
+ let existing = [];
2775
+ try {
2776
+ existing = readdirSync3(dir).filter((f) => f.endsWith(".patterproj"));
2777
+ } catch {
2910
2778
  }
2911
- for (let i = 0; i < loaded.locales.length; i++) {
2912
- const shard = loaded.locales[i];
2913
- if (shard.locale !== def || shard.scene === PROJECT_LOCALE_SCENE2) continue;
2914
- const path = loaded.localeFiles[i];
2915
- if (!path) continue;
2916
- let changed = false;
2917
- const merged = { ...shard.strings };
2918
- for (const [id, before] of Object.entries(shard.strings)) {
2919
- if (opts.onlyId && id !== opts.onlyId) continue;
2920
- re.lastIndex = 0;
2921
- if (!re.test(before)) continue;
2922
- const after = before.replace(re, () => opts.replacement);
2923
- if (after === before) continue;
2924
- plan.hits.push({ id, sceneId: shard.scene, location: locOf.get(id) ?? [sceneName.get(shard.scene) ?? shard.scene], before, after });
2925
- merged[id] = after;
2926
- changed = true;
2927
- }
2928
- if (changed) {
2929
- const next = { ...shard, strings: merged };
2930
- plan.writes.push({ path, content: canonicalStringify5(next) });
2931
- plan.shards.push(next);
2932
- plan.scenes++;
2779
+ if (existing.length > 0) throw new Error(`a project already exists here: ${existing[0]}`);
2780
+ const sceneId = newId("scn");
2781
+ const beatId = newId("T");
2782
+ const project = {
2783
+ schema: "patter/project@0",
2784
+ project: { id: newId("proj"), name },
2785
+ locales: { default: "en", all: ["en"] },
2786
+ ...opts.vcs ? { vcs: opts.vcs } : {},
2787
+ // record the chosen VCS so it can be read / switched later
2788
+ voiced: true
2789
+ };
2790
+ const flow = {
2791
+ schema: "patter/flow@0",
2792
+ scene: {
2793
+ id: sceneId,
2794
+ type: "scene",
2795
+ name: "Start",
2796
+ blocks: [{
2797
+ id: newId("blk"),
2798
+ type: "block",
2799
+ name: "Main",
2800
+ children: [{
2801
+ id: newId("sn"),
2802
+ type: "snippet",
2803
+ beats: [{ id: beatId, kind: "text" }],
2804
+ jump: { to: "END" }
2805
+ }]
2806
+ }]
2933
2807
  }
2934
- }
2935
- return plan;
2936
- }
2937
-
2938
- // src/pack.ts
2939
- import JSZip from "jszip";
2940
- import { readFileSync as readFileSync4 } from "fs";
2941
- import { dirname as dirname4, relative, sep as sep2 } from "path";
2942
- import { parseSource as parseSource4 } from "@patterkit/core";
2943
- var SHARD_EXTENSIONS = [".patterflow", ".patterloc", ".patterx", ".patterproj"];
2944
- var FIXED_DATE = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
2945
- var ENTRY_OPTS = { date: FIXED_DATE, createFolders: false };
2946
- async function runPack(startPath) {
2947
- const projectFile = findProjectFile(startPath);
2948
- const root = dirname4(projectFile);
2949
- const project = parseSource4(readFileSync4(projectFile, "utf8"));
2950
- 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));
2951
- const manifest = {
2952
- schema: "patter/document@0",
2953
- project: { id: project.project.id, name: project.project.name },
2954
- files: files.map((f) => f.rel)
2955
2808
  };
2956
- const zip = new JSZip();
2957
- zip.file("patter.manifest.json", JSON.stringify(manifest, null, 2) + "\n", ENTRY_OPTS);
2958
- for (const f of files) zip.file(f.rel, readFileSync4(f.abs, "utf8"), ENTRY_OPTS);
2959
- return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", streamFiles: false });
2809
+ const locale = {
2810
+ schema: "patter/strings@0",
2811
+ scene: sceneId,
2812
+ locale: "en",
2813
+ default: true,
2814
+ // The starter line is STORY content the writer replaces, so it must read the same however the
2815
+ // project was made: no file paths, no commands. (Front-ends give their own next step - `patter init`
2816
+ // prints "try: patter play <dir>" on the terminal, and Patterpad just opens the scene for editing.)
2817
+ strings: { [beatId]: `Welcome to ${name}. This is the first line of your story - replace it with your own.` }
2818
+ };
2819
+ const bundle = opts.bundle ?? "commit";
2820
+ const projectFile = join4(dir, `${fileSlug}.patterproj`);
2821
+ const writes = [
2822
+ { path: projectFile, content: canonicalStringify5(project) },
2823
+ { path: join4(dir, "scenes", "start.patterflow"), content: canonicalStringify5(flow) },
2824
+ { path: join4(dir, "loc", "en", "start.patterloc"), content: canonicalStringify5(locale) },
2825
+ { path: join4(dir, ".editorconfig"), content: EDITORCONFIG },
2826
+ ...vcsConfigWrites(dir, opts.vcs, bundle)
2827
+ ];
2828
+ const collisions = writes.map((w) => w.path).filter((p) => existsSync2(p));
2829
+ if (collisions.length > 0) {
2830
+ throw new Error(`refusing to overwrite existing file(s): ${collisions.join(", ")}`);
2831
+ }
2832
+ return { writes, projectFile, name };
2833
+ }
2834
+ function vcsConfigWrites(dir, vcs, bundle = "commit") {
2835
+ const writes = [{ path: join4(dir, "vcs-setup.md"), content: vcsSetup(vcs, bundle) }];
2836
+ if (vcs === "git") writes.push({ path: join4(dir, ".gitattributes"), content: GITATTRIBUTES });
2837
+ const ignore = ignoreFileFor(vcs);
2838
+ if (ignore) writes.push({ path: join4(dir, ignore), content: ignoreContent(bundle) });
2839
+ return writes;
2960
2840
  }
2841
+ var EDITORCONFIG = `# Patter projects are UTF-8 + LF, always (spec - patter format enforces this).
2842
+ root = true
2961
2843
 
2962
- // src/unpack.ts
2963
- import JSZip2 from "jszip";
2964
- import { join as join5, normalize, isAbsolute as isAbsolute2, resolve as resolve4, sep as sep3 } from "path";
2965
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
2966
- import { parseSource as parseSource5, canonicalStringify as canonicalStringify7 } from "@patterkit/core";
2844
+ [*]
2845
+ charset = utf-8
2846
+ end_of_line = lf
2847
+ insert_final_newline = true
2967
2848
 
2968
- // src/merge.ts
2969
- import { canonicalStringify as canonicalStringify6 } from "@patterkit/core";
2970
- var UnsupportedMergeError = class extends Error {
2971
- };
2972
- var eq = (a, b) => canonicalStringify6(a) === canonicalStringify6(b);
2973
- var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2974
- function detectMergeType(file) {
2975
- const s = typeof file.schema === "string" ? file.schema : "";
2976
- if (s.startsWith("patter/flow")) return "flow";
2977
- if (s.startsWith("patter/strings")) return "loc";
2978
- if (s.startsWith("patter/authoring")) return "authoring";
2979
- if (s.startsWith("patter/project")) return "project";
2980
- throw new UnsupportedMergeError(`cannot detect a Patter merge type from schema '${s}'`);
2981
- }
2982
- function runMerge(base, ours, theirs, opts) {
2983
- const type = opts?.type ?? detectMergeType(ours);
2984
- switch (type) {
2985
- case "loc":
2986
- return mergeLoc(base, ours, theirs);
2987
- case "authoring":
2988
- return mergeAuthoring2(base, ours, theirs);
2989
- case "flow":
2990
- return mergeFlow(base, ours, theirs);
2991
- case "project":
2992
- return mergeProject(base, ours, theirs);
2849
+ [*.{patterflow,patterloc,patterx,patterproj}]
2850
+ indent_style = space
2851
+ indent_size = 2
2852
+ `;
2853
+ var GITATTRIBUTES = `# Patter source is UTF-8 + LF text (pinned; never let autocrlf touch it).
2854
+ *.patterflow text eol=lf
2855
+ *.patterloc text eol=lf
2856
+ *.patterx text eol=lf
2857
+ *.patterproj text eol=lf
2858
+
2859
+ # Id-keyed structured merge for Patter source (the 'patter merge' driver).
2860
+ # Register it once per clone (see vcs-setup.md); until then git falls back to a
2861
+ # normal text merge for these - no harm, just not structure-aware.
2862
+ *.patterflow merge=patter
2863
+ *.patterloc merge=patter
2864
+ *.patterx merge=patter
2865
+ *.patterproj merge=patter
2866
+
2867
+ # Generated artifacts (spec \xA711). The compiled bundle is committed but
2868
+ # REGENERATED, never hand-merged - keep ours on conflict and rebuild (the
2869
+ # validate staleness gate catches a stale one). Needs a one-time
2870
+ # 'git config merge.ours.driver true' (see vcs-setup.md).
2871
+ *.patterc text eol=lf merge=ours
2872
+ # The packed document (.patterpack) is a binary zip envelope - kept out of VCS (.gitignore).
2873
+ *.patterpack binary
2874
+ `;
2875
+ function ignoreFileFor(vcs) {
2876
+ switch (vcs) {
2877
+ case "git":
2878
+ return ".gitignore";
2879
+ case "perforce":
2880
+ return ".p4ignore";
2881
+ case "plastic":
2882
+ return "ignore.conf";
2883
+ default:
2884
+ return void 0;
2993
2885
  }
2994
2886
  }
2995
- function deletedKind(base, ours, theirs) {
2996
- const deleted = base !== void 0 && (ours === void 0 || theirs === void 0);
2997
- return deleted ? "delete-vs-edit" : "both-changed";
2998
- }
2999
- function merge3(base, ours, theirs, id, path, conflicts) {
3000
- if (eq(ours, theirs)) return ours;
3001
- if (eq(base, ours)) return theirs;
3002
- if (eq(base, theirs)) return ours;
3003
- conflicts.push({ id, path, base, ours, theirs, kind: deletedKind(base, ours, theirs) });
3004
- return ours;
3005
- }
3006
- function mergeMap(base, ours, theirs, prefix, conflicts, resolve5) {
3007
- const out = {};
3008
- for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
3009
- const b = base[k], o = ours[k], t = theirs[k];
3010
- let v;
3011
- if (eq(o, t)) v = o;
3012
- else if (eq(b, o)) v = t;
3013
- else if (eq(b, t)) v = o;
3014
- else {
3015
- const r = resolve5?.(k, b, o, t);
3016
- if (r && r.resolved) v = r.value;
3017
- else {
3018
- conflicts.push({ id: k, path: `${prefix}.${k}`, base: b, ours: o, theirs: t, kind: r ? r.kind : deletedKind(b, o, t) });
3019
- v = o;
3020
- }
3021
- }
3022
- if (v !== void 0) out[k] = v;
2887
+ function ignoreContent(bundle) {
2888
+ const lines = [
2889
+ "# Patter generated artifacts - not source (see vcs-setup.md).",
2890
+ "# Packed document (the send-and-return envelope):",
2891
+ "*.patterpack",
2892
+ "# Unresolved merge sidecar:",
2893
+ "*.patterconflict"
2894
+ ];
2895
+ if (bundle === "ignore") {
2896
+ lines.push("# Compiled bundle (built in CI, not committed under this posture):", "*.patterc");
3023
2897
  }
3024
- return out;
2898
+ return lines.join("\n") + "\n";
3025
2899
  }
3026
- var asMap = (v) => isObj(v) ? v : {};
3027
- var asArr = (v) => Array.isArray(v) ? v : [];
3028
- function setIf(obj, key, val) {
3029
- if (Array.isArray(val) ? val.length > 0 : Object.keys(val).length > 0) obj[key] = val;
2900
+ function vcsSetup(vcs, bundle) {
2901
+ const sections = {
2902
+ git: `## git
2903
+
2904
+ \`.gitattributes\` (already emitted) pins Patter source shards to UTF-8 + LF text
2905
+ and marks the generated artifacts (the \`.patterc\` bundle \`merge=ours\`, the
2906
+ \`.patterpack\` document \`binary\`). \`.gitignore\` (already emitted) keeps the document
2907
+ (and the bundle, under the "ignore" posture) out of source control.
2908
+
2909
+ Recommended pre-commit hook (\`.git/hooks/pre-commit\`, executable):
2910
+
2911
+ #!/bin/sh
2912
+ patter validate || exit 1
2913
+
2914
+ The \`merge=patter\` rules in \`.gitattributes\` are already active; register the
2915
+ drivers once per clone (git config is not repo-tracked) - until then git falls
2916
+ back to a normal text merge for those files:
2917
+
2918
+ git config merge.patter.name "Patter structured merge"
2919
+ git config merge.patter.driver "patter merge %O %A %B -o %A"
2920
+ git config merge.ours.driver true
2921
+
2922
+ git invokes the per-path driver directly (no \`mergetool\` wrapper needed). \`%O %A
2923
+ %B\` are base / ours / theirs; the merged result is written back to \`%A\`. On a
2924
+ conflict \`patter merge\` exits non-zero and writes a \`.patterconflict\` sidecar
2925
+ beside the file, so the merge stays unresolved.
2926
+ `,
2927
+ perforce: `## Perforce
2928
+
2929
+ Add Patter extensions to the typemap (\`p4 typemap\`): source shards and the
2930
+ compiled bundle are TEXT with LF; the packed document is BINARY:
2931
+
2932
+ text //....patterflow
2933
+ text //....patterloc
2934
+ text //....patterx
2935
+ text //....patterproj
2936
+ text //....patterc
2937
+ binary //....patterpack
2938
+
2939
+ On a unicode-mode server, set \`P4CHARSET=utf8\`. For the lock-based workflow
2940
+ (spec: one scene per file = one lock per scene), add \`+l\` to make checkouts
2941
+ exclusive. A \`.p4ignore\` (already emitted) keeps the packed document out of the
2942
+ depot.
2943
+
2944
+ Perforce allows ONE global merge tool, so set \`patter mergetool\` as it - it
2945
+ runs the structured merge for Patter source and hands everything else to your
2946
+ normal tool. Map Perforce's variables into the order BASE THEIRS OURS OUT:
2947
+
2948
+ patter mergetool --fallback "<your merge tool>" %b %t %y %r
2949
+
2950
+ (\`%b\` base, \`%t\` theirs, \`%y\` yours/ours, \`%r\` result - adjust to your P4
2951
+ client's variable names; the argument ORDER is what matters.)
2952
+ `,
2953
+ plastic: `## Plastic SCM (Unity VC)
2954
+
2955
+ Patter source shards are plain UTF-8 text; Plastic handles them as-is. The
2956
+ \`ignore.conf\` (already emitted) keeps the packed document out of the repo. For
2957
+ the lock-based workflow, configure exclusive checkout (lock.conf) for the shard
2958
+ extensions.
2959
+
2960
+ In Preferences > Merge tools, add an external tool (a global entry is fine -
2961
+ the wrapper sniffs the path) with the arguments in BASE THEIRS OURS OUT order:
2962
+
2963
+ patter mergetool --fallback "<your merge tool>" @basefile @sourcefile @destinationfile @output
2964
+ `,
2965
+ svn: `## SVN
2966
+
2967
+ Set auto-props so Patter source keeps LF (\`~/.subversion/config\` or repo config):
2968
+
2969
+ [auto-props]
2970
+ *.patterflow = svn:eol-style=LF
2971
+ *.patterloc = svn:eol-style=LF
2972
+ *.patterx = svn:eol-style=LF
2973
+ *.patterproj = svn:eol-style=LF
2974
+ *.patterc = svn:eol-style=LF
2975
+ *.patterpack = svn:mime-type=application/octet-stream
2976
+
2977
+ SVN's ignore is a directory PROPERTY, not a file - set it from the project root:
2978
+
2979
+ svn propset svn:ignore "*.patterpack${bundle === "ignore" ? "\\n*.patterc" : ""}\\n*.patterconflict" .
2980
+
2981
+ For the lock-based workflow add \`svn:needs-lock\` to the shard patterns.
2982
+
2983
+ SVN allows one global merge tool. Point \`[helpers] merge-tool-cmd\` at a small
2984
+ wrapper script that forwards SVN's four arguments (base theirs mine merged =
2985
+ BASE THEIRS OURS OUT) to \`patter mergetool\`:
2986
+
2987
+ #!/bin/sh
2988
+ exec patter mergetool --fallback "<your merge tool>" "$1" "$2" "$3" "$4"
2989
+ `
2990
+ };
2991
+ const head = vcs ? sections[vcs] : `No VCS selected (\`patter init --vcs git|perforce|plastic|svn\` emits tailored
2992
+ config). Whichever you adopt, the rules are the same: Patter source is UTF-8 +
2993
+ LF text, one scene per file, and \`patter validate\` belongs in your pre-commit /
2994
+ CI. Sections for every supported VCS:
2995
+
2996
+ ${Object.keys(sections).map((k) => sections[k]).join("\n")}`;
2997
+ return `# Version-control setup for this Patter project
2998
+
2999
+ ${head}
3000
+ ${bundleSection(bundle)}`;
3030
3001
  }
3031
- function mergeLoc(base, ours, theirs) {
3032
- const conflicts = [];
3033
- const merged = {};
3034
- for (const f of ["schema", "scene", "locale", "default"]) {
3035
- const v = merge3(base[f], ours[f], theirs[f], "", f, conflicts);
3036
- if (v !== void 0) merged[f] = v;
3037
- }
3038
- merged.strings = mergeMap(asMap(base.strings), asMap(ours.strings), asMap(theirs.strings), "strings", conflicts);
3039
- return { type: "loc", merged, conflicts, warnings: [] };
3002
+ function bundleSection(bundle) {
3003
+ const posture = bundle === "commit" ? `This project COMMITS the compiled \`.patterc\` bundle (\`patter init --bundle ignore\`
3004
+ to build it in CI instead). The bundle is regenerated, never hand-merged: on a
3005
+ conflict, keep ours and re-run \`patter export\`. \`patter validate\` recomputes the
3006
+ bundle's embedded hash from source and FAILS if it is stale, so a forgotten
3007
+ regenerate cannot ship silently.` : `This project IGNORES the compiled \`.patterc\` bundle (built in CI; \`patter init
3008
+ --bundle commit\` to commit it instead). Run \`patter export\` as a build step.`;
3009
+ return `## Compiled bundle & packed document
3010
+
3011
+ ${posture}
3012
+
3013
+ The packed \`.patterpack\` document (\`patter pack\`) is the send-and-return envelope
3014
+ for collaborators without VCS. It is a binary zip and a projection of the
3015
+ shards - NOT source - so it stays out of version control (ignored above); edits
3016
+ return via \`patter unpack\`.
3017
+ `;
3040
3018
  }
3041
- function mergeAuthoring2(base, ours, theirs) {
3042
- const conflicts = [];
3043
- const merged = {};
3044
- merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
3045
- setIf(merged, "comments", mergeComments(asArr(base.comments), asArr(ours.comments), asArr(theirs.comments)));
3046
- const edits = mergeEdits(asMap(base.edits), asMap(ours.edits), asMap(theirs.edits));
3047
- setIf(merged, "edits", edits);
3048
- const lww = lastWriterWins(asMap(ours.edits), asMap(theirs.edits));
3049
- for (const f of ["writing", "recording", "audio"]) {
3050
- setIf(merged, f, mergeMap(asMap(base[f]), asMap(ours[f]), asMap(theirs[f]), f, conflicts, lww));
3019
+
3020
+ // src/resolve.ts
3021
+ import { walkNodes as walkNodes4, effectiveGameId, DEFAULT_WRITING_STATUSES as DEFAULT_WRITING_STATUSES3, DEFAULT_RECORDING_STATUSES as DEFAULT_RECORDING_STATUSES4 } from "@patterkit/model";
3022
+ function runResolve(loaded, query) {
3023
+ const entries = indexProject(loaded);
3024
+ const q = query.trim();
3025
+ const ql = q.toLowerCase();
3026
+ const tiers = [
3027
+ (e) => e.id === q,
3028
+ (e) => e.gameId?.toLowerCase() === ql,
3029
+ (e) => e.name?.toLowerCase() === ql,
3030
+ (e) => e.id.toLowerCase().includes(ql) || (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false)
3031
+ ];
3032
+ for (const match of tiers) {
3033
+ const hits = entries.filter(match);
3034
+ if (hits.length > 0) return hits;
3051
3035
  }
3052
- setIf(merged, "documentation", mergeMap(asMap(base.documentation), asMap(ours.documentation), asMap(theirs.documentation), "documentation", conflicts));
3053
- setIf(merged, "cut", mergeMap(asMap(base.cut), asMap(ours.cut), asMap(theirs.cut), "cut", conflicts, void 0));
3054
- return { type: "authoring", merged, conflicts, warnings: [] };
3036
+ return [];
3055
3037
  }
3056
- function mergeComments(base, ours, theirs) {
3057
- const byId = /* @__PURE__ */ new Map();
3058
- for (const c of [...base, ...ours, ...theirs]) {
3059
- if (isObj(c) && typeof c.id === "string" && !byId.has(c.id)) byId.set(c.id, c);
3038
+ function runSearch(loaded, query, focus) {
3039
+ const ql = query.trim().toLowerCase();
3040
+ if (!ql) return [];
3041
+ const entries = indexProject(loaded);
3042
+ const inMeta = (e) => (e.gameId?.toLowerCase().includes(ql) ?? false) || (e.name?.toLowerCase().includes(ql) ?? false);
3043
+ const inText = (e) => e.text?.toLowerCase().includes(ql) ?? false;
3044
+ const inTech = (e) => e.id.toLowerCase().includes(ql);
3045
+ const matched = entries.map((e, i) => ({ e, i })).filter(({ e }) => inMeta(e) || inText(e) || inTech(e));
3046
+ if (!focus) {
3047
+ const meta = matched.filter(({ e }) => inMeta(e)).map((m) => m.e);
3048
+ const text = matched.filter(({ e }) => !inMeta(e) && inText(e)).map((m) => m.e);
3049
+ const tech = matched.filter(({ e }) => !inMeta(e) && !inText(e) && inTech(e)).map((m) => m.e);
3050
+ return [...meta, ...text, ...tech];
3060
3051
  }
3061
- return [...byId.values()].sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")));
3062
- }
3063
- function lastWriterWins(oursEdits, theirsEdits) {
3064
- return (id, _b, o, t) => {
3065
- const om = asMap(oursEdits[id]).modifiedAt, tm = asMap(theirsEdits[id]).modifiedAt;
3066
- if (typeof om === "string" && typeof tm === "string") return { resolved: true, value: om >= tm ? o : t };
3067
- return { resolved: false, kind: "no-timestamp" };
3068
- };
3052
+ const cut = focus.fromBeatId ? entries.findIndex((e) => e.id === focus.fromBeatId) : -1;
3053
+ const group = (e, i) => e.sceneId !== focus.sceneId ? 2 : cut < 0 || i >= cut ? 0 : 1;
3054
+ return matched.sort((a, b) => group(a.e, a.i) - group(b.e, b.i) || a.i - b.i).map((m) => m.e);
3069
3055
  }
3070
- function mergeEdits(base, ours, theirs) {
3071
- const out = {};
3072
- for (const id of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
3073
- const o = ours[id], t = theirs[id];
3074
- if (eq(o, t)) {
3075
- if (o !== void 0) out[id] = o;
3076
- continue;
3077
- }
3078
- if (o === void 0) {
3079
- if (t !== void 0) out[id] = t;
3080
- continue;
3081
- }
3082
- if (t === void 0) {
3083
- out[id] = o;
3084
- continue;
3056
+ function runStatusBrowse(loaded, status, dimension = "writing", focus, recordingOverride) {
3057
+ const strings = sourceStrings(loaded);
3058
+ const merged = mergeAuthoring2(loaded);
3059
+ const recording = dimension === "recording";
3060
+ const statusOf = recording ? recordingOverride ?? merged.recording : merged.writing;
3061
+ const ladder = recording ? loaded.project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES4 : loaded.project.writingStatuses ?? DEFAULT_WRITING_STATUSES3;
3062
+ const lowest = ladder[0]?.name;
3063
+ const statusAt = (id) => recording ? effectiveRecording(id, statusOf, merged.rerecord, lowest ?? "") : statusOf.get(id) ?? lowest;
3064
+ const out = [];
3065
+ for (const scene of loaded.scenes) {
3066
+ const file = loaded.sceneFiles[scene.id];
3067
+ for (const block of scene.blocks) {
3068
+ const segments = [scene.name, block.name];
3069
+ const consider = (beat) => {
3070
+ const tracked = recording ? beat.kind === "line" : beat.kind === "line" || beat.kind === "text";
3071
+ if (!tracked) return;
3072
+ if (statusAt(beat.id) !== status) return;
3073
+ out.push({ id: beat.id, kind: "beat", text: strings[beat.id], location: segments, sceneId: scene.id, file });
3074
+ };
3075
+ walkNodes4(block.children, (node) => {
3076
+ if (node.type === "group") {
3077
+ if (node.prompt) consider(node.prompt);
3078
+ return;
3079
+ }
3080
+ for (const beat of node.beats ?? []) consider(beat);
3081
+ });
3085
3082
  }
3086
- out[id] = mergeEditRecord(asMap(o), asMap(t));
3087
3083
  }
3084
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
3088
3085
  return out;
3089
3086
  }
3090
- function mergeEditRecord(o, t) {
3091
- const om = typeof o.modifiedAt === "string" ? o.modifiedAt : "";
3092
- const tm = typeof t.modifiedAt === "string" ? t.modifiedAt : "";
3093
- const newer = om >= tm ? o : t;
3094
- const ol = asMap(o.localisedAt), tl = asMap(t.localisedAt);
3095
- const loc = {};
3096
- for (const k of /* @__PURE__ */ new Set([...Object.keys(ol), ...Object.keys(tl)])) {
3097
- const a = typeof ol[k] === "string" ? ol[k] : "";
3098
- const b = typeof tl[k] === "string" ? tl[k] : "";
3099
- loc[k] = a >= b ? ol[k] ?? tl[k] : tl[k] ?? ol[k];
3100
- }
3101
- const merged = { ...newer };
3102
- if (Object.keys(loc).length > 0) merged.localisedAt = loc;
3103
- return merged;
3104
- }
3105
- function mergeKeyedByName(base, ours, theirs, path, conflicts) {
3106
- const byName = (arr) => {
3107
- const m = {};
3108
- for (const p of arr) if (isObj(p) && typeof p.name === "string") m[p.name] = p;
3109
- return m;
3087
+ function listProjectTags(loaded) {
3088
+ const counts = /* @__PURE__ */ new Map();
3089
+ const add = (tags) => {
3090
+ for (const t of tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1);
3110
3091
  };
3111
- const merged = mergeMap(byName(base), byName(ours), byName(theirs), path, conflicts);
3112
- return Object.keys(merged).sort().map((name) => merged[name]);
3113
- }
3114
- function mergeProject(base, ours, theirs) {
3115
- const conflicts = [];
3116
- const merged = {};
3117
- for (const k of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])) {
3118
- if (k === "properties" || k === "cast") {
3119
- const v = mergeKeyedByName(asArr(base[k]), asArr(ours[k]), asArr(theirs[k]), k, conflicts);
3120
- if (v.length > 0) merged[k] = v;
3121
- } else if (k === "gameDataFields") {
3122
- const v = mergeGameDataFields(asMap(base[k]), asMap(ours[k]), asMap(theirs[k]), conflicts);
3123
- if (Object.keys(v).length > 0) merged[k] = v;
3124
- } else if (k === "locales") {
3125
- merged.locales = mergeLocales(asMap(base.locales), asMap(ours.locales), asMap(theirs.locales), conflicts);
3126
- } else {
3127
- const v = merge3(base[k], ours[k], theirs[k], "", k, conflicts);
3128
- if (v !== void 0) merged[k] = v;
3092
+ for (const scene of loaded.scenes) {
3093
+ add(scene.tags);
3094
+ for (const block of scene.blocks) {
3095
+ add(block.tags);
3096
+ walkNodes4(block.children, (node) => {
3097
+ add(node.tags);
3098
+ if (node.type === "group") {
3099
+ add(node.prompt?.tags);
3100
+ return;
3101
+ }
3102
+ for (const beat of node.beats ?? []) add(beat.tags);
3103
+ });
3129
3104
  }
3130
3105
  }
3131
- return { type: "project", merged, conflicts, warnings: [] };
3132
- }
3133
- function mergeGameDataFields(b, o, t, conflicts) {
3134
- const out = {};
3135
- for (const kind of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
3136
- const v = mergeKeyedByName(asArr(b[kind]), asArr(o[kind]), asArr(t[kind]), `gameDataFields.${kind}`, conflicts);
3137
- if (v.length > 0) out[kind] = v;
3138
- }
3139
- return out;
3106
+ return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
3140
3107
  }
3141
- function mergeLocales(b, o, t, conflicts) {
3142
- const out = {};
3143
- const def = merge3(b.default, o.default, t.default, "", "locales.default", conflicts);
3144
- if (def !== void 0) out.default = def;
3145
- const all = [];
3146
- for (const arr of [asArr(b.all), asArr(o.all), asArr(t.all)]) {
3147
- for (const x of arr) if (typeof x === "string" && !all.includes(x)) all.push(x);
3108
+ function runTagBrowse(loaded, tag, focus) {
3109
+ if (!tag) return [];
3110
+ const strings = sourceStrings(loaded);
3111
+ const has = (tags) => !!tags && tags.includes(tag);
3112
+ const out = [];
3113
+ for (const scene of loaded.scenes) {
3114
+ const file = loaded.sceneFiles[scene.id];
3115
+ if (has(scene.tags)) out.push({ id: scene.id, kind: "scene", name: scene.name, gameId: effectiveGameId(scene), location: [scene.name], sceneId: scene.id, file });
3116
+ for (const block of scene.blocks) {
3117
+ const segments = [scene.name, block.name];
3118
+ if (has(block.tags)) out.push({ id: block.id, kind: "block", name: block.name, gameId: effectiveGameId(block), location: segments, sceneId: scene.id, file });
3119
+ walkNodes4(block.children, (node) => {
3120
+ if (has(node.tags)) {
3121
+ const preview = node.type === "group" ? node.prompt ? strings[node.prompt.id] : void 0 : strings[node.beats?.[0]?.id ?? ""];
3122
+ out.push({ id: node.id, kind: node.type, text: preview, location: segments, sceneId: scene.id, file });
3123
+ }
3124
+ if (node.type === "group") {
3125
+ 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 });
3126
+ return;
3127
+ }
3128
+ 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 });
3129
+ });
3130
+ }
3148
3131
  }
3149
- out.all = all;
3150
- return out;
3151
- }
3152
- var CHILD_KEYS = ["blocks", "children", "beats"];
3153
- function toTree(raw) {
3154
- const childKey = CHILD_KEYS.find((k) => Array.isArray(raw[k]));
3155
- const fields = {};
3156
- for (const [k, v] of Object.entries(raw)) if (k !== childKey) fields[k] = v;
3157
- const children = childKey ? raw[childKey].filter(isObj).map(toTree) : [];
3158
- return { id: typeof raw.id === "string" ? raw.id : "", fields, childKey, children };
3159
- }
3160
- function fromTree(n) {
3161
- const out = { ...n.fields };
3162
- if (n.childKey) out[n.childKey] = n.children.map(fromTree);
3132
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
3163
3133
  return out;
3164
3134
  }
3165
- function mergeFlow(base, ours, theirs) {
3166
- const conflicts = [];
3167
- const warnings = [];
3168
- const merged = {};
3169
- merged.schema = merge3(base.schema, ours.schema, theirs.schema, "", "schema", conflicts) ?? ours.schema;
3170
- const bScene = isObj(base.scene) ? toTree(base.scene) : void 0;
3171
- const oScene = toTree(asMap(ours.scene));
3172
- const tScene = isObj(theirs.scene) ? toTree(theirs.scene) : void 0;
3173
- const mergedScene = mergeNode(bScene, oScene, tScene, "scene", conflicts, warnings);
3174
- merged.scene = fromTree(mergedScene);
3175
- checkDuplicateIds(mergedScene, conflicts);
3176
- return { type: "flow", merged, conflicts, warnings };
3135
+ function runPropertyUsage(loaded, query, focus) {
3136
+ const parsed = parsePropertyQuery(query);
3137
+ if (!parsed) return [];
3138
+ const { regexes, value } = parsed;
3139
+ const strings = sourceStrings(loaded);
3140
+ const refs = (src) => !!src && regexes.some((re) => re.test(src));
3141
+ const valueOk = (src) => !value || src.toLowerCase().includes(value);
3142
+ const out = [];
3143
+ for (const scene of loaded.scenes) {
3144
+ const file = loaded.sceneFiles[scene.id];
3145
+ const push = (id, kind, segments, usage) => {
3146
+ out.push({ id, kind, text: usage, location: segments, sceneId: scene.id, file });
3147
+ };
3148
+ for (const e of scene.onEntry ?? []) {
3149
+ const src = `${e.target} = ${e.value}`;
3150
+ if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(scene.id, "scene", [scene.name], `on entry: set ${src}`);
3151
+ }
3152
+ for (const block of scene.blocks) {
3153
+ const segments = [scene.name, block.name];
3154
+ walkNodes4(block.children, (node) => {
3155
+ if (refs(node.condition) && valueOk(node.condition)) push(node.id, node.type, segments, `if ${node.condition}`);
3156
+ if (node.type === "group") {
3157
+ if (node.prompt && refs(strings[node.prompt.id]) && valueOk(strings[node.prompt.id])) push(node.prompt.id, "beat", segments, strings[node.prompt.id]);
3158
+ return;
3159
+ }
3160
+ for (const phase of ["onEnter", "onExit"]) {
3161
+ for (const e of node[phase] ?? []) {
3162
+ const src = `${e.target} = ${e.value}`;
3163
+ if ((refs(e.target) || refs(e.value)) && valueOk(src)) push(node.id, "snippet", segments, `${phase === "onEnter" ? "on enter" : "on exit"}: set ${src}`);
3164
+ }
3165
+ }
3166
+ for (const beat of node.beats ?? []) {
3167
+ if (refs(strings[beat.id]) && valueOk(strings[beat.id])) push(beat.id, "beat", segments, strings[beat.id]);
3168
+ }
3169
+ });
3170
+ }
3171
+ }
3172
+ if (focus) out.sort((a, b) => (a.sceneId === focus.sceneId ? 0 : 1) - (b.sceneId === focus.sceneId ? 0 : 1));
3173
+ return out;
3177
3174
  }
3178
- function mergeNode(b, o, t, path, conflicts, warnings) {
3179
- const fields = mergeFields(b?.fields ?? {}, o.fields, t?.fields ?? {}, o.id, path, conflicts);
3180
- const childKey = o.childKey ?? t?.childKey ?? b?.childKey;
3181
- const children = childKey ? mergeChildren(b?.children ?? [], o.children, t?.children ?? [], `${path}.${childKey}`, conflicts, warnings) : [];
3182
- return { id: o.id, fields, childKey, children };
3175
+ function parsePropertyQuery(query) {
3176
+ const m = /^@?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)(.*)$/.exec(query.trim());
3177
+ if (!m) return null;
3178
+ const prop = m[1];
3179
+ const value = m[2].replace(/^[\s=<>!]+/, "").replace(/^["']|["']$/g, "").trim().toLowerCase() || void 0;
3180
+ const esc2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3181
+ const regexes = prop.includes(".") ? [new RegExp(`@${esc2(prop)}\\b`, "i")] : [new RegExp(`@${esc2(prop)}\\b`, "i"), new RegExp(`@patter\\.${esc2(prop)}\\b`, "i")];
3182
+ return { regexes, value };
3183
3183
  }
3184
- function mergeFields(b, o, t, id, path, conflicts) {
3185
- const out = {};
3186
- for (const k of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)])) {
3187
- const v = merge3(b[k], o[k], t[k], id, `${path}.${k}`, conflicts);
3188
- if (v !== void 0) out[k] = v;
3184
+ var indexCache = null;
3185
+ function sameRefs(a, b) {
3186
+ if (a.length !== b.length) return false;
3187
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
3188
+ return true;
3189
+ }
3190
+ function indexProject(loaded) {
3191
+ if (indexCache && indexCache.loaded === loaded && sameRefs(indexCache.scenes, loaded.scenes) && sameRefs(indexCache.locales, loaded.locales)) {
3192
+ return indexCache.entries;
3189
3193
  }
3190
- return out;
3194
+ const entries = buildIndex(loaded);
3195
+ indexCache = { loaded, scenes: [...loaded.scenes], locales: [...loaded.locales], entries };
3196
+ return entries;
3191
3197
  }
3192
- function mergeChildren(B, O, T, path, conflicts, warnings) {
3193
- const Bm = idMap(B), Om = idMap(O), Tm = idMap(T);
3194
- const Bset = new Set(Bm.keys()), Oset = new Set(Om.keys()), Tset = new Set(Tm.keys());
3195
- const merged = /* @__PURE__ */ new Map();
3196
- const here = (id) => `${path}[${id}]`;
3197
- for (const id of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
3198
- const b = Bm.get(id), o = Om.get(id), t = Tm.get(id);
3199
- const inB = Bset.has(id), inO = Oset.has(id), inT = Tset.has(id);
3200
- if (inO && inT) {
3201
- if (!inB && !eq(fromTree(o), fromTree(t))) {
3202
- conflicts.push({ id, path: here(id), base: void 0, ours: fromTree(o), theirs: fromTree(t), kind: "added-both" });
3203
- merged.set(id, o);
3204
- } else {
3205
- merged.set(id, mergeNode(b, o, t, here(id), conflicts, warnings));
3206
- }
3207
- } else if (inO && !inT) {
3208
- if (inB && !eq(fromTree(o), fromTree(b))) {
3209
- conflicts.push({ id, path: here(id), base: fromTree(b), ours: fromTree(o), theirs: void 0, kind: "delete-vs-edit" });
3210
- merged.set(id, o);
3211
- } else if (!inB) {
3212
- merged.set(id, o);
3213
- }
3214
- } else if (!inO && inT) {
3215
- if (inB && !eq(fromTree(t), fromTree(b))) {
3216
- conflicts.push({ id, path: here(id), base: fromTree(b), ours: void 0, theirs: fromTree(t), kind: "delete-vs-edit" });
3217
- } else if (!inB) {
3218
- merged.set(id, t);
3219
- }
3198
+ function buildIndex(loaded) {
3199
+ const out = [];
3200
+ const strings = sourceStrings(loaded);
3201
+ for (const scene of loaded.scenes) {
3202
+ const file = loaded.sceneFiles[scene.id];
3203
+ out.push({
3204
+ id: scene.id,
3205
+ kind: "scene",
3206
+ name: scene.name,
3207
+ gameId: effectiveGameId(scene),
3208
+ location: [scene.name],
3209
+ sceneId: scene.id,
3210
+ file
3211
+ });
3212
+ for (const block of scene.blocks) {
3213
+ const segments = [scene.name, block.name];
3214
+ out.push({
3215
+ id: block.id,
3216
+ kind: "block",
3217
+ name: block.name,
3218
+ gameId: effectiveGameId(block),
3219
+ location: segments,
3220
+ sceneId: scene.id,
3221
+ file
3222
+ });
3223
+ walkNodes4(block.children, (node) => {
3224
+ out.push({
3225
+ id: node.id,
3226
+ kind: node.type,
3227
+ location: segments,
3228
+ sceneId: scene.id,
3229
+ file
3230
+ });
3231
+ if (node.type === "group" && node.prompt) {
3232
+ out.push({
3233
+ id: node.prompt.id,
3234
+ kind: "beat",
3235
+ text: strings[node.prompt.id],
3236
+ location: segments,
3237
+ sceneId: scene.id,
3238
+ file
3239
+ });
3240
+ return;
3241
+ }
3242
+ if (node.type !== "snippet") return;
3243
+ for (const beat of node.beats ?? []) {
3244
+ out.push({
3245
+ id: beat.id,
3246
+ kind: "beat",
3247
+ text: strings[beat.id],
3248
+ location: segments,
3249
+ sceneId: scene.id,
3250
+ file
3251
+ });
3252
+ }
3253
+ });
3220
3254
  }
3221
3255
  }
3222
- return orderChildren(merged, B, O, T, path, conflicts, warnings);
3256
+ return out;
3223
3257
  }
3224
- function orderChildren(merged, B, O, T, path, conflicts, warnings) {
3225
- const Bids = B.map((n) => n.id), Oids = O.map((n) => n.id), Tids = T.map((n) => n.id);
3226
- const Oidset = new Set(Oids);
3227
- const common = new Set(Bids.filter((id) => merged.has(id)));
3228
- const restrict = (ids) => ids.filter((id) => common.has(id));
3229
- const relB = restrict(Bids), relO = restrict(Oids), relT = restrict(Tids);
3230
- const fullO = relO.length === common.size, fullT = relT.length === common.size;
3231
- let commonOrder;
3232
- if (!fullO && !fullT) commonOrder = relB;
3233
- else if (!fullO) commonOrder = relT;
3234
- else if (!fullT) commonOrder = relO;
3235
- else if (arrEq(relO, relB)) commonOrder = relT;
3236
- else if (arrEq(relT, relB)) commonOrder = relO;
3237
- else if (arrEq(relO, relT)) commonOrder = relO;
3238
- else {
3239
- conflicts.push({ id: "", path, base: relB, ours: relO, theirs: relT, kind: "moved" });
3240
- commonOrder = relO;
3258
+
3259
+ // src/replace.ts
3260
+ import { canonicalStringify as canonicalStringify6 } from "@patterkit/core";
3261
+ import { walkNodes as walkNodes5, PROJECT_LOCALE_SCENE as PROJECT_LOCALE_SCENE2 } from "@patterkit/model";
3262
+ function matcher(opts) {
3263
+ if (!opts.query) return null;
3264
+ const esc2 = opts.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3265
+ const body = opts.wholeWord ? `\\b${esc2}\\b` : esc2;
3266
+ return new RegExp(body, opts.caseSensitive ? "g" : "gi");
3267
+ }
3268
+ function runReplace(loaded, opts) {
3269
+ const plan = { hits: [], writes: [], shards: [], scenes: 0 };
3270
+ const re = matcher(opts);
3271
+ if (!re) return plan;
3272
+ const def = loaded.project.locales.default;
3273
+ const locOf = /* @__PURE__ */ new Map();
3274
+ const sceneName = /* @__PURE__ */ new Map();
3275
+ for (const scene of loaded.scenes) {
3276
+ sceneName.set(scene.id, scene.name);
3277
+ for (const block of scene.blocks) {
3278
+ const segs = [scene.name, block.name];
3279
+ walkNodes5(block.children, (node) => {
3280
+ if (node.type === "group") {
3281
+ if (node.prompt) locOf.set(node.prompt.id, segs);
3282
+ return;
3283
+ }
3284
+ for (const b of node.beats ?? []) locOf.set(b.id, segs);
3285
+ });
3286
+ }
3241
3287
  }
3242
- const anchorIn = (ids, id) => {
3243
- for (let i = ids.indexOf(id) - 1; i >= 0; i--) if (common.has(ids[i])) return ids[i];
3244
- return null;
3245
- };
3246
- const byAnchor = /* @__PURE__ */ new Map();
3247
- const group = (a) => {
3248
- let g = byAnchor.get(a);
3249
- if (!g) {
3250
- g = { ours: [], theirs: [] };
3251
- byAnchor.set(a, g);
3288
+ for (let i = 0; i < loaded.locales.length; i++) {
3289
+ const shard = loaded.locales[i];
3290
+ if (shard.locale !== def || shard.scene === PROJECT_LOCALE_SCENE2) continue;
3291
+ const path = loaded.localeFiles[i];
3292
+ if (!path) continue;
3293
+ let changed = false;
3294
+ const merged = { ...shard.strings };
3295
+ for (const [id, before] of Object.entries(shard.strings)) {
3296
+ if (opts.onlyId && id !== opts.onlyId) continue;
3297
+ re.lastIndex = 0;
3298
+ if (!re.test(before)) continue;
3299
+ const after = before.replace(re, () => opts.replacement);
3300
+ if (after === before) continue;
3301
+ plan.hits.push({ id, sceneId: shard.scene, location: locOf.get(id) ?? [sceneName.get(shard.scene) ?? shard.scene], before, after });
3302
+ merged[id] = after;
3303
+ changed = true;
3304
+ }
3305
+ if (changed) {
3306
+ const next = { ...shard, strings: merged };
3307
+ plan.writes.push({ path, content: canonicalStringify6(next) });
3308
+ plan.shards.push(next);
3309
+ plan.scenes++;
3252
3310
  }
3253
- return g;
3254
- };
3255
- for (const n of O) if (merged.has(n.id) && !common.has(n.id)) group(anchorIn(Oids, n.id)).ours.push(n.id);
3256
- 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);
3257
- const out = [];
3258
- const emit = (anchor) => {
3259
- const g = byAnchor.get(anchor);
3260
- if (!g) return;
3261
- 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" });
3262
- out.push(...g.ours, ...g.theirs);
3263
- };
3264
- emit(null);
3265
- for (const id of commonOrder) {
3266
- out.push(id);
3267
- emit(id);
3268
3311
  }
3269
- return out.map((id) => merged.get(id));
3312
+ return plan;
3270
3313
  }
3271
- function checkDuplicateIds(root, conflicts) {
3272
- const seen = /* @__PURE__ */ new Set(), dup = /* @__PURE__ */ new Set();
3273
- const walk = (n) => {
3274
- if (n.id) {
3275
- if (seen.has(n.id)) dup.add(n.id);
3276
- else seen.add(n.id);
3277
- }
3278
- n.children.forEach(walk);
3314
+
3315
+ // src/pack.ts
3316
+ import JSZip from "jszip";
3317
+ import { readFileSync as readFileSync4 } from "fs";
3318
+ import { dirname as dirname4, relative, sep as sep2 } from "path";
3319
+ import { parseSource as parseSource4 } from "@patterkit/core";
3320
+ var SHARD_EXTENSIONS = [".patterflow", ".patterloc", ".patterx", ".patterproj"];
3321
+ var FIXED_DATE = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
3322
+ var ENTRY_OPTS = { date: FIXED_DATE, createFolders: false };
3323
+ async function runPack(startPath) {
3324
+ const projectFile = findProjectFile(startPath);
3325
+ const root = dirname4(projectFile);
3326
+ const project = parseSource4(readFileSync4(projectFile, "utf8"));
3327
+ const unresolved = sidecarIssues(walkFiles(root, CONFLICT_SIDECAR));
3328
+ if (unresolved.length) {
3329
+ throw new Error(
3330
+ `${unresolved.length} unresolved merge conflict(s) - resolve them before packing, or the recipient gets your side of a disagreement with no sign of it:
3331
+ ` + unresolved.map((i) => ` ${i.file}`).join("\n")
3332
+ );
3333
+ }
3334
+ 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));
3335
+ const manifest = {
3336
+ schema: "patter/document@0",
3337
+ project: { id: project.project.id, name: project.project.name },
3338
+ files: files.map((f) => f.rel)
3279
3339
  };
3280
- walk(root);
3281
- for (const id of dup) conflicts.push({ id, path: "scene", base: void 0, ours: void 0, theirs: void 0, kind: "structural" });
3340
+ const zip = new JSZip();
3341
+ zip.file("patter.manifest.json", JSON.stringify(manifest, null, 2) + "\n", ENTRY_OPTS);
3342
+ for (const f of files) zip.file(f.rel, readFileSync4(f.abs, "utf8"), ENTRY_OPTS);
3343
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", streamFiles: false });
3282
3344
  }
3283
- var idMap = (nodes) => new Map(nodes.filter((n) => n.id).map((n) => [n.id, n]));
3284
- var arrEq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
3285
3345
 
3286
3346
  // src/unpack.ts
3347
+ import JSZip2 from "jszip";
3348
+ import { join as join5, normalize, isAbsolute as isAbsolute2, resolve as resolve4, sep as sep3 } from "path";
3349
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
3350
+ import { parseSource as parseSource5, canonicalStringify as canonicalStringify7 } from "@patterkit/core";
3287
3351
  var MANIFEST = "patter.manifest.json";
3288
3352
  var UnsafeEntryError = class extends Error {
3289
3353
  };
@@ -3346,10 +3410,17 @@ async function runUnpackMerge(returnedBytes, baseBytes, projectDir) {
3346
3410
  shards.push({ path: rel, added: true });
3347
3411
  continue;
3348
3412
  }
3349
- const oursObj = parseSource5(readFileSync5(outPath, "utf8"));
3350
- const theirsObj = parseSource5(theirText);
3413
+ const readSide = (side, text) => {
3414
+ try {
3415
+ return parseSource5(text);
3416
+ } catch (e) {
3417
+ throw new Error(`${rel}: the ${side} copy is not readable Patter source - ${e instanceof Error ? e.message : String(e)}`);
3418
+ }
3419
+ };
3420
+ const oursObj = readSide("ours", readFileSync5(outPath, "utf8"));
3421
+ const theirsObj = readSide("theirs", theirText);
3351
3422
  const baseText = base.get(rel);
3352
- const baseObj = baseText !== void 0 ? parseSource5(baseText) : {};
3423
+ const baseObj = baseText !== void 0 ? readSide("base", baseText) : {};
3353
3424
  const result = runMerge(baseObj, oursObj, theirsObj);
3354
3425
  writes.push({ path: outPath, content: canonicalStringify7(result.merged) });
3355
3426
  if (result.conflicts.length > 0) {
@@ -3375,6 +3446,8 @@ function containedWrite(dir, name) {
3375
3446
  return full;
3376
3447
  }
3377
3448
  export {
3449
+ AUTHORING_HANDLED,
3450
+ CONFLICT_SIDECAR,
3378
3451
  SHARD_EXTENSIONS,
3379
3452
  UnsafeEntryError,
3380
3453
  UnsupportedMergeError,
@@ -3429,6 +3502,7 @@ export {
3429
3502
  sceneIdForShard,
3430
3503
  scriptToDocx,
3431
3504
  scriptToPdf,
3505
+ sidecarIssues,
3432
3506
  vcsConfigWrites,
3433
3507
  voiceScriptToXlsx,
3434
3508
  xlsxToCatalog