@onceworlds/cli 0.9.4 → 0.9.5

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/README.md +7 -5
  2. package/dist/cli.js +799 -122
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { execFileSync as execFileSync2 } from "node:child_process";
5
- import { createHash as createHash3 } from "node:crypto";
6
- import { existsSync as existsSync8, watch } from "node:fs";
5
+ import { createHash as createHash4 } from "node:crypto";
6
+ import { existsSync as existsSync9, watch } from "node:fs";
7
7
  import { mkdir as mkdir4, readdir as readdir3, readFile as readFile5, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
8
- import { basename as basename4, dirname as dirname8, join as join12, resolve as resolve9, sep as sep2 } from "node:path";
8
+ import { basename as basename5, dirname as dirname9, join as join13, resolve as resolve10, sep as sep3 } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
 
11
11
  // src/check.ts
@@ -1151,6 +1151,9 @@ function missingControls(listed, wanted) {
1151
1151
  }
1152
1152
 
1153
1153
  // src/glyphs.ts
1154
+ function unionCoverage(parts, unchecked = []) {
1155
+ return { has: (code) => parts.some((p) => p.coverage.has(code)), names: parts.map((p) => p.name), unchecked };
1156
+ }
1154
1157
  var SORA_RANGES = "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD,U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF";
1155
1158
  function rangeCoverage(ranges = SORA_RANGES) {
1156
1159
  const spans = ranges.split(",").map((part) => {
@@ -1174,9 +1177,12 @@ function missingGlyphs(texts, font, max = 12) {
1174
1177
  }
1175
1178
  return out;
1176
1179
  }
1177
- function glyphNote(missing) {
1180
+ function glyphNote(missing, fonts = {}) {
1178
1181
  const codes = missing.map((ch) => `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
1179
- return `text uses characters the engine's font (Sora) can't draw: ${missing.join(" ")} (${codes.join(", ")}). A browser draws them in whatever font the device has, so they look different on every phone or come out as boxes (shot draws boxes). Draw them as shapes (Shape2D, ui.draw.circle and polygon, UI.custom), or load a font that has them (loadFont, the theme's font) and draw the text in it`;
1182
+ const names = fonts.names && fonts.names.length > 0 ? fonts.names : ["Sora"];
1183
+ const which = names.length > 1 ? `its fonts (${names.join(", ")})` : names[0] === "Sora" ? "the engine's font (Sora)" : `its font (${names[0]})`;
1184
+ const unchecked = fonts.unchecked && fonts.unchecked.length > 0 ? ` (${fonts.unchecked.join(", ")} could not be had to look in)` : "";
1185
+ return `text uses characters ${which} can't draw: ${missing.join(" ")} (${codes.join(", ")})${unchecked}. A browser draws them in whatever font the device has, so they look different on every phone or come out as boxes (shot draws boxes). Draw them as shapes (Shape2D, ui.draw.circle and polygon, UI.custom), or load a font that has them (loadFont, the theme's font) and draw the text in it`;
1180
1186
  }
1181
1187
 
1182
1188
  // src/check.ts
@@ -1641,8 +1647,9 @@ async function checkEngineGame(options) {
1641
1647
  );
1642
1648
  }
1643
1649
  lines.push(...budgetLines(smoke.budget));
1644
- const missing = smoke.budget?.chars ? missingGlyphs([smoke.budget.chars], options.font ?? rangeCoverage()) : [];
1645
- if (missing.length > 0) lines.push(`! The ${glyphNote(missing)}.`);
1650
+ const coverage2 = options.font ?? rangeCoverage();
1651
+ const missing = smoke.budget?.chars ? missingGlyphs([smoke.budget.chars], coverage2) : [];
1652
+ if (missing.length > 0) lines.push(`! The ${glyphNote(missing, coverage2)}.`);
1646
1653
  if (smoke.dump) lines.push("\xB7 The end of the first match (game.debug.text()):", ...smoke.dump.split("\n").slice(0, 24).map((l) => ` ${l}`));
1647
1654
  const problems = smoke.problems ?? [];
1648
1655
  if (problems.length > 0) {
@@ -2607,8 +2614,8 @@ function gitignoreText() {
2607
2614
  }
2608
2615
  function missingIgnores(existing) {
2609
2616
  const norm = (line2) => line2.trim().replace(/^\//, "").replace(/\/$/, "");
2610
- const have = new Set(existing.split(/\r?\n/).map(norm).filter((line2) => line2 && !line2.startsWith("#")));
2611
- return IGNORED.filter(({ line: line2, needed }) => needed && !have.has(norm(line2))).map(({ line: line2 }) => line2);
2617
+ const have2 = new Set(existing.split(/\r?\n/).map(norm).filter((line2) => line2 && !line2.startsWith("#")));
2618
+ return IGNORED.filter(({ line: line2, needed }) => needed && !have2.has(norm(line2))).map(({ line: line2 }) => line2);
2612
2619
  }
2613
2620
 
2614
2621
  // src/init.ts
@@ -2751,7 +2758,7 @@ import { fileURLToPath } from "node:url";
2751
2758
  // ../engine/package.json
2752
2759
  var package_default = {
2753
2760
  name: "@onceworlds/engine",
2754
- version: "1.3.2",
2761
+ version: "1.4.0",
2755
2762
  description: "The Onceworlds game engine: entities, components, systems, fixed-step time, and the platform's rooms, matches and saves built in.",
2756
2763
  homepage: "https://onceworlds.com/docs",
2757
2764
  type: "module",
@@ -3381,7 +3388,7 @@ import { spawn as spawn2 } from "node:child_process";
3381
3388
  import { existsSync as existsSync3, mkdirSync, mkdtempSync, readdirSync as readdirSync2, rmSync, writeFileSync } from "node:fs";
3382
3389
  import { homedir as homedir2, tmpdir } from "node:os";
3383
3390
  import { dirname as dirname5, join as join5 } from "node:path";
3384
- var sleep = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
3391
+ var sleep = (ms) => new Promise((resolve11) => setTimeout(resolve11, ms));
3385
3392
  var ARROWS = { ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40 };
3386
3393
  function keyInfo(key) {
3387
3394
  if (key in ARROWS) return { key, code: key, windowsVirtualKeyCode: ARROWS[key] };
@@ -3475,8 +3482,8 @@ async function launch(options) {
3475
3482
  throw new Error(`The browser (${binary}) did not start.`);
3476
3483
  }
3477
3484
  const ws = new WebSocket(version.webSocketDebuggerUrl);
3478
- await new Promise((resolve10, reject) => {
3479
- ws.onopen = () => resolve10();
3485
+ await new Promise((resolve11, reject) => {
3486
+ ws.onopen = () => resolve11();
3480
3487
  ws.onerror = () => reject(new Error("Could not talk to the browser."));
3481
3488
  });
3482
3489
  let nextId = 1;
@@ -3497,10 +3504,10 @@ async function launch(options) {
3497
3504
  for (const waiting of pending.values()) waiting.reject(new Error("the browser is gone"));
3498
3505
  pending.clear();
3499
3506
  };
3500
- const send2 = (method, params = {}, sessionId) => new Promise((resolve10, reject) => {
3507
+ const send2 = (method, params = {}, sessionId) => new Promise((resolve11, reject) => {
3501
3508
  const id = nextId++;
3502
3509
  const timer = setTimeout(() => (pending.delete(id), reject(new Error(`${method}: the browser did not answer in 90 s`))), 9e4);
3503
- pending.set(id, { resolve: (value) => (clearTimeout(timer), resolve10(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
3510
+ pending.set(id, { resolve: (value) => (clearTimeout(timer), resolve11(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
3504
3511
  if (ws.readyState !== 1) return pending.get(id).reject(new Error(`${method}: the browser is gone`));
3505
3512
  ws.send(JSON.stringify({ id, method, params, sessionId }));
3506
3513
  });
@@ -3646,7 +3653,7 @@ function saveFile(file, bytes) {
3646
3653
  }
3647
3654
 
3648
3655
  // src/posters.ts
3649
- var sleep2 = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
3656
+ var sleep2 = (ms) => new Promise((resolve11) => setTimeout(resolve11, ms));
3650
3657
  function defaultPosterSize(name) {
3651
3658
  if (/icon/i.test(name)) return { width: 512, height: 512 };
3652
3659
  if (/badge/i.test(name)) return { width: 256, height: 256 };
@@ -3778,7 +3785,7 @@ function openBrowser(url) {
3778
3785
  } catch {
3779
3786
  }
3780
3787
  }
3781
- var sleep3 = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
3788
+ var sleep3 = (ms) => new Promise((resolve11) => setTimeout(resolve11, ms));
3782
3789
  async function login(api2) {
3783
3790
  const started = await post(`${api2}/api/cli/device`, { label: deviceLabel() });
3784
3791
  if (started.status !== 200) throw new Error(String(started.data.message ?? `Login failed (${started.status}).`));
@@ -3822,7 +3829,7 @@ async function logout(api2) {
3822
3829
  import { join as join7 } from "node:path";
3823
3830
  var QUICK = ["join", "start", "reload-guest", "reload-host", "drop-host", "phone"];
3824
3831
  var ALL = ["join", "start", "reload-guest", "reload-host", "drop-guest", "drop-host", "two-away", "spectator", "second-match", "rapid-ready", "phone"];
3825
- var sleep4 = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
3832
+ var sleep4 = (ms) => new Promise((resolve11) => setTimeout(resolve11, ms));
3826
3833
  async function until(fn, ms = 15e3, step = 250) {
3827
3834
  const end = Date.now() + ms;
3828
3835
  for (; ; ) {
@@ -4862,7 +4869,7 @@ var TOOLS = [
4862
4869
  {
4863
4870
  name: "screenshot",
4864
4871
  title: "See the game",
4865
- description: 'A picture of the game to look at: the first player\'s screen `at` seconds into a simulated match with bots (default 8), the title screen (phase: "title") or the lobby (phase: "lobby": Play pressed, nobody ready), or a poster (poster: "cover", "icon"...). story: true is the first minute as a new player lives it, frame by frame on one contact sheet, with the questions to ask of it. 2D draws without a browser; 3D (or browser: true) needs Chrome. Engine games, after npm install.',
4872
+ description: 'A picture of the game to look at: the first player\'s screen `at` seconds into a simulated match with bots (default 8), the title screen (phase: "title") or the lobby (phase: "lobby": Play pressed, nobody ready), or a poster (poster: "cover", "icon"...). story: true is the first minute as a new player lives it, frame by frame on one contact sheet, with the questions to ask of it. 2D draws without a browser, text in the game\'s own fonts (not the HTML overlay: browser: true draws it); 3D (or browser: true) needs Chrome. Engine games, after npm install.',
4866
4873
  inputSchema: {
4867
4874
  type: "object",
4868
4875
  properties: {
@@ -5255,13 +5262,13 @@ function formatStatus(raw) {
5255
5262
  }
5256
5263
 
5257
5264
  // src/shot.ts
5258
- import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
5259
- import { tmpdir as tmpdir3 } from "node:os";
5260
- import { join as join10, resolve as resolve7 } from "node:path";
5265
+ import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
5266
+ import { tmpdir as tmpdir4 } from "node:os";
5267
+ import { join as join11, resolve as resolve8 } from "node:path";
5261
5268
  import { fileURLToPath as fileURLToPath3, pathToFileURL as pathToFileURL3 } from "node:url";
5262
5269
 
5263
5270
  // src/font.ts
5264
- import { brotliDecompressSync } from "node:zlib";
5271
+ import { brotliDecompressSync, inflateSync as inflateSync2 } from "node:zlib";
5265
5272
  var KNOWN_TAGS = [
5266
5273
  "cmap",
5267
5274
  "head",
@@ -5564,7 +5571,8 @@ function plainGlyphs(glyf, loca, numGlyphs, longOffsets) {
5564
5571
  continue;
5565
5572
  }
5566
5573
  const ends = Array.from({ length: count }, () => r.u16());
5567
- r.at += r.u16();
5574
+ const instructions = r.u16();
5575
+ r.at += instructions;
5568
5576
  const total = count ? ends[count - 1] + 1 : 0;
5569
5577
  const fl = [];
5570
5578
  while (fl.length < total) {
@@ -5649,10 +5657,35 @@ function readCmap(data) {
5649
5657
  return 0;
5650
5658
  };
5651
5659
  }
5660
+ function woffTables(bytes) {
5661
+ const r = new Reader(bytes);
5662
+ r.at = 12;
5663
+ const numTables = r.u16();
5664
+ r.at = 44;
5665
+ const tables = /* @__PURE__ */ new Map();
5666
+ for (let i = 0; i < numTables; i++) {
5667
+ const tag = tagOf(r);
5668
+ const offset = r.u32();
5669
+ const compLength = r.u32();
5670
+ const origLength = r.u32();
5671
+ r.u32();
5672
+ if (offset + compLength > bytes.length) throw new Error("the font ends early");
5673
+ const stored = bytes.subarray(offset, offset + compLength);
5674
+ const data = compLength < origLength ? new Uint8Array(inflateSync2(stored)) : stored;
5675
+ tables.set(tag, { data, transformed: false });
5676
+ }
5677
+ return tables;
5678
+ }
5679
+ var tagAt = (bytes, at) => String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]);
5652
5680
  function readFont(bytes) {
5681
+ if (bytes.length < 12) throw new Error("not a font");
5653
5682
  let tables;
5654
- const magic = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
5683
+ const magic = tagAt(bytes, 0);
5684
+ const flavor = magic === "wOF2" || magic === "wOFF" ? tagAt(bytes, 4) : magic;
5685
+ if (flavor === "OTTO") throw new Error("CFF outlines (an OpenType font with PostScript outlines) are not read");
5686
+ if (flavor !== "\0\0\0" && flavor !== "true") throw new Error(magic === "ttcf" ? "a font collection is not read" : "not a font");
5655
5687
  if (magic === "wOF2") tables = woff2Tables(bytes).tables;
5688
+ else if (magic === "wOFF") tables = woffTables(bytes);
5656
5689
  else {
5657
5690
  tables = /* @__PURE__ */ new Map();
5658
5691
  const r = new Reader(bytes);
@@ -5699,6 +5732,12 @@ function readFont(bytes) {
5699
5732
  const raw = glyf.transformed ? transformedGlyphs(glyf.data) : plainGlyphs(glyf.data, table("loca").data, numGlyphs, longOffsets);
5700
5733
  const toGlyph = readCmap(table("cmap").data);
5701
5734
  let weight = 400;
5735
+ const os2 = tables.get("OS/2");
5736
+ if (os2 && os2.data.length >= 6) {
5737
+ const declared = os2.data[4] << 8 | os2.data[5];
5738
+ const w = declared > 0 && declared < 10 ? declared * 100 : declared;
5739
+ if (w >= 100 && w <= 1e3) weight = w;
5740
+ }
5702
5741
  const fvar = tables.get("fvar");
5703
5742
  if (fvar) {
5704
5743
  const r = new Reader(fvar.data);
@@ -5764,6 +5803,591 @@ function fontStackOf(fonts) {
5764
5803
  has: (code) => fonts.some((f) => f.has(code))
5765
5804
  };
5766
5805
  }
5806
+ var FONT_FAMILY_LIST = /(?:^|\s)[+-]?(?:\d+\.?\d*|\.\d+)(?:px|pt|pc|em|rem|ex|ch|%|vh|vw|vmin|vmax|mm|cm|in|q)(?:\s*\/\s*\S+)?\s+(\S.*)$/i;
5807
+ function familiesOf(css) {
5808
+ const list = FONT_FAMILY_LIST.exec(css)?.[1] ?? css;
5809
+ return list.split(",").map((f) => f.trim().replace(/^(['"])(.*)\1$/, "$2").trim().toLowerCase()).filter(Boolean);
5810
+ }
5811
+ var picked = /* @__PURE__ */ new WeakMap();
5812
+ function pickFont(book, css) {
5813
+ let known = picked.get(book);
5814
+ if (!known) picked.set(book, known = /* @__PURE__ */ new Map());
5815
+ const seen = known.get(css);
5816
+ if (seen !== void 0) return seen;
5817
+ let font = book.fallback;
5818
+ for (const family of familiesOf(css)) {
5819
+ const found = book.families.get(family);
5820
+ if (found) {
5821
+ font = found;
5822
+ break;
5823
+ }
5824
+ }
5825
+ if (known.size < 500) known.set(css, font);
5826
+ return font;
5827
+ }
5828
+
5829
+ // src/game-fonts.ts
5830
+ import { createHash as createHash3 } from "node:crypto";
5831
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
5832
+ import { tmpdir as tmpdir3 } from "node:os";
5833
+ import { basename as basename4, dirname as dirname7, extname, join as join9, relative as relative3, resolve as resolve6, sep } from "node:path";
5834
+ var MAX_FAMILIES = 8;
5835
+ var NOT_HAD = /* @__PURE__ */ new Set([
5836
+ "serif",
5837
+ "sans-serif",
5838
+ "monospace",
5839
+ "cursive",
5840
+ "fantasy",
5841
+ "system-ui",
5842
+ "ui-serif",
5843
+ "ui-sans-serif",
5844
+ "ui-monospace",
5845
+ "ui-rounded",
5846
+ "math",
5847
+ "emoji",
5848
+ "fangsong",
5849
+ "-apple-system",
5850
+ "blinkmacsystemfont",
5851
+ "arial",
5852
+ "arial black",
5853
+ "helvetica",
5854
+ "helvetica neue",
5855
+ "segoe ui",
5856
+ "segoe ui emoji",
5857
+ "segoe ui symbol",
5858
+ "times",
5859
+ "times new roman",
5860
+ "courier",
5861
+ "courier new",
5862
+ "georgia",
5863
+ "verdana",
5864
+ "tahoma",
5865
+ "trebuchet ms",
5866
+ "impact",
5867
+ "comic sans ms",
5868
+ "lucida grande",
5869
+ "lucida console",
5870
+ "menlo",
5871
+ "monaco",
5872
+ "consolas",
5873
+ "sf pro",
5874
+ "sf pro display",
5875
+ "sf pro text",
5876
+ "sf mono",
5877
+ "apple color emoji",
5878
+ "noto color emoji",
5879
+ "palatino",
5880
+ "palatino linotype",
5881
+ // Words that aren't families: CSS keywords and values a `font`-named setting may hold.
5882
+ "inherit",
5883
+ "initial",
5884
+ "unset",
5885
+ "revert",
5886
+ "revert-layer",
5887
+ "none",
5888
+ "auto",
5889
+ "default",
5890
+ "normal",
5891
+ "bold",
5892
+ "bolder",
5893
+ "lighter",
5894
+ "italic",
5895
+ "oblique",
5896
+ "small-caps",
5897
+ "caption",
5898
+ "icon",
5899
+ "menu",
5900
+ "message-box",
5901
+ "small-caption",
5902
+ "status-bar",
5903
+ "swap",
5904
+ "block",
5905
+ "fallback",
5906
+ "optional",
5907
+ "true",
5908
+ "false",
5909
+ "null",
5910
+ "undefined"
5911
+ ]);
5912
+ var FONT_FILE = /\.(?:woff2?|ttf|otf)(?:[?#].*)?$/i;
5913
+ var GOOGLE_CSS = /fonts\.googleapis\.com\/css2?\?([^"'`\s)<>\\]+)/gi;
5914
+ var GOOGLE_HOST = /fonts\.googleapis\.com\/css/i;
5915
+ var FAMILY_NAME = /^[A-Za-z][A-Za-z0-9 _.-]{0,63}$/;
5916
+ var FONT_KEY = /font|family/i;
5917
+ var NOT_FONT_KEY = /(?:size|weight|style|variant|kerning|stretch|scale|features?|smoothing|optical|generation|loaded|ready|px|height|colou?r|count|index|id)s?$/i;
5918
+ function fileFamily(path) {
5919
+ const name = basename4(path.split(/[?#]/)[0]);
5920
+ let decoded = name;
5921
+ try {
5922
+ decoded = decodeURIComponent(name);
5923
+ } catch {
5924
+ }
5925
+ return decoded.slice(0, decoded.length - extname(decoded).length).trim();
5926
+ }
5927
+ function familyOf(value) {
5928
+ const v = value.replace(/!important\s*$/i, "").trim();
5929
+ if (v === "" || v.length > 400 || GOOGLE_HOST.test(v)) return null;
5930
+ if (FONT_FILE.test(v) && !/^data:/i.test(v)) {
5931
+ const family = fileFamily(v);
5932
+ return /^[^.${}<>"'`\\][^${}<>"'`\\]{0,63}$/.test(family) ? { family, source: v } : null;
5933
+ }
5934
+ if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(v)) return null;
5935
+ const list = FONT_FAMILY_LIST.exec(v)?.[1] ?? v;
5936
+ const first = list.split(",")[0].trim().replace(/^(['"])(.*)\1$/, "$2").trim();
5937
+ if (!FAMILY_NAME.test(first) || NOT_HAD.has(first.toLowerCase())) return null;
5938
+ return { family: first };
5939
+ }
5940
+ function googleFamilies(text2) {
5941
+ const out = [];
5942
+ for (const m of text2.matchAll(GOOGLE_CSS)) {
5943
+ for (const part of m[1].replace(/&amp;/g, "&").split("&")) {
5944
+ const [key, value] = part.split("=");
5945
+ if (key !== "family" || !value) continue;
5946
+ let decoded = value.replace(/\+/g, " ");
5947
+ try {
5948
+ decoded = decodeURIComponent(decoded);
5949
+ } catch {
5950
+ }
5951
+ for (const family of decoded.split("|")) {
5952
+ const name = family.split(":")[0].trim();
5953
+ if (name) out.push(name);
5954
+ }
5955
+ }
5956
+ }
5957
+ return out;
5958
+ }
5959
+ function literalAt(text2, code, at) {
5960
+ const quote = text2[at];
5961
+ if (quote === "`") {
5962
+ let value = "";
5963
+ let dynamic = false;
5964
+ let i = at + 1;
5965
+ while (i < text2.length) {
5966
+ const c = text2[i];
5967
+ if (c === "\\") {
5968
+ value += text2[i + 1] ?? "";
5969
+ i += 2;
5970
+ } else if (c === "`") return { value, end: i, dynamic };
5971
+ else if (c === "$" && text2[i + 1] === "{") {
5972
+ const end2 = closing(code, i + 1);
5973
+ if (end2 < 0) return null;
5974
+ value += "0";
5975
+ dynamic = true;
5976
+ i = end2 + 1;
5977
+ } else {
5978
+ value += c;
5979
+ i++;
5980
+ }
5981
+ }
5982
+ return null;
5983
+ }
5984
+ const end = code.indexOf(quote, at + 1);
5985
+ if (end < 0) return null;
5986
+ const raw = text2.slice(at + 1, end);
5987
+ return raw.includes("\n") ? null : { value: raw.replace(/\\(.)/g, "$1"), end, dynamic: false };
5988
+ }
5989
+ function objectAround(code, at) {
5990
+ let depth = 0;
5991
+ for (let i = at; i >= 0 && at - i < 4e3; i--) {
5992
+ if (code[i] === "}") depth++;
5993
+ else if (code[i] === "{") {
5994
+ if (depth === 0) {
5995
+ const end = closing(code, i);
5996
+ return end < 0 ? null : [i, end];
5997
+ }
5998
+ depth--;
5999
+ }
6000
+ }
6001
+ return null;
6002
+ }
6003
+ var SETTING = /(?<![\w$])([A-Za-z_$][\w$]*)\s*(?::|=(?![=>]))\s*(['"`])/g;
6004
+ var FONT_FILE_LITERAL = /(['"`])([^'"`\n]*?\.(?:woff2?|ttf|otf)(?:[?#][^'"`\n]*)?)\1/gi;
6005
+ var FACE_KEYS = /(?<![\w$])(?:weights?|src|url|file|style|display|unicodeRange)\s*:/;
6006
+ function fromScript(text2, addFont) {
6007
+ const code = blank(text2);
6008
+ const named = [];
6009
+ const add = (at, value, source, dynamic = false) => {
6010
+ if (!(dynamic && FONT_FILE.test(value))) named.push({ at, value, source });
6011
+ };
6012
+ const files = /* @__PURE__ */ new Map();
6013
+ for (const m of text2.matchAll(FONT_FILE_LITERAL)) if (code[m.index] === m[1]) files.set(m.index, m[2]);
6014
+ const taken = /* @__PURE__ */ new Set();
6015
+ const lists = [];
6016
+ for (const m of code.matchAll(/(?<![\w$])([\w$]*fonts)\s*(?::|=(?![=>]))\s*\[/gi)) {
6017
+ const open = m.index + m[0].length - 1;
6018
+ const end = closing(code, open);
6019
+ if (end < 0) continue;
6020
+ lists.push([open, end]);
6021
+ for (let i = open + 1; i < end; i++) {
6022
+ const c = code[i];
6023
+ if (c !== "'" && c !== '"' && c !== "`") continue;
6024
+ const literal = literalAt(text2, code, i);
6025
+ if (!literal) break;
6026
+ const before = code.slice(open, i).trimEnd();
6027
+ if ((before.endsWith("[") || before.endsWith(",")) && !files.has(i)) add(i, literal.value, void 0, literal.dynamic);
6028
+ i = literal.end;
6029
+ }
6030
+ }
6031
+ for (const m of code.matchAll(SETTING)) {
6032
+ const key = m[1];
6033
+ if (!FONT_KEY.test(key) || NOT_FONT_KEY.test(key)) continue;
6034
+ const at = m.index + m[0].length - 1;
6035
+ const literal = literalAt(text2, code, at);
6036
+ if (!literal) continue;
6037
+ let source;
6038
+ if (/family/i.test(key)) {
6039
+ const object = objectAround(code, m.index);
6040
+ const inside = (pos) => object !== null && pos > object[0] && pos < object[1];
6041
+ const own = [...files.keys()].filter(inside);
6042
+ const listed = lists.some(([a, b]) => m.index > a && m.index < b);
6043
+ const face = own.length > 0 || object !== null && FACE_KEYS.test(code.slice(object[0], object[1]));
6044
+ if (/^family$/i.test(key) && !listed && !face) continue;
6045
+ for (const pos of own) taken.add(pos);
6046
+ source = own.length > 0 ? files.get(own[0]) : void 0;
6047
+ }
6048
+ if (files.has(at)) taken.add(at);
6049
+ add(at, literal.value, source, literal.dynamic);
6050
+ }
6051
+ for (const m of code.matchAll(/(?<![\w$])loadFont\s*\(\s*(['"`])/g)) {
6052
+ const at = m.index + m[0].length - 1;
6053
+ const literal = literalAt(text2, code, at);
6054
+ if (literal && !files.has(at)) add(at, literal.value, void 0, literal.dynamic);
6055
+ }
6056
+ for (const m of code.matchAll(/(?<![\w$])FontFace\s*\(\s*(['"`])/g)) {
6057
+ const at = m.index + m[0].length - 1;
6058
+ const family = literalAt(text2, code, at);
6059
+ if (!family) continue;
6060
+ const comma = /^\s*,\s*(['"`])/.exec(code.slice(family.end + 1));
6061
+ const src = comma ? literalAt(text2, code, family.end + comma[0].length) : null;
6062
+ const url = src && !src.dynamic ? /url\(\s*(['"]?)(.*?)\1\s*\)/i.exec(src.value)?.[2] ?? src.value : void 0;
6063
+ add(at, family.value, url && (FONT_FILE.test(url) || /^data:/i.test(url)) ? url : void 0, family.dynamic);
6064
+ }
6065
+ for (const [pos, file] of files) if (!taken.has(pos)) add(pos, file);
6066
+ for (const n of named.sort((a, b) => a.at - b.at)) addFont(n.value, n.source);
6067
+ return /(?<![\w$])overlay\s*\.\s*(?:el|mount)\s*\(|\{[^{}]*\b(?:el|mount)\b[^{}]*\}\s*=\s*[\w$.]*overlay\b/.test(code);
6068
+ }
6069
+ function fromCss(text2, add) {
6070
+ const css = text2.replace(/\/\*[\s\S]*?\*\//g, " ");
6071
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/gi)) {
6072
+ const family = /font-family\s*:\s*([^;]+)/i.exec(m[1])?.[1];
6073
+ if (!family) continue;
6074
+ const src = /(?:^|[;\s])src\s*:\s*([^;]+)/i.exec(m[1])?.[1] ?? "";
6075
+ const urls = [...src.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/gi)].map((u) => u[2].trim());
6076
+ add(family, urls.find((u) => FONT_FILE.test(u) || /^data:/i.test(u)) ?? urls[0]);
6077
+ }
6078
+ const rest = css.replace(/@font-face\s*\{[^}]*\}/gi, " ");
6079
+ for (const m of rest.matchAll(/(?<![\w-])font-family\s*:\s*([^;{}]+)/gi)) add(m[1]);
6080
+ for (const m of rest.matchAll(/(?<![\w-])font\s*:\s*([^;{}]+)/gi)) add(m[1]);
6081
+ }
6082
+ function fromHtml(text2, addScript, addStyle) {
6083
+ let overlay = false;
6084
+ for (const m of text2.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) overlay = fromScript(m[1], addScript) || overlay;
6085
+ for (const m of text2.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)) fromCss(m[1], addStyle);
6086
+ for (const m of text2.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) fromCss(m[1] ?? m[2] ?? "", addStyle);
6087
+ return overlay;
6088
+ }
6089
+ function fromJson(text2, add) {
6090
+ let data;
6091
+ try {
6092
+ data = JSON.parse(text2);
6093
+ } catch {
6094
+ return;
6095
+ }
6096
+ let budget = 2e5;
6097
+ const visit = (value, depth, inFonts) => {
6098
+ if (budget-- <= 0 || depth > 40 || !value || typeof value !== "object") return;
6099
+ if (Array.isArray(value)) {
6100
+ for (const v of value) visit(v, depth + 1, inFonts);
6101
+ return;
6102
+ }
6103
+ const entries = Object.entries(value);
6104
+ const file = entries.find(([, v]) => typeof v === "string" && FONT_FILE.test(v))?.[1];
6105
+ const face = inFonts || file !== void 0 || entries.some(([k]) => /^(?:weights?|src|url|file|style|display|unicodeRange)$/.test(k));
6106
+ for (const [key, v] of entries) {
6107
+ const list = /fonts$/i.test(key);
6108
+ if (typeof v === "string" && FONT_KEY.test(key) && !NOT_FONT_KEY.test(key) && (face || !/^family$/i.test(key))) add(v, /family/i.test(key) ? file : void 0);
6109
+ else if (Array.isArray(v) && list) {
6110
+ for (const item of v) if (typeof item === "string") add(item);
6111
+ }
6112
+ visit(v, depth + 1, list && Array.isArray(v));
6113
+ }
6114
+ };
6115
+ visit(data, 0, false);
6116
+ }
6117
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
6118
+ "node_modules",
6119
+ "dist",
6120
+ "vendor",
6121
+ "vendors",
6122
+ "third_party",
6123
+ "third-party",
6124
+ "bower_components",
6125
+ "jspm_packages",
6126
+ "web_modules",
6127
+ "coverage",
6128
+ "test",
6129
+ "tests",
6130
+ "__tests__"
6131
+ ]);
6132
+ var SOURCE_FILE = /\.(?:[cm]?[jt]sx?|html?|css|json)$/i;
6133
+ var NOT_SOURCE = /(?:\.min\.[cm]?js|\.bundle\.[cm]?js|\.d\.[cm]?ts|\.(?:test|spec)\.[cm]?[jt]sx?|^package(?:-lock)?\.json|^tsconfig\.json|^jsconfig\.json)$/i;
6134
+ function discoverFonts(dir) {
6135
+ const root = resolve6(dir);
6136
+ const seen = /* @__PURE__ */ new Map();
6137
+ const pages = [];
6138
+ let overlay = false;
6139
+ let files = 0;
6140
+ const adder = (from) => (value, source) => {
6141
+ const found = familyOf(value);
6142
+ if (!found) return;
6143
+ const key = found.family.toLowerCase();
6144
+ if (key === "sora") return;
6145
+ let entry = seen.get(key);
6146
+ if (!entry) seen.set(key, entry = { ref: { family: found.family }, uses: 0, order: seen.size });
6147
+ entry.uses++;
6148
+ if (entry.ref.family === entry.ref.family.toLowerCase() && found.family !== key) entry.ref.family = found.family;
6149
+ const file = source ?? found.source;
6150
+ if (file && !entry.ref.source) {
6151
+ entry.ref.source = file;
6152
+ entry.ref.from = from;
6153
+ }
6154
+ };
6155
+ const walk = (folder, depth) => {
6156
+ if (depth > 10 || files > 4e3) return;
6157
+ let entries;
6158
+ try {
6159
+ entries = readdirSync3(folder, { withFileTypes: true });
6160
+ } catch {
6161
+ return;
6162
+ }
6163
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
6164
+ if (entry.name.startsWith(".")) continue;
6165
+ const abs = join9(folder, entry.name);
6166
+ if (entry.isDirectory()) {
6167
+ if (!SKIP_DIRS.has(entry.name)) walk(abs, depth + 1);
6168
+ continue;
6169
+ }
6170
+ if (!entry.isFile() || !SOURCE_FILE.test(entry.name) || NOT_SOURCE.test(entry.name)) continue;
6171
+ const rel = relative3(root, abs);
6172
+ if (/^index\.html?$/i.test(entry.name)) pages.push(relative3(root, folder));
6173
+ let text2;
6174
+ try {
6175
+ if (statSync3(abs).size > 15e5) continue;
6176
+ text2 = readFileSync4(abs, "utf8");
6177
+ } catch {
6178
+ continue;
6179
+ }
6180
+ files++;
6181
+ const add = adder(rel);
6182
+ for (const family of googleFamilies(text2)) add(family);
6183
+ const ext = extname(entry.name).toLowerCase();
6184
+ if (ext === ".css") fromCss(text2, add);
6185
+ else if (ext === ".html" || ext === ".htm") overlay = fromHtml(text2, add, add) || overlay;
6186
+ else if (ext === ".json") fromJson(text2, add);
6187
+ else overlay = fromScript(text2, add) || overlay;
6188
+ }
6189
+ };
6190
+ walk(root, 0);
6191
+ const families = [...seen.values()].sort((a, b) => b.uses - a.uses || a.order - b.order).slice(0, MAX_FAMILIES).map((e) => e.ref);
6192
+ return { families, overlay, pages };
6193
+ }
6194
+ var NO_GAME_FONTS = { fonts: [], unavailable: [], overlay: false };
6195
+ var FONT_HOSTS = /* @__PURE__ */ new Set(["cdn.jsdelivr.net", "cdnjs.cloudflare.com", "unpkg.com", "esm.sh", "ga.jspm.io", "fonts.gstatic.com"]);
6196
+ var GOOGLE_WEIGHTS = ["800", "700", "600", "regular"];
6197
+ function parse(bytes) {
6198
+ try {
6199
+ return { font: readFont(bytes) };
6200
+ } catch (error) {
6201
+ return { why: /CFF/.test(String(error.message)) ? "CFF outlines, not read here" : "not a font file read here" };
6202
+ }
6203
+ }
6204
+ function cacheOf(options) {
6205
+ const dir = options.cacheDir ?? join9(tmpdir3(), "onceworlds-fonts");
6206
+ try {
6207
+ mkdirSync2(dir, { recursive: true });
6208
+ return dir;
6209
+ } catch {
6210
+ return null;
6211
+ }
6212
+ }
6213
+ async function download2(url, options) {
6214
+ const fetcher = options.fetch ?? fetch;
6215
+ try {
6216
+ const res = await fetcher(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 1e4) });
6217
+ if (!res.ok) return { why: `${new URL(url).host} answered ${res.status}` };
6218
+ const bytes = new Uint8Array(await res.arrayBuffer());
6219
+ return bytes.length > 0 && bytes.length < 3e7 ? bytes : { why: "an empty file" };
6220
+ } catch {
6221
+ return { why: "offline" };
6222
+ }
6223
+ }
6224
+ function cached2(cache, key) {
6225
+ if (!cache) return null;
6226
+ const fonts = [];
6227
+ for (let i = 0; i < 8; i++) {
6228
+ const file = join9(cache, `${key}.${i}.font`);
6229
+ if (!existsSync5(file)) break;
6230
+ try {
6231
+ fonts.push(readFont(readFileSync4(file)));
6232
+ } catch {
6233
+ return null;
6234
+ }
6235
+ }
6236
+ return fonts.length > 0 ? fontStackOf(fonts) : null;
6237
+ }
6238
+ function keep(cache, key, files) {
6239
+ if (!cache) return;
6240
+ try {
6241
+ files.forEach((bytes, i) => writeFileSync3(join9(cache, `${key}.${i}.font`), bytes));
6242
+ } catch {
6243
+ }
6244
+ }
6245
+ async function googleFont(family, options = {}) {
6246
+ const cache = cacheOf(options);
6247
+ const slug = `google-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
6248
+ for (const weight of GOOGLE_WEIGHTS) {
6249
+ const font = cached2(cache, `${slug}-${weight}`);
6250
+ if (font) return { font };
6251
+ }
6252
+ const fetcher = options.fetch ?? fetch;
6253
+ const name = encodeURIComponent(family).replace(/%20/g, "+");
6254
+ for (const weight of GOOGLE_WEIGHTS) {
6255
+ const url = `https://fonts.googleapis.com/css2?family=${name}${weight === "regular" ? "" : `:wght@${weight}`}`;
6256
+ let css;
6257
+ try {
6258
+ const res = await fetcher(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 1e4) });
6259
+ if (res.status === 400 || res.status === 404) continue;
6260
+ if (!res.ok) return { why: `Google Fonts answered ${res.status}` };
6261
+ css = await res.text();
6262
+ } catch {
6263
+ return { why: "offline" };
6264
+ }
6265
+ const urls = [...css.matchAll(/url\(\s*(['"]?)(https:[^'")\s]+)\1\s*\)/gi)].map((m) => m[2]).slice(0, 8);
6266
+ if (urls.length === 0) return { why: "no file on Google Fonts" };
6267
+ const files = [];
6268
+ const fonts = [];
6269
+ for (const url2 of urls) {
6270
+ const got = await download2(url2, options);
6271
+ if (!(got instanceof Uint8Array)) return got;
6272
+ const had = parse(got);
6273
+ if ("font" in had) {
6274
+ files.push(got);
6275
+ fonts.push(had.font);
6276
+ }
6277
+ }
6278
+ if (fonts.length === 0) return { why: "not a font file read here" };
6279
+ keep(cache, `${slug}-${weight}`, files);
6280
+ return { font: fontStackOf(fonts) };
6281
+ }
6282
+ return { why: "not on Google Fonts" };
6283
+ }
6284
+ async function remoteFont(url, options) {
6285
+ let host;
6286
+ try {
6287
+ host = new URL(url).host;
6288
+ } catch {
6289
+ return { why: "not an address" };
6290
+ }
6291
+ if (!FONT_HOSTS.has(host)) return { why: `${host} isn't a host the platform loads fonts from` };
6292
+ const cache = cacheOf(options);
6293
+ const key = `url-${createHash3("sha1").update(url).digest("hex").slice(0, 16)}`;
6294
+ const known = cached2(cache, key);
6295
+ if (known) return { font: known };
6296
+ const got = await download2(url, options);
6297
+ if (!(got instanceof Uint8Array)) return got;
6298
+ const had = parse(got);
6299
+ if ("font" in had) keep(cache, key, [got]);
6300
+ return had;
6301
+ }
6302
+ function gameFile(source, from, dir, pages) {
6303
+ let path = source.split(/[?#]/)[0];
6304
+ try {
6305
+ path = decodeURIComponent(path);
6306
+ } catch {
6307
+ }
6308
+ const rooted = path.startsWith("/");
6309
+ const rel = path.replace(/^\/+/, "");
6310
+ const bases = [...from && !rooted ? [dirname7(join9(dir, from))] : [], ...pages.map((p) => join9(dir, p)), dir];
6311
+ for (const base of bases) {
6312
+ const file = resolve6(base, rel);
6313
+ if (file !== dir && !file.startsWith(dir + sep)) continue;
6314
+ try {
6315
+ if (statSync3(file).isFile()) return file;
6316
+ } catch {
6317
+ }
6318
+ }
6319
+ return null;
6320
+ }
6321
+ async function have(ref, dir, pages, options) {
6322
+ const source = ref.source;
6323
+ if (!source) {
6324
+ const had = await googleFont(ref.family, options);
6325
+ const plain3 = ref.family.replace(/\s+Variable$/i, "");
6326
+ return "why" in had && had.why === "not on Google Fonts" && plain3 !== ref.family ? googleFont(plain3, options) : had;
6327
+ }
6328
+ if (/^data:/i.test(source)) {
6329
+ const m = /^data:[^,]*?(;base64)?,(.*)$/is.exec(source);
6330
+ if (!m) return { why: "not a font file read here" };
6331
+ let bytes;
6332
+ try {
6333
+ bytes = m[1] ? Buffer.from(m[2], "base64") : Buffer.from(decodeURIComponent(m[2]), "latin1");
6334
+ } catch {
6335
+ return { why: "not a font file read here" };
6336
+ }
6337
+ return parse(bytes);
6338
+ }
6339
+ if (/^(?:https?:)?\/\//i.test(source)) return remoteFont(source.startsWith("//") ? `https:${source}` : source, options);
6340
+ if (/^[a-z][\w+.-]*:/i.test(source)) return { why: "not a file" };
6341
+ const file = gameFile(source, ref.from, resolve6(dir), pages);
6342
+ if (!file) return { why: `no file ${source}` };
6343
+ try {
6344
+ return parse(readFileSync4(file));
6345
+ } catch {
6346
+ return { why: `no file ${source}` };
6347
+ }
6348
+ }
6349
+ async function loadGameFonts(dir, options = {}) {
6350
+ let found;
6351
+ try {
6352
+ found = discoverFonts(dir);
6353
+ } catch {
6354
+ return NO_GAME_FONTS;
6355
+ }
6356
+ const had = await Promise.all(found.families.map((ref) => have(ref, dir, found.pages, options).catch(() => ({ why: "not a font file read here" }))));
6357
+ const out = { fonts: [], unavailable: [], overlay: found.overlay };
6358
+ found.families.forEach((ref, i) => {
6359
+ const result = had[i];
6360
+ if ("font" in result) {
6361
+ const aliases = ref.source && !/^data:/i.test(ref.source) ? [.../* @__PURE__ */ new Set([ref.source, ref.source.split(/[?#]/)[0]])] : [];
6362
+ out.fonts.push({ family: ref.family, font: result.font, aliases });
6363
+ } else out.unavailable.push({ family: ref.family, why: result.why });
6364
+ });
6365
+ return out;
6366
+ }
6367
+ function fontBook(game, sora) {
6368
+ const families = /* @__PURE__ */ new Map();
6369
+ for (const { family, font, aliases } of game.fonts) {
6370
+ const drawn = sora ? fontStackOf([font, sora]) : font;
6371
+ for (const name of [family, ...aliases]) families.set(name.toLowerCase(), drawn);
6372
+ }
6373
+ return { fallback: sora, families };
6374
+ }
6375
+ function fontCoverage(game, sora) {
6376
+ return unionCoverage(
6377
+ [...game.fonts.map((f) => ({ name: f.family, coverage: f.font })), { name: "Sora", coverage: sora ?? rangeCoverage() }],
6378
+ game.unavailable.map((u) => u.family)
6379
+ );
6380
+ }
6381
+ var listOf = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
6382
+ function fontNotes(game) {
6383
+ const notes = [];
6384
+ const lost = game.unavailable;
6385
+ if (lost.length > 0) {
6386
+ notes.push(`${listOf(lost.map((u) => `${u.family} (${u.why})`))} could not be had: ${lost.length === 1 ? "its" : "their"} text is drawn in Sora`);
6387
+ }
6388
+ if (game.overlay) notes.push("the game's HTML overlay (ui.overlay) isn't in a headless picture: --browser draws it");
6389
+ return notes;
6390
+ }
5767
6391
 
5768
6392
  // src/raster.ts
5769
6393
  var NAMED = {
@@ -5836,16 +6460,20 @@ var Surface = class {
5836
6460
  height;
5837
6461
  /** Premultiplied RGBA, 0 to 1. */
5838
6462
  data;
6463
+ /** What text is drawn in when the state's font names no family of `book` (the engine's font). */
5839
6464
  font;
6465
+ /** The game's fonts by family: text is drawn in the first family of the state's font the book has. */
6466
+ book;
5840
6467
  state;
5841
6468
  stack = [];
5842
6469
  paths = [];
5843
6470
  current = null;
5844
- constructor(width, height, font = null) {
6471
+ constructor(width, height, fonts = null) {
5845
6472
  this.width = Math.max(1, Math.min(8192, Math.round(width) || 1));
5846
6473
  this.height = Math.max(1, Math.min(8192, Math.round(height) || 1));
5847
6474
  this.data = new Float32Array(this.width * this.height * 4);
5848
- this.font = font;
6475
+ this.book = fonts && "families" in fonts ? fonts : null;
6476
+ this.font = fonts && "families" in fonts ? fonts.fallback : fonts;
5849
6477
  this.state = {
5850
6478
  t: [1, 0, 0, 1, 0, 0],
5851
6479
  fill: [0, 0, 0, 1],
@@ -6121,8 +6749,8 @@ var Surface = class {
6121
6749
  const k = mask.data[j * mask.w + i];
6122
6750
  if (k <= 0) continue;
6123
6751
  const p = (mask.y0 + j) * this.width + mask.x0 + i;
6124
- const keep = 1 - k * (clip ? clip[p] : 1);
6125
- for (let c = 0; c < 4; c++) this.data[p * 4 + c] *= keep;
6752
+ const keep2 = 1 - k * (clip ? clip[p] : 1);
6753
+ for (let c = 0; c < 4; c++) this.data[p * 4 + c] *= keep2;
6126
6754
  }
6127
6755
  }
6128
6756
  }
@@ -6255,8 +6883,12 @@ var Surface = class {
6255
6883
  const weight = Number(/(?:^|\s)([1-9]00)(?:\s|$)/.exec(f)?.[1] ?? named);
6256
6884
  return { size, weight };
6257
6885
  }
6886
+ /** The font the state's font string draws in. */
6887
+ typeface() {
6888
+ return this.book ? pickFont(this.book, this.state.font) : this.font;
6889
+ }
6258
6890
  measureText(text2) {
6259
- return { width: measure(this.font, String(text2), this.fontOf().size) };
6891
+ return { width: measure(this.typeface(), String(text2), this.fontOf().size) };
6260
6892
  }
6261
6893
  fillText(text2, x, y) {
6262
6894
  this.text(String(text2), x, y, false);
@@ -6265,7 +6897,7 @@ var Surface = class {
6265
6897
  this.text(String(text2), x, y, true);
6266
6898
  }
6267
6899
  text(text2, x, y, outline) {
6268
- const font = this.font;
6900
+ const font = this.typeface();
6269
6901
  const { size, weight } = this.fontOf();
6270
6902
  if (!(size > 0) || text2 === "") return;
6271
6903
  const width = measure(font, text2, size);
@@ -6492,7 +7124,7 @@ function circle(cx, cy, r) {
6492
7124
  const n = Math.max(8, Math.min(64, Math.ceil(r * 1.5)));
6493
7125
  return Array.from({ length: n }, (_, i) => [cx + r * Math.cos(i / n * Math.PI * 2), cy + r * Math.sin(i / n * Math.PI * 2)]);
6494
7126
  }
6495
- function strokePolygons(pts, closed, half, cap, join13, miterLimit) {
7127
+ function strokePolygons(pts, closed, half, cap, join14, miterLimit) {
6496
7128
  const points = pts.filter((p, i) => i === 0 || Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]) > 1e-6);
6497
7129
  if (closed && points.length > 2 && Math.hypot(points[0][0] - points[points.length - 1][0], points[0][1] - points[points.length - 1][1]) < 1e-6) points.pop();
6498
7130
  const out = [];
@@ -6524,7 +7156,7 @@ function strokePolygons(pts, closed, half, cap, join13, miterLimit) {
6524
7156
  const v = points[i];
6525
7157
  const n1 = normals[(i - 1 + segs) % segs];
6526
7158
  const n2 = normals[i % segs];
6527
- if (join13 === "round") {
7159
+ if (join14 === "round") {
6528
7160
  out.push(circle(v[0], v[1], half));
6529
7161
  continue;
6530
7162
  }
@@ -6532,7 +7164,7 @@ function strokePolygons(pts, closed, half, cap, join13, miterLimit) {
6532
7164
  const p1 = [v[0] + n1[0] * half * side, v[1] + n1[1] * half * side];
6533
7165
  const p2 = [v[0] + n2[0] * half * side, v[1] + n2[1] * half * side];
6534
7166
  const poly = [v, p1, p2];
6535
- if (join13 === "miter") {
7167
+ if (join14 === "miter") {
6536
7168
  const mx = n1[0] + n2[0];
6537
7169
  const my = n1[1] + n2[1];
6538
7170
  const ml = Math.hypot(mx, my);
@@ -6696,9 +7328,9 @@ function boxBlur(src, w, h, r, horizontal) {
6696
7328
 
6697
7329
  // src/serve.ts
6698
7330
  import { createServer } from "node:http";
6699
- import { existsSync as existsSync5, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
7331
+ import { existsSync as existsSync6, readFileSync as readFileSync5, statSync as statSync4 } from "node:fs";
6700
7332
  import { readFile as readFile4 } from "node:fs/promises";
6701
- import { dirname as dirname7, extname, join as join9, resolve as resolve6, sep } from "node:path";
7333
+ import { dirname as dirname8, extname as extname2, join as join10, resolve as resolve7, sep as sep2 } from "node:path";
6702
7334
  var TYPES = {
6703
7335
  ".html": "text/html; charset=utf-8",
6704
7336
  ".js": "text/javascript",
@@ -6732,18 +7364,18 @@ var TYPES = {
6732
7364
  ".map": "application/json"
6733
7365
  };
6734
7366
  function engineFiles(dir, api2, version) {
6735
- for (let here = resolve6(dir); ; here = dirname7(here)) {
6736
- const pkg = join9(here, "node_modules", "@onceworlds", "engine");
6737
- for (const files of [join9(pkg, "dist"), pkg]) {
6738
- if (!existsSync5(join9(files, "index.js")) || !existsSync5(join9(files, "modules.js"))) continue;
7367
+ for (let here = resolve7(dir); ; here = dirname8(here)) {
7368
+ const pkg = join10(here, "node_modules", "@onceworlds", "engine");
7369
+ for (const files of [join10(pkg, "dist"), pkg]) {
7370
+ if (!existsSync6(join10(files, "index.js")) || !existsSync6(join10(files, "modules.js"))) continue;
6739
7371
  let installed = null;
6740
7372
  try {
6741
- installed = String(JSON.parse(readFileSync4(join9(pkg, "package.json"), "utf8")).version ?? "") || null;
7373
+ installed = String(JSON.parse(readFileSync5(join10(pkg, "package.json"), "utf8")).version ?? "") || null;
6742
7374
  } catch {
6743
7375
  }
6744
7376
  return { local: files, installed, remote: "" };
6745
7377
  }
6746
- if (dirname7(here) === here) break;
7378
+ if (dirname8(here) === here) break;
6747
7379
  }
6748
7380
  return { local: null, installed: null, remote: `${api2}/engine/${version}/` };
6749
7381
  }
@@ -6756,7 +7388,7 @@ function atLeast(version, least) {
6756
7388
  return true;
6757
7389
  }
6758
7390
  async function serveFolder(options) {
6759
- const dir = resolve6(options.dir);
7391
+ const dir = resolve7(options.dir);
6760
7392
  const engine = engineFiles(dir, options.api, options.version);
6761
7393
  const map = engine.local ? { "@onceworlds/engine": "/__engine/index.js", "@onceworlds/engine/modules": "/__engine/modules.js" } : { "@onceworlds/engine": `${engine.remote}engine.js`, "@onceworlds/engine/modules": `${engine.remote}modules.js` };
6762
7394
  const server = createServer(async (req, res) => {
@@ -6764,16 +7396,16 @@ async function serveFolder(options) {
6764
7396
  const path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
6765
7397
  const fromEngine = path.startsWith("/__engine/") && engine.local !== null;
6766
7398
  const base = fromEngine ? engine.local : dir;
6767
- let file = fromEngine ? join9(base, path.slice("/__engine/".length)) : join9(dir, path);
6768
- if (resolve6(file) !== base && !resolve6(file).startsWith(base + sep)) throw new Error("outside");
6769
- if (path.endsWith("/") || existsSync5(file) && statSync3(file).isDirectory()) file = join9(file, "index.html");
7399
+ let file = fromEngine ? join10(base, path.slice("/__engine/".length)) : join10(dir, path);
7400
+ if (resolve7(file) !== base && !resolve7(file).startsWith(base + sep2)) throw new Error("outside");
7401
+ if (path.endsWith("/") || existsSync6(file) && statSync4(file).isDirectory()) file = join10(file, "index.html");
6770
7402
  let body = await readFile4(file);
6771
7403
  if (file.endsWith(".html")) {
6772
7404
  const tag = `<script type="importmap">${JSON.stringify({ imports: map })}</script>`;
6773
7405
  const html = body.toString("utf8");
6774
7406
  body = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => `${m}${tag}`) : `${tag}${html}`;
6775
7407
  }
6776
- res.writeHead(200, { "Content-Type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream", "Cache-Control": "no-store" });
7408
+ res.writeHead(200, { "Content-Type": TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream", "Cache-Control": "no-store" });
6777
7409
  res.end(body);
6778
7410
  } catch {
6779
7411
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
@@ -6903,9 +7535,9 @@ async function fontFile(file, api2, fetcher, versions) {
6903
7535
  return null;
6904
7536
  }
6905
7537
  };
6906
- if (existsSync6(join10(LOCAL_FONTS, file))) return read(readFileSync5(join10(LOCAL_FONTS, file)));
6907
- const cached2 = join10(tmpdir3(), `onceworlds-${file}`);
6908
- if (existsSync6(cached2)) return read(readFileSync5(cached2));
7538
+ if (existsSync7(join11(LOCAL_FONTS, file))) return read(readFileSync6(join11(LOCAL_FONTS, file)));
7539
+ const cached3 = join11(tmpdir4(), `onceworlds-${file}`);
7540
+ if (existsSync7(cached3)) return read(readFileSync6(cached3));
6909
7541
  for (const version of [...versions, "1.1.0"]) {
6910
7542
  try {
6911
7543
  const res = await fetcher(`${api2}/engine/${version}/fonts/${file}`, { signal: AbortSignal.timeout(1e4) });
@@ -6914,7 +7546,7 @@ async function fontFile(file, api2, fetcher, versions) {
6914
7546
  const font = read(bytes);
6915
7547
  if (font) {
6916
7548
  try {
6917
- writeFileSync3(cached2, bytes);
7549
+ writeFileSync4(cached3, bytes);
6918
7550
  } catch {
6919
7551
  }
6920
7552
  return font;
@@ -6930,6 +7562,30 @@ async function engineFont(api2, fetcher = fetch, versions = []) {
6930
7562
  const extended = await fontFile(FONT_FILES[1], api2, fetcher, versions);
6931
7563
  return extended ? fontStackOf([latin, extended]) : latin;
6932
7564
  }
7565
+ async function textFonts(options) {
7566
+ const [sora, game] = await Promise.all([
7567
+ engineFont(options.api, options.fetch, options.version ? [options.version] : []).catch(() => null),
7568
+ loadGameFonts(options.dir, { fetch: options.fetch, cacheDir: options.fontCache }).catch(() => NO_GAME_FONTS)
7569
+ ]);
7570
+ return { sora, game, book: fontBook(game, sora), coverage: fontCoverage(game, sora) };
7571
+ }
7572
+ async function textCoverage(dir, api2, fetcher = fetch) {
7573
+ const fonts = await textFonts({ dir, api: api2, fetch: fetcher });
7574
+ return fonts.coverage;
7575
+ }
7576
+ function metricsTable(book) {
7577
+ const table = { def: metricsOf(book.fallback), fam: {}, list: [] };
7578
+ const index = /* @__PURE__ */ new Map();
7579
+ for (const [family, font] of book.families) {
7580
+ let at = index.get(font);
7581
+ if (at === void 0) {
7582
+ at = table.list.push(metricsOf(font)) - 1;
7583
+ index.set(font, at);
7584
+ }
7585
+ table.fam[family] = at;
7586
+ }
7587
+ return table;
7588
+ }
6933
7589
  function metricsOf(font) {
6934
7590
  if (!font) return { upm: 1e3, asc: 800, desc: -200, adv: {}, avg: 550 };
6935
7591
  const adv = {};
@@ -6952,8 +7608,10 @@ function posterSize(name, width, height) {
6952
7608
  function recorderScript(options) {
6953
7609
  return `
6954
7610
  const OUT = ${JSON.stringify(options.out)};
6955
- const DIR = ${JSON.stringify(resolve7(options.dir))};
7611
+ const DIR = ${JSON.stringify(resolve8(options.dir))};
6956
7612
  const M = ${JSON.stringify(options.metrics)};
7613
+ // A CSS font's family list (font.ts's FONT_FAMILY_LIST): text is measured in its first family this process draws with, else in Sora.
7614
+ const FAMILIES = new RegExp(${JSON.stringify(FONT_FAMILY_LIST.source)}, 'i');
6957
7615
  const MODE = ${JSON.stringify({ poster: options.poster ?? null, phase: options.phase ?? "play", story: options.story === true, at: options.at, width: options.width, height: options.height })};
6958
7616
  const { writeFileSync, readFileSync } = await import('node:fs');
6959
7617
  const { resolve: resolvePath, sep } = await import('node:path');
@@ -6971,11 +7629,25 @@ function record(canvas, op, args) {
6971
7629
  log.push([canvas.id, seq++, op, ...args.map(round)]);
6972
7630
  }
6973
7631
  const sizeOf = (font) => Number(/(\\d+(?:\\.\\d+)?)px/.exec(String(font))?.[1] ?? 10);
7632
+ const picked = new Map();
7633
+ function metricsOf(font) {
7634
+ const css = String(font);
7635
+ let m = picked.get(css);
7636
+ if (m) return m;
7637
+ m = M.def;
7638
+ for (const part of (FAMILIES.exec(css)?.[1] ?? css).split(',')) {
7639
+ const name = part.trim().replace(/^(['"])(.*)\\1$/, '$2').trim().toLowerCase();
7640
+ if (Object.hasOwn(M.fam, name)) { m = M.list[M.fam[name]]; break; }
7641
+ }
7642
+ if (picked.size < 500) picked.set(css, m);
7643
+ return m;
7644
+ }
6974
7645
  function measure(font, text) {
7646
+ const m = metricsOf(font);
6975
7647
  const size = sizeOf(font);
6976
7648
  let units = 0;
6977
- for (const ch of String(text)) units += M.adv[ch.codePointAt(0)] ?? M.avg;
6978
- return (units * size) / M.upm;
7649
+ for (const ch of String(text)) units += m.adv[ch.codePointAt(0)] ?? m.avg;
7650
+ return (units * size) / m.upm;
6979
7651
  }
6980
7652
  class RecCanvas {
6981
7653
  constructor(w, h) { this.id = nextId++; this._w = Math.max(0, w | 0); this._h = Math.max(0, h | 0); this.style = {}; canvases[this.id] = [this._w, this._h]; this._ctx = null; }
@@ -7017,7 +7689,8 @@ class RecContext {
7017
7689
  getLineDash() { return [...this._dash]; }
7018
7690
  measureText(text) {
7019
7691
  const size = sizeOf(this._state.font);
7020
- return { width: measure(this._state.font, text), actualBoundingBoxAscent: (M.asc / M.upm) * size, actualBoundingBoxDescent: (-M.desc / M.upm) * size, fontBoundingBoxAscent: (M.asc / M.upm) * size, fontBoundingBoxDescent: (-M.desc / M.upm) * size, actualBoundingBoxLeft: 0, actualBoundingBoxRight: measure(this._state.font, text) };
7692
+ const m = metricsOf(this._state.font);
7693
+ return { width: measure(this._state.font, text), actualBoundingBoxAscent: (m.asc / m.upm) * size, actualBoundingBoxDescent: (-m.desc / m.upm) * size, fontBoundingBoxAscent: (m.asc / m.upm) * size, fontBoundingBoxDescent: (-m.desc / m.upm) * size, actualBoundingBoxLeft: 0, actualBoundingBoxRight: measure(this._state.font, text) };
7021
7694
  }
7022
7695
  /** Everything before a call that covers the whole canvas is gone from it: kept logs stay one frame long. */
7023
7696
  _covers(x, y, w, h) {
@@ -7285,9 +7958,13 @@ function replay(record, font) {
7285
7958
  }
7286
7959
  return { surface: surfaceOf(record.root), problems: [...problems], texts: [...texts] };
7287
7960
  }
7288
- function glyphProblems(texts, font) {
7289
- const missing = font ? missingGlyphs(texts, font) : [];
7290
- return missing.length > 0 ? [glyphNote(missing)] : [];
7961
+ function glyphProblems(texts, coverage2) {
7962
+ const missing = missingGlyphs(texts, coverage2);
7963
+ return missing.length > 0 ? [glyphNote(missing, coverage2)] : [];
7964
+ }
7965
+ function textNotes(fonts, texts, overlay) {
7966
+ const bars = fonts.sora ? [] : [`${fonts.game.fonts.length > 0 ? "text in Sora" : "text"} is drawn as bars: the engine's font could not be had`];
7967
+ return [...fontNotes({ ...fonts.game, overlay: overlay && fonts.game.overlay }), ...glyphProblems(texts, fonts.coverage), ...bars];
7291
7968
  }
7292
7969
  function applySet(s, prop, value) {
7293
7970
  const v = value;
@@ -7303,8 +7980,8 @@ function greyBox(s, args) {
7303
7980
  s.fillRect(dx, dy, dw, dh);
7304
7981
  s.restore();
7305
7982
  }
7306
- async function shootHeadless(options, font) {
7307
- const dir = resolve7(options.dir);
7983
+ async function shootHeadless(options, fonts) {
7984
+ const dir = resolve8(options.dir);
7308
7985
  const config = findConfigModule(dir);
7309
7986
  const project = config ? null : findProject(dir);
7310
7987
  if (!config && !project) throw new Error("No game.config.js (export const config = {...}) or project.json here: shot plays the game from its config, as check does.");
@@ -7312,56 +7989,56 @@ async function shootHeadless(options, font) {
7312
7989
  const size = options.poster ? posterSize(options.poster, options.width, options.height) : { width: options.width ?? 1280, height: options.height ?? 720 };
7313
7990
  const phase = options.poster ? void 0 : options.phase ?? "play";
7314
7991
  const at = options.at ?? defaultAt(phase);
7315
- const temp = mkdtempSync2(join10(tmpdir3(), "onceworlds-shot-"));
7992
+ const temp = mkdtempSync2(join11(tmpdir4(), "onceworlds-shot-"));
7316
7993
  try {
7317
- const out = join10(temp, "record.json");
7318
- const script = recorderScript({ config: config ?? void 0, project: project ?? void 0, out, poster: options.poster, phase, at, ...size, metrics: metricsOf(font), dir });
7994
+ const out = join11(temp, "record.json");
7995
+ const script = recorderScript({ config: config ?? void 0, project: project ?? void 0, out, poster: options.poster, phase, at, ...size, metrics: metricsTable(fonts.book), dir });
7319
7996
  const ran = await runProgram(process.execPath, ["--input-type=module", "--eval", script], dir, 3 * 6e4);
7320
- if (!existsSync6(out)) {
7997
+ if (!existsSync7(out)) {
7321
7998
  const tail = ran.output.trimEnd().split("\n").slice(-12).join("\n");
7322
7999
  throw new Error(ran.timedOut ? "The game did not finish its frames in 3 minutes." : `The game could not be played headless:
7323
8000
  ${tail}`);
7324
8001
  }
7325
- const record = JSON.parse(readFileSync5(out, "utf8"));
8002
+ const record = JSON.parse(readFileSync6(out, "utf8"));
7326
8003
  if (record.error && !record.three) throw new Error(`The game could not be drawn: ${record.error.split("\n").slice(0, 4).join(" ")}`);
7327
- const { surface, problems, texts } = replay(record, font);
8004
+ const { surface, problems, texts } = replay(record, fonts.book);
7328
8005
  const png = encodePng(surface.width, surface.height, surface.rgba());
7329
8006
  const what = options.poster ? `the "${options.poster}" poster` : headlessMoment(phase ?? "play", record.seconds ?? at, record.phase);
7330
- const notes = [...problems, ...glyphProblems(texts, font), ...font ? [] : ["text is drawn as bars: the engine's font could not be had"], ...record.errors.map((e) => `the game reported: ${e}`)];
8007
+ const notes = [...problems, ...textNotes(fonts, texts, !options.poster), ...record.errors.map((e) => `the game reported: ${e}`)];
7331
8008
  return { png, width: surface.width, height: surface.height, caption: [what, ...notes].join("; "), three: record.three };
7332
8009
  } finally {
7333
8010
  rmSync2(temp, { recursive: true, force: true });
7334
8011
  }
7335
8012
  }
7336
- async function storyHeadless(options, font) {
7337
- const dir = resolve7(options.dir);
8013
+ async function storyHeadless(options, fonts) {
8014
+ const dir = resolve8(options.dir);
7338
8015
  const config = findConfigModule(dir);
7339
8016
  const project = config ? null : findProject(dir);
7340
8017
  if (!config && !project) throw new Error("No game.config.js (export const config = {...}) or project.json here: shot plays the game from its config, as check does.");
7341
8018
  if (!hasNodeModules(dir)) throw new Error('@onceworlds/engine is not installed here: run "npm install" in the game folder first.');
7342
8019
  const size = { width: options.width ?? 960, height: options.height ?? 540 };
7343
- const temp = mkdtempSync2(join10(tmpdir3(), "onceworlds-story-"));
8020
+ const temp = mkdtempSync2(join11(tmpdir4(), "onceworlds-story-"));
7344
8021
  try {
7345
- const out = join10(temp, "record.json");
7346
- const script = recorderScript({ config: config ?? void 0, project: project ?? void 0, out, story: true, at: options.at ?? STORY_SECONDS, ...size, metrics: metricsOf(font), dir });
8022
+ const out = join11(temp, "record.json");
8023
+ const script = recorderScript({ config: config ?? void 0, project: project ?? void 0, out, story: true, at: options.at ?? STORY_SECONDS, ...size, metrics: metricsTable(fonts.book), dir });
7347
8024
  const ran = await runProgram(process.execPath, ["--input-type=module", "--eval", script], dir, 5 * 6e4);
7348
- if (!existsSync6(out)) {
8025
+ if (!existsSync7(out)) {
7349
8026
  const tail = ran.output.trimEnd().split("\n").slice(-12).join("\n");
7350
8027
  throw new Error(ran.timedOut ? "The game did not finish its first minute in 5 minutes." : `The game could not be played headless:
7351
8028
  ${tail}`);
7352
8029
  }
7353
- const record = JSON.parse(readFileSync5(out, "utf8"));
8030
+ const record = JSON.parse(readFileSync6(out, "utf8"));
7354
8031
  if (record.three) return { frames: [], notes: [], three: true };
7355
8032
  if (record.error) throw new Error(`The game could not be drawn: ${record.error.split("\n").slice(0, 4).join(" ")}`);
7356
8033
  const problems = /* @__PURE__ */ new Set();
7357
8034
  const texts = /* @__PURE__ */ new Set();
7358
8035
  const frames = (record.frames ?? []).map((f) => {
7359
- const { surface, problems: p, texts: drawn } = replay({ ops: f.ops, canvases: f.canvases, root: record.root, images: record.images }, font);
8036
+ const { surface, problems: p, texts: drawn } = replay({ ops: f.ops, canvases: f.canvases, root: record.root, images: record.images }, fonts.book);
7360
8037
  for (const x of p) problems.add(x);
7361
8038
  for (const t of drawn) texts.add(t);
7362
8039
  return { label: f.label, seconds: f.seconds, image: surface };
7363
8040
  });
7364
- const notes = [...problems, ...glyphProblems(texts, font), ...font ? [] : ["text is drawn as bars: the engine's font could not be had"], ...record.errors.map((e) => `the game reported: ${e}`)];
8041
+ const notes = [...problems, ...textNotes(fonts, texts, true), ...record.errors.map((e) => `the game reported: ${e}`)];
7365
8042
  return { frames, notes, three: false };
7366
8043
  } finally {
7367
8044
  rmSync2(temp, { recursive: true, force: true });
@@ -7529,15 +8206,16 @@ async function storyBrowser(options, version) {
7529
8206
  }
7530
8207
  async function shootStory(options) {
7531
8208
  const version = options.version ?? ENGINE_VERSION;
7532
- const font = await engineFont(options.api, options.fetch, options.version ? [options.version] : []);
8209
+ const fonts = options.browser ? null : await textFonts(options);
7533
8210
  let story;
7534
- if (options.browser) story = await storyBrowser(options, version);
8211
+ if (!fonts) story = await storyBrowser(options, version);
7535
8212
  else {
7536
- const headless = await storyHeadless(options, font);
8213
+ const headless = await storyHeadless(options, fonts);
7537
8214
  story = headless.three ? await storyBrowser(options, version) : headless;
7538
8215
  }
7539
8216
  if (story.frames.length === 0) throw new Error("The game drew nothing to look at in its first minute.");
7540
- const sheet = contactSheet(story.frames, font);
8217
+ const sora = fonts ? fonts.sora : await engineFont(options.api, options.fetch, options.version ? [options.version] : []).catch(() => null);
8218
+ const sheet = contactSheet(story.frames, sora);
7541
8219
  const frames = story.frames.map((f, i) => {
7542
8220
  const surface = f.image instanceof Surface ? f.image : null;
7543
8221
  const png = surface ? encodePng(surface.width, surface.height, surface.rgba()) : encodePng(f.image.width, f.image.height, toRgba(f.image.data));
@@ -7560,16 +8238,15 @@ function toRgba(data) {
7560
8238
  async function shoot(options) {
7561
8239
  const version = options.version ?? ENGINE_VERSION;
7562
8240
  if (options.browser) return shootBrowser(options, version);
7563
- const font = await engineFont(options.api, options.fetch, options.version ? [options.version] : []);
7564
- const headless = await shootHeadless(options, font);
8241
+ const headless = await shootHeadless(options, await textFonts(options));
7565
8242
  if (!headless.three) return headless;
7566
8243
  return shootBrowser(options, version);
7567
8244
  }
7568
8245
 
7569
8246
  // src/assets.ts
7570
- import { mkdtempSync as mkdtempSync3, readFileSync as readFileSync6, rmSync as rmSync3, existsSync as existsSync7 } from "node:fs";
7571
- import { tmpdir as tmpdir4 } from "node:os";
7572
- import { join as join11, resolve as resolve8 } from "node:path";
8247
+ import { mkdtempSync as mkdtempSync3, readFileSync as readFileSync7, rmSync as rmSync3, existsSync as existsSync8 } from "node:fs";
8248
+ import { tmpdir as tmpdir5 } from "node:os";
8249
+ import { join as join12, resolve as resolve9 } from "node:path";
7573
8250
  var LIBRARY_EXPORTS = ["assetLibrary", "library", "Library", "assetIndex", "libraryIndex", "ASSET_LIBRARY", "searchAssets"];
7574
8251
  function normalizeAsset(raw) {
7575
8252
  if (!raw || typeof raw !== "object") return null;
@@ -7627,14 +8304,14 @@ writeFileSync(${JSON.stringify(out)}, JSON.stringify(result, (k, v) => (typeof v
7627
8304
  `;
7628
8305
  }
7629
8306
  async function searchAssets(dir, api2, query, options = {}, fetcher = fetch) {
7630
- const folder = resolve8(dir);
8307
+ const folder = resolve9(dir);
7631
8308
  if (hasNodeModules(folder)) {
7632
- const temp = mkdtempSync3(join11(tmpdir4(), "onceworlds-assets-"));
8309
+ const temp = mkdtempSync3(join12(tmpdir5(), "onceworlds-assets-"));
7633
8310
  try {
7634
- const out = join11(temp, "catalog.json");
8311
+ const out = join12(temp, "catalog.json");
7635
8312
  await runProgram(process.execPath, ["--input-type=module", "--eval", catalogScript(out, query, options)], folder, 6e4);
7636
- if (existsSync7(out)) {
7637
- const result = JSON.parse(readFileSync6(out, "utf8"));
8313
+ if (existsSync8(out)) {
8314
+ const result = JSON.parse(readFileSync7(out, "utf8"));
7638
8315
  if (Array.isArray(result.searched)) return result.searched.map(normalizeAsset).filter((a) => a !== null).slice(0, options.limit ?? 8);
7639
8316
  if (Array.isArray(result.entries)) return rankAssets(result.entries.map(normalizeAsset).filter((a) => a !== null), query, options);
7640
8317
  }
@@ -7746,7 +8423,7 @@ function checkProjectConfig(config) {
7746
8423
  }
7747
8424
 
7748
8425
  // src/index.ts
7749
- var CLI_VERSION = "0.9.4";
8426
+ var CLI_VERSION = "0.9.5";
7750
8427
  var PRODUCTION_API = "https://onceworlds.com";
7751
8428
  var LOCAL_API = "http://localhost:8787";
7752
8429
  var USAGE = `Usage:
@@ -7873,7 +8550,7 @@ async function main() {
7873
8550
  console.log(USAGE);
7874
8551
  return;
7875
8552
  }
7876
- const projectDir = resolve9(projectArg ?? ".");
8553
+ const projectDir = resolve10(projectArg ?? ".");
7877
8554
  const api2 = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
7878
8555
  if (command === "api" || command === "recipe" || command === "guide") {
7879
8556
  const words3 = positionals.slice(1).join(" ").trim();
@@ -7995,7 +8672,7 @@ async function dev(projectDir, api2, tokenArg, players = 1) {
7995
8672
  for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
7996
8673
  console.log(` Watching ${root}. Press Ctrl+C to stop.
7997
8674
  `);
7998
- const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile5(join12(projectDir, "onceworlds.json"), "utf8").catch(() => "");
8675
+ const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile5(join13(projectDir, "onceworlds.json"), "utf8").catch(() => "");
7999
8676
  let lastSignature = await projectSignature();
8000
8677
  let timer;
8001
8678
  let running = false;
@@ -8028,17 +8705,17 @@ async function dev(projectDir, api2, tokenArg, players = 1) {
8028
8705
  timer = setTimeout(run, 250);
8029
8706
  };
8030
8707
  watch(root, { recursive: true }, (_event, filename) => {
8031
- if (filename && filename.split(sep2).some((part) => part.startsWith(".") || part === "node_modules")) return;
8708
+ if (filename && filename.split(sep3).some((part) => part.startsWith(".") || part === "node_modules")) return;
8032
8709
  trigger();
8033
8710
  });
8034
8711
  await new Promise(() => {
8035
8712
  });
8036
8713
  }
8037
8714
  async function designDoc(projectDir) {
8038
- return readFile5(join12(projectDir, "DESIGN.md"), "utf8").catch(() => null);
8715
+ return readFile5(join13(projectDir, "DESIGN.md"), "utf8").catch(() => null);
8039
8716
  }
8040
8717
  async function kitPrintOf(projectDir) {
8041
- return readKitPrint(await readFile5(join12(projectDir, KIT_PRINT_PATH), "utf8").catch(() => null));
8718
+ return readKitPrint(await readFile5(join13(projectDir, KIT_PRINT_PATH), "utf8").catch(() => null));
8042
8719
  }
8043
8720
  async function readSources(files) {
8044
8721
  const sources = [];
@@ -8130,7 +8807,7 @@ async function writeControls(projectDir, config, hints) {
8130
8807
  } catch (err) {
8131
8808
  throw new CliError(`The action map gives controls the platform won't take: ${err.message}.`);
8132
8809
  }
8133
- const file = join12(projectDir, "onceworlds.json");
8810
+ const file = join13(projectDir, "onceworlds.json");
8134
8811
  const written = JSON.parse(await readFile5(file, "utf8"));
8135
8812
  written.controls = controls;
8136
8813
  await writeFile4(file, `${JSON.stringify(written, null, 2)}
@@ -8145,7 +8822,7 @@ async function check(projectDir, options) {
8145
8822
  const sources = await readSources(files);
8146
8823
  const engine = await isEngineGame(projectDir, config);
8147
8824
  if (engine) console.log("Playing the game headless (its tests, then simulated matches with chaos)...");
8148
- const result = engine ? await checkEngineGame({ dir: projectDir, configFile: options.config, font: await engineFont(options.api).catch(() => null) }) : null;
8825
+ const result = engine ? await checkEngineGame({ dir: projectDir, configFile: options.config, font: await textCoverage(projectDir, options.api).catch(() => null) }) : null;
8149
8826
  const findings = projectFindings(config, sources, result?.controls, await assetsOf(files), await configProblems(projectDir, config), await designDoc(projectDir), await kitPrintOf(projectDir));
8150
8827
  console.log(`
8151
8828
  Checked ${sources.length} file${sources.length === 1 ? "" : "s"} in ${root}.`);
@@ -8186,8 +8863,8 @@ ${formatBuildTemplates(scenes)}
8186
8863
  if (values.space !== void 0 && !SPACES.includes(values.space)) throw new CliError(`--space is ${SPACES.join(" or ")}.`);
8187
8864
  const folder = dirArg ?? values.slug;
8188
8865
  if (!folder) throw new CliError("Say which folder to make: onceworlds new <folder> (blank), or add --kit <name> to start from a kit (--list shows them).");
8189
- const dir = resolve9(folder);
8190
- const source = basename4(dir);
8866
+ const dir = resolve10(folder);
8867
+ const source = basename5(dir);
8191
8868
  const slug = values.slug ?? slugFrom(source);
8192
8869
  if (!slug) throw new CliError(`"${source}" can't be a game's link: pass --slug (lowercase letters, digits and dashes).`);
8193
8870
  const made = await newGame({ api: api2, dir, slug, name: values.name ?? titleFrom(source), description: values.description, kit: values.kit, template: values.template, space: values.space });
@@ -8215,14 +8892,14 @@ Next:
8215
8892
  Read AGENTS.md: the guide for you and your AI tool. Look the engine up with onceworlds api <name> and onceworlds recipe.`);
8216
8893
  }
8217
8894
  function publishRoot(projectDir, config) {
8218
- const root = resolve9(projectDir, config.dir ?? ".");
8219
- if (root !== projectDir && !root.startsWith(projectDir + sep2)) {
8895
+ const root = resolve10(projectDir, config.dir ?? ".");
8896
+ if (root !== projectDir && !root.startsWith(projectDir + sep3)) {
8220
8897
  throw new CliError(`"dir" in onceworlds.json (${config.dir}) must be a folder inside the project.`);
8221
8898
  }
8222
8899
  return root;
8223
8900
  }
8224
8901
  async function loadConfig(projectDir, options = {}) {
8225
- const file = join12(projectDir, "onceworlds.json");
8902
+ const file = join13(projectDir, "onceworlds.json");
8226
8903
  let config;
8227
8904
  try {
8228
8905
  config = JSON.parse(await readFile5(file, "utf8"));
@@ -8263,8 +8940,8 @@ function storeImages(config) {
8263
8940
  }
8264
8941
  async function readStoreImage(projectDir, value, slot, field2) {
8265
8942
  if (typeof value !== "string" || !value.trim()) return { problem: `"${field2}" in onceworlds.json must be the path of an image, like "store/icon.png".` };
8266
- const abs = resolve9(projectDir, value);
8267
- if (!abs.startsWith(projectDir + sep2)) return { problem: `"${field2}" in onceworlds.json (${value}) must be inside the project folder.` };
8943
+ const abs = resolve10(projectDir, value);
8944
+ if (!abs.startsWith(projectDir + sep3)) return { problem: `"${field2}" in onceworlds.json (${value}) must be inside the project folder.` };
8268
8945
  let data;
8269
8946
  try {
8270
8947
  data = await readFile5(abs);
@@ -8276,7 +8953,7 @@ async function readStoreImage(projectDir, value, slot, field2) {
8276
8953
  if (!info) return { problem: `${value} ("${field2}") must be a PNG, JPEG, WebP or GIF.` };
8277
8954
  const problem = imageSizeProblem(slot, info.width, info.height);
8278
8955
  if (problem) return { problem: `${value} ("${field2}") is ${info.width}\xD7${info.height}. ${problem}` };
8279
- return { file: { path: value, abs, hash: createHash3("sha256").update(data).digest("hex"), size: data.byteLength } };
8956
+ return { file: { path: value, abs, hash: createHash4("sha256").update(data).digest("hex"), size: data.byteLength } };
8280
8957
  }
8281
8958
  async function loadStore(projectDir, config) {
8282
8959
  const images = /* @__PURE__ */ new Map();
@@ -8319,7 +8996,7 @@ async function collectFiles(root) {
8319
8996
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
8320
8997
  if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name) || TEST_FILE.test(entry.name)) continue;
8321
8998
  if (entry.isDirectory() && (prefix === "" ? DEV_FOLDERS.has(entry.name) : entry.name === "__tests__")) continue;
8322
- const abs = join12(dir, entry.name);
8999
+ const abs = join13(dir, entry.name);
8323
9000
  const path = prefix + entry.name;
8324
9001
  if (entry.isDirectory()) {
8325
9002
  await walk(abs, `${path}/`);
@@ -8332,7 +9009,7 @@ async function collectFiles(root) {
8332
9009
  if (data.byteLength > LIMITS.deployFileBytes) {
8333
9010
  throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
8334
9011
  }
8335
- files.push({ path, abs, hash: createHash3("sha256").update(data).digest("hex"), size: data.byteLength });
9012
+ files.push({ path, abs, hash: createHash4("sha256").update(data).digest("hex"), size: data.byteLength });
8336
9013
  }
8337
9014
  }
8338
9015
  }
@@ -8388,7 +9065,7 @@ async function whoami(api2) {
8388
9065
  }
8389
9066
  async function runPlaytest(projectDir, api2, options) {
8390
9067
  const { url, draft, chrome } = await draftToPlay(projectDir, api2, "Playtesting");
8391
- const pictures = options.pictures ? join12(projectDir, "store") : void 0;
9068
+ const pictures = options.pictures ? join13(projectDir, "store") : void 0;
8392
9069
  const result = await playtest({
8393
9070
  chrome,
8394
9071
  url,
@@ -8417,12 +9094,12 @@ async function runPosters(projectDir, api2, only) {
8417
9094
  const result = await capturePosters({
8418
9095
  chrome,
8419
9096
  url,
8420
- dir: join12(projectDir, "store"),
9097
+ dir: join13(projectDir, "store"),
8421
9098
  only: only ? only.split(",").map((name) => name.trim()).filter(Boolean) : void 0,
8422
9099
  log: (name) => console.error(` posters: ${name}`)
8423
9100
  });
8424
9101
  const lines = [formatPosters(result)];
8425
- const file = join12(projectDir, "onceworlds.json");
9102
+ const file = join13(projectDir, "onceworlds.json");
8426
9103
  const config = JSON.parse(await readFile5(file, "utf8"));
8427
9104
  const named = listingFrom(result.saved, config);
8428
9105
  if (named.icon || named.thumbnails) {
@@ -8449,11 +9126,11 @@ async function runShot(projectDir, api2, values) {
8449
9126
  const story = await shootStory({ dir: projectDir, api: api2, at, ...sizeArg(values.size), browser: values.browser === true }).catch((err) => {
8450
9127
  throw new CliError(err.message);
8451
9128
  });
8452
- const folder = resolve9(projectDir, "store", "story");
9129
+ const folder = resolve10(projectDir, "store", "story");
8453
9130
  await mkdir4(folder, { recursive: true });
8454
- for (const frame of story.frames) await writeFile4(join12(folder, frame.file), frame.png);
8455
- const sheet = resolve9(projectDir, values.out ?? join12("store", "story.png"));
8456
- await mkdir4(dirname8(sheet), { recursive: true });
9131
+ for (const frame of story.frames) await writeFile4(join13(folder, frame.file), frame.png);
9132
+ const sheet = resolve10(projectDir, values.out ?? join13("store", "story.png"));
9133
+ await mkdir4(dirname9(sheet), { recursive: true });
8457
9134
  await writeFile4(sheet, story.sheet.png);
8458
9135
  console.log(`Saved ${sheet} (${story.sheet.width}x${story.sheet.height}) and ${story.frames.length} frames in ${folder}: ${story.sheet.caption}.`);
8459
9136
  console.log(`
@@ -8468,13 +9145,13 @@ ${STORY_CHECKLIST.map((q) => ` - ${q}`).join("\n")}`);
8468
9145
  const shot = await shoot({ dir: projectDir, api: api2, poster: values.poster, phase, at, ...sizeArg(values.size), browser: values.browser === true }).catch((err) => {
8469
9146
  throw new CliError(err.message);
8470
9147
  });
8471
- const file = resolve9(projectDir, values.out ?? join12("store", values.poster ? `shot-${values.poster}.png` : phase && phase !== "play" ? `shot-${phase}.png` : "shot.png"));
8472
- await mkdir4(dirname8(file), { recursive: true });
9148
+ const file = resolve10(projectDir, values.out ?? join13("store", values.poster ? `shot-${values.poster}.png` : phase && phase !== "play" ? `shot-${phase}.png` : "shot.png"));
9149
+ await mkdir4(dirname9(file), { recursive: true });
8473
9150
  await writeFile4(file, shot.png);
8474
9151
  console.log(`Saved ${file} (${shot.width}x${shot.height}): ${shot.caption}.`);
8475
9152
  }
8476
9153
  async function servedRoot(projectDir) {
8477
- if (!existsSync8(join12(projectDir, "onceworlds.json"))) return { root: projectDir };
9154
+ if (!existsSync9(join13(projectDir, "onceworlds.json"))) return { root: projectDir };
8478
9155
  const config = await loadConfig(projectDir, { strict: false });
8479
9156
  return { root: publishRoot(projectDir, config), name: config.name };
8480
9157
  }
@@ -8489,7 +9166,7 @@ async function runServe(projectDir, api2, values) {
8489
9166
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new CliError("--port is a port number (0 for any free one).");
8490
9167
  if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
8491
9168
  const { root, name } = await servedRoot(projectDir);
8492
- if (!existsSync8(join12(root, "index.html"))) throw new CliError(`No index.html in ${root}: serve plays the page a game starts from.`);
9169
+ if (!existsSync9(join13(root, "index.html"))) throw new CliError(`No index.html in ${root}: serve plays the page a game starts from.`);
8493
9170
  const served = await serveFolder({ dir: root, api: api2, version: ENGINE_VERSION, port });
8494
9171
  const { lines } = servedNotes(served, { bots, name });
8495
9172
  console.log(`${lines.join("\n")}
@@ -8498,7 +9175,7 @@ async function runServe(projectDir, api2, values) {
8498
9175
  });
8499
9176
  }
8500
9177
  async function namePictures(projectDir) {
8501
- const file = join12(projectDir, "onceworlds.json");
9178
+ const file = join13(projectDir, "onceworlds.json");
8502
9179
  const config = JSON.parse(await readFile5(file, "utf8"));
8503
9180
  let changed = false;
8504
9181
  if (!config.icon) {
@@ -8525,7 +9202,7 @@ function mcpHost(api2) {
8525
9202
  },
8526
9203
  engineCheck: async (dir) => {
8527
9204
  const config = await loadConfig(dir);
8528
- return await isEngineGame(dir, config) ? checkEngineGame({ dir, font: await engineFont(api2).catch(() => null) }) : null;
9205
+ return await isEngineGame(dir, config) ? checkEngineGame({ dir, font: await textCoverage(dir, api2).catch(() => null) }) : null;
8529
9206
  },
8530
9207
  playtest: (dir, options) => runPlaytest(dir, api2, options),
8531
9208
  index: async (dir) => (await loadIndex({ dir, api: api2 })).index,
@@ -8538,7 +9215,7 @@ function mcpHost(api2) {
8538
9215
  searchAssets: (dir, query, options) => searchAssets(dir, api2, query, options),
8539
9216
  serve: async (dir, options) => {
8540
9217
  const { root, name } = await servedRoot(dir);
8541
- if (!existsSync8(join12(root, "index.html"))) throw new CliError(`No index.html in ${root}: serve plays the page a game starts from.`);
9218
+ if (!existsSync9(join13(root, "index.html"))) throw new CliError(`No index.html in ${root}: serve plays the page a game starts from.`);
8542
9219
  let served = serving.get(root);
8543
9220
  if (!served) {
8544
9221
  served = await serveFolder({ dir: root, api: api2, version: ENGINE_VERSION, port: 0 });