frogoe 0.3.1 → 0.5.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.
package/dist/cli.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // frogoe CLI — BUNDLED CODE (esbuild). Reading this wastes tokens.
3
+ // Source: github.com/frogoe/engine/tree/main/packages/cli/src/
4
+ // How-to: frogoe --help, or skills/frogoe-creative/references/art.md
2
5
  var __defProp = Object.defineProperty;
3
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
7
  var __esm = (fn, res) => function __init() {
@@ -82,9 +85,28 @@ TODO \u2014 two lines: what the player does, and what ends the run.
82
85
  </html>
83
86
  `;
84
87
  gameTemplate = `/** My Game \u2014 the whole simulation in one closure. Replace everything
85
- * below with your game; the four nouns are the entire platform. */
88
+ * below with your game; the four nouns are the entire platform.
89
+ * Keep the palette exported: the identity scenes (assets/) draw with
90
+ * it, so key art stays 1:1 with the shipped game. */
86
91
  import { defineGame } from "frogoe";
87
92
 
93
+ export const C = { bg: "#101418", fg: "#fffdf7", accent: "#ffd166" };
94
+
95
+ /** SPRITES \u2014 the frogoe vision workbench registry: each object
96
+ * rendered in isolation and ASCII-mapped by 'frogoe vision'. TODO:
97
+ * add one entry per drawable as your sprites take shape. */
98
+ export const SPRITES = {
99
+ ball: {
100
+ w: 120, h: 120,
101
+ draw: (ctx) => {
102
+ ctx.fillStyle = C.fg;
103
+ ctx.beginPath();
104
+ ctx.arc(60, 60, 34, 0, 7);
105
+ ctx.fill();
106
+ },
107
+ },
108
+ };
109
+
88
110
  defineGame(({ stage, input, loop, finish }) => {
89
111
  let t = 0;
90
112
  let x = stage.play.center;
@@ -100,7 +122,7 @@ defineGame(({ stage, input, loop, finish }) => {
100
122
 
101
123
  loop.render = (ctx) => {
102
124
  ctx.clearRect(0, 0, stage.width, stage.height);
103
- ctx.fillStyle = "#fffdf7";
125
+ ctx.fillStyle = C.fg;
104
126
  ctx.beginPath();
105
127
  ctx.arc(x, stage.height / 2, 14 + Math.sin(t * 3) * 3, 0, 7);
106
128
  ctx.fill();
@@ -119,13 +141,18 @@ node_modules/
119
141
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
120
142
  import path from "path";
121
143
  import { fileURLToPath } from "url";
122
- var here, CONTRACT_SOURCE, scaffold, registryRoot;
144
+ var here, contractSourceFor, CONTRACT_SOURCE, scaffold, registryRootFor, registryRoot;
123
145
  var init_init = __esm({
124
146
  "src/init.ts"() {
125
147
  "use strict";
126
148
  init_templates();
127
149
  here = path.dirname(fileURLToPath(import.meta.url));
128
- CONTRACT_SOURCE = existsSync(path.join(here, "../../contract/src/contract.js")) ? path.join(here, "../../contract/src/contract.js") : path.join(here, "../contract/contract.js");
150
+ contractSourceFor = (base) => {
151
+ const repo = path.join(base, "../../contract/src/contract.js");
152
+ if (existsSync(repo)) return repo;
153
+ return path.join(base, "contract/contract.js");
154
+ };
155
+ CONTRACT_SOURCE = contractSourceFor(here);
129
156
  scaffold = (name, options) => {
130
157
  const root = options?.dir ?? process.cwd();
131
158
  const target = path.resolve(root, name);
@@ -161,12 +188,12 @@ var init_init = __esm({
161
188
  }
162
189
  return { dir: target, files: files.map(([rel]) => rel) };
163
190
  };
164
- registryRoot = () => {
191
+ registryRootFor = (base) => {
165
192
  const candidates = [
166
- path.resolve(here, "../../../registry"),
193
+ path.resolve(base, "../../../registry"),
167
194
  // repo source mode
168
- path.resolve(here, "../registry")
169
- // dist mode (build:copy)
195
+ path.resolve(base, "registry")
196
+ // dist mode: beside the bundle
170
197
  ];
171
198
  for (const candidate of candidates) {
172
199
  if (existsSync(path.join(candidate, "registry.json"))) {
@@ -174,9 +201,10 @@ var init_init = __esm({
174
201
  }
175
202
  }
176
203
  throw new Error(
177
- "frogoe: registry not found (expected ../registry or packaged copy). Run from the repo or install @frogoe/cli fully."
204
+ "frogoe: registry not found (expected the repo registry or the packaged copy beside the CLI). Run from the repo or reinstall frogoe."
178
205
  );
179
206
  };
207
+ registryRoot = () => registryRootFor(here);
180
208
  }
181
209
  });
182
210
 
@@ -192,10 +220,25 @@ var init_add = __esm({
192
220
  const styleOpen = source.indexOf("<style>");
193
221
  const styleClose = source.indexOf("</style>");
194
222
  const css = styleOpen === -1 || styleClose === -1 || styleClose < styleOpen ? null : source.slice(styleOpen + "<style>".length, styleClose);
195
- return {
196
- css: css?.trim() ?? null,
197
- markup: (styleClose === -1 ? "" : source.slice(styleClose + "</style>".length)).trim()
198
- };
223
+ const fromMarker = source.indexOf("COPY FROM HERE");
224
+ const toMarker = source.indexOf("COPY TO HERE");
225
+ let markup;
226
+ if (fromMarker !== -1 && toMarker !== -1 && toMarker > fromMarker) {
227
+ const afterFrom = source.indexOf("-->", fromMarker);
228
+ const beforeTo = source.lastIndexOf("<!--", toMarker);
229
+ markup = afterFrom !== -1 && beforeTo !== -1 && beforeTo > afterFrom ? source.slice(afterFrom + 3, beforeTo).trim() : "";
230
+ } else {
231
+ markup = (styleClose === -1 ? "" : source.slice(styleClose + "</style>".length)).trim();
232
+ for (const closer of ["</body>", "</html>"]) {
233
+ const idx = markup.lastIndexOf(closer);
234
+ if (idx !== -1) markup = markup.slice(0, idx).trim();
235
+ }
236
+ for (const opener of ["</head>", "<body>", "<html...>"]) {
237
+ const idx = markup.toLowerCase().indexOf(opener.toLowerCase());
238
+ if (idx === 0) markup = markup.slice(opener.length).trim();
239
+ }
240
+ }
241
+ return { css: css?.trim() ?? null, markup };
199
242
  };
200
243
  blockMarker = (name) => `<!-- frogoe:block:${name} -->`;
201
244
  injectIntoHtml = (html, name, css, markup, placement) => {
@@ -526,9 +569,9 @@ var init_bundle = __esm({
526
569
  });
527
570
  const seen = /* @__PURE__ */ new Map();
528
571
  const resolveFont = async (url) => {
529
- const cached = seen.get(url);
530
- if (cached) {
531
- return cached;
572
+ const cached2 = seen.get(url);
573
+ if (cached2) {
574
+ return cached2;
532
575
  }
533
576
  assertAllowedRemote(url, options.extraAllowedHosts);
534
577
  const res = options.fetchImpl ? await options.fetchImpl(url, { headers: { "user-agent": FONT_UA } }) : await fetch(url, { headers: { "user-agent": FONT_UA } });
@@ -664,63 +707,6 @@ ${css}
664
707
  }
665
708
  });
666
709
 
667
- // src/commands/bundle.ts
668
- var bundle_exports = {};
669
- __export(bundle_exports, {
670
- command: () => command2
671
- });
672
- import { defineCommand as defineCommand2 } from "citty";
673
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
674
- import path4 from "path";
675
- var command2;
676
- var init_bundle2 = __esm({
677
- "src/commands/bundle.ts"() {
678
- "use strict";
679
- init_bundle();
680
- command2 = defineCommand2({
681
- args: {
682
- dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
683
- json: { type: "boolean", description: "machine-readable report" },
684
- out: { type: "string", description: "output path (default: dist/index.html)" }
685
- },
686
- async run({ args }) {
687
- const dir = args.dir ? String(args.dir) : process.cwd();
688
- const report = await bundle({ dir });
689
- const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(dir, "dist", "index.html");
690
- mkdirSync3(path4.dirname(outPath), { recursive: true });
691
- writeFileSync3(outPath, report.artifact, "utf-8");
692
- for (const warning of report.warnings) {
693
- console.log(` \u26A0 ${warning}`);
694
- }
695
- if (args.json) {
696
- console.log(
697
- JSON.stringify(
698
- {
699
- artifact: outPath,
700
- assets: report.assets,
701
- bytes: report.bytes,
702
- sha256: report.sha256,
703
- warnings: report.warnings
704
- },
705
- null,
706
- 2
707
- )
708
- );
709
- } else {
710
- console.log(` frogoe bundle \u2192 ${outPath}`);
711
- console.log(
712
- ` ${report.bytes} bytes \xB7 ${report.assets.length} dissolved asset(s) \xB7 sha256 ${report.sha256.slice(0, 12)}`
713
- );
714
- for (const asset of report.assets) {
715
- console.log(` ${asset.kind.padEnd(5)} ${asset.source}`);
716
- }
717
- }
718
- },
719
- meta: { description: "dissolve externals into one self-contained HTML" }
720
- });
721
- }
722
- });
723
-
724
710
  // ../lint/src/brief.ts
725
711
  var KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
726
712
  var init_brief = __esm({
@@ -780,6 +766,177 @@ var init_brief = __esm({
780
766
  }
781
767
  });
782
768
 
769
+ // ../lint/src/art.ts
770
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
771
+ import path4 from "path";
772
+ var SPECS, MIN_DRAW_CALLS, DRAW_CALL, SPRITE_CALL, checkArt, checkScene, hexColorsOf, stripComments;
773
+ var init_art = __esm({
774
+ "../lint/src/art.ts"() {
775
+ "use strict";
776
+ init_brief();
777
+ SPECS = [
778
+ { draw: "drawPoster", file: "assets/poster.js" },
779
+ { draw: "drawIcon", file: "assets/icon.js" }
780
+ ];
781
+ MIN_DRAW_CALLS = 2;
782
+ DRAW_CALL = /(?:fillRect|strokeRect|clearRect|beginPath|closePath|arc|ellipse|moveTo|lineTo|quadraticCurveTo|bezierCurveTo|fillText|strokeText|drawImage|roundRect|fill|stroke)\s*\(/gu;
783
+ SPRITE_CALL = /\bdraw[A-Z]\w*\s*\(/gu;
784
+ checkArt = (dir, findings) => {
785
+ const briefPath = path4.join(dir, "BRIEF.md");
786
+ const brief = existsSync4(briefPath) ? parseBrief(readFileSync4(briefPath, "utf-8")) : void 0;
787
+ const briefFonts = (brief?.fonts ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token.length > 0);
788
+ const gamePath = path4.join(dir, "game.js");
789
+ const gameSource = existsSync4(gamePath) ? readFileSync4(gamePath, "utf-8") : "";
790
+ const gameColors = hexColorsOf(stripComments(gameSource));
791
+ const htmlSource = existsSync4(path4.join(dir, "index.html")) ? readFileSync4(path4.join(dir, "index.html"), "utf-8") : "";
792
+ for (const spec of SPECS) {
793
+ const scenePath = path4.join(dir, spec.file);
794
+ if (!existsSync4(scenePath)) {
795
+ findings.push({
796
+ code: "art/missing",
797
+ file: spec.file,
798
+ fix: "author the scene \u2014 a canvas composition importing the game's own sprites (frogoe-creative \u2192 references/art.md)",
799
+ message: `${spec.file} is missing \u2014 poster and icon are authored art, not generated`,
800
+ recipe: "frogoe-creative \u2192 references/art.md",
801
+ severity: "error"
802
+ });
803
+ continue;
804
+ }
805
+ const source = stripComments(readFileSync4(scenePath, "utf-8"));
806
+ checkScene(source, spec, gameColors, briefFonts, htmlSource, findings);
807
+ }
808
+ };
809
+ checkScene = (source, spec, gameColors, briefFonts, htmlSource, findings) => {
810
+ if (!new RegExp(`export\\s+(?:async\\s+)?(?:function|const)\\s+${spec.draw}\\b`, "u").test(source)) {
811
+ findings.push({
812
+ code: "art/scene-export",
813
+ file: spec.file,
814
+ fix: `export the entry the rasterizer calls \u2014 export function ${spec.draw}(ctx, w, h)`,
815
+ message: `${spec.file} does not export ${spec.draw}()`,
816
+ severity: "error"
817
+ });
818
+ }
819
+ if (!/from\s+["']\.\.\/game\.js["']/u.test(source)) {
820
+ findings.push({
821
+ code: "art/scene-source",
822
+ file: spec.file,
823
+ fix: 'import the sprites/palette from the game \u2014 `import { \u2026 } from "../game.js"` \u2014 identity art renders with the game\'s own code',
824
+ message: `${spec.file} does not import from ../game.js (the art must be 1:1 by construction)`,
825
+ severity: "error"
826
+ });
827
+ }
828
+ for (const hex of hexColorsOf(source)) {
829
+ if (!gameColors.has(hex)) {
830
+ findings.push({
831
+ code: "art/color-drift",
832
+ file: spec.file,
833
+ fix: `use the game's own color for "${hex}" \u2014 import the palette from ../game.js; a color the game never draws has no business in its key art`,
834
+ message: `${spec.file} uses "${hex}", which game.js never draws`,
835
+ severity: "error"
836
+ });
837
+ }
838
+ }
839
+ const fontStrings = [
840
+ ...source.matchAll(/\.font\s*=\s*'([^']+)'/gu),
841
+ ...source.matchAll(/\.font\s*=\s*"([^"]+)"/gu)
842
+ ].map((match) => match[1] ?? "").filter((value) => value.length > 0);
843
+ for (const font of fontStrings) {
844
+ const lower = font.toLowerCase();
845
+ if (!briefFonts.some((family2) => lower.includes(family2))) {
846
+ findings.push({
847
+ code: "art/text-font",
848
+ file: spec.file,
849
+ fix: briefFonts.length > 0 ? `lettering must use the BRIEF font (${briefFonts.join(", ")}) \u2014 ctx.font = \`700 96px ${briefFonts[0]}\` \u2014 or drop the text` : "this BRIEF declares no fonts \u2014 the poster carries no canvas lettering",
850
+ message: `canvas font "${font.slice(0, 40)}" is not the game's typography`,
851
+ severity: "error"
852
+ });
853
+ continue;
854
+ }
855
+ const family = briefFonts.find((name) => lower.includes(name)) ?? "";
856
+ const haystack = htmlSource.toLowerCase().replaceAll("+", " ").replaceAll("%20", " ");
857
+ if (!haystack.includes(family)) {
858
+ findings.push({
859
+ code: "art/text-font",
860
+ file: spec.file,
861
+ fix: `index.html never loads "${family}" \u2014 add the Google Fonts <link> (the rasterizer dissolves the same stylesheet)`,
862
+ message: `font "${family}" is used in the art but the game never loads it`,
863
+ severity: "error"
864
+ });
865
+ }
866
+ }
867
+ const entryCalls = /* @__PURE__ */ new Set([`drawPoster(`, `drawIcon(`]);
868
+ const spriteCalls = [...source.matchAll(SPRITE_CALL)].map((match) => match[0] ?? "").filter((call) => !entryCalls.has(call)).length;
869
+ const drawCalls = [...source.matchAll(DRAW_CALL)].length + spriteCalls;
870
+ if (drawCalls < MIN_DRAW_CALLS) {
871
+ findings.push({
872
+ code: "art/empty",
873
+ file: spec.file,
874
+ fix: `compose the scene with real draw calls (found ${drawCalls}) \u2014 an empty or placeholder scene is not key art`,
875
+ message: `${spec.file} barely draws anything`,
876
+ severity: "error"
877
+ });
878
+ }
879
+ if (spec.draw === "drawPoster" && !source.includes("__frogoeTitleBand")) {
880
+ findings.push({
881
+ code: "art/title-band",
882
+ file: spec.file,
883
+ fix: "declare the lettering block from the layout variables \u2014 ctx.__frogoeTitleBand = [x0, y0, x1, y1] (never hand-typed: the render is the only truth)",
884
+ message: "the poster declares no title band \u2014 bundle's safe-zone collision check stays off",
885
+ recipe: "frogoe-creative \u2192 references/art.md",
886
+ severity: "warning"
887
+ });
888
+ }
889
+ if (spec.draw === "drawPoster" && !/fillText|strokeText/u.test(source)) {
890
+ findings.push({
891
+ code: "art/title-presence",
892
+ file: spec.file,
893
+ fix: "draw the logotype \u2014 ctx.font with the BRIEF font, strokeText outline first (frogoe-creative \u2192 references/art.md); shape-drawn lettering is the legal alternative",
894
+ message: "the poster has no canvas lettering \u2014 Steam's capsule rules require a readable logotype",
895
+ recipe: "frogoe-creative \u2192 references/art.md",
896
+ severity: "warning"
897
+ });
898
+ }
899
+ };
900
+ hexColorsOf = (source) => {
901
+ const colors = /* @__PURE__ */ new Set();
902
+ for (const match of source.matchAll(/#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b/gu)) {
903
+ const hex = (match[0] ?? "").toLowerCase();
904
+ colors.add(hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex);
905
+ }
906
+ return colors;
907
+ };
908
+ stripComments = (source) => {
909
+ let out = "";
910
+ let i = 0;
911
+ while (i < source.length) {
912
+ const line = source.indexOf("//", i);
913
+ const block = source.indexOf("/*", i);
914
+ if (line === -1 && block === -1) {
915
+ out += source.slice(i);
916
+ break;
917
+ }
918
+ const at = block !== -1 && (line === -1 || block < line) ? block : line !== -1 ? line : -1;
919
+ if (at === block) {
920
+ out += source.slice(i, at);
921
+ const end = source.indexOf("*/", at + 2);
922
+ i = end === -1 ? source.length : end + 2;
923
+ } else {
924
+ const isProtocol = at > 0 && source[at - 1] === ":";
925
+ if (isProtocol) {
926
+ out += source.slice(i, at + 2);
927
+ i = at + 2;
928
+ } else {
929
+ out += source.slice(i, at);
930
+ const end = source.indexOf("\n", at);
931
+ i = end === -1 ? source.length : end;
932
+ }
933
+ }
934
+ }
935
+ return out;
936
+ };
937
+ }
938
+ });
939
+
783
940
  // ../lint/src/contrast.ts
784
941
  var HEX, isHex, contrastRatio;
785
942
  var init_contrast = __esm({
@@ -806,7 +963,7 @@ var init_contrast = __esm({
806
963
  });
807
964
 
808
965
  // ../lint/src/check.ts
809
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "fs";
966
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5 } from "fs";
810
967
  import path5 from "path";
811
968
  var VERBS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
812
969
  var init_check = __esm({
@@ -814,10 +971,11 @@ var init_check = __esm({
814
971
  "use strict";
815
972
  init_brief();
816
973
  init_contrast();
974
+ init_art();
817
975
  VERBS = /* @__PURE__ */ new Set(["tap", "hold", "steer", "aim"]);
818
976
  read = (file) => {
819
977
  try {
820
- return readFileSync4(file, "utf-8");
978
+ return readFileSync5(file, "utf-8");
821
979
  } catch {
822
980
  return "";
823
981
  }
@@ -829,7 +987,7 @@ var init_check = __esm({
829
987
  };
830
988
  checkBrief = (dir, findings) => {
831
989
  const file = path5.join(dir, "BRIEF.md");
832
- if (!existsSync4(file)) {
990
+ if (!existsSync5(file)) {
833
991
  findings.push({
834
992
  code: "brief/missing",
835
993
  file: "BRIEF.md",
@@ -1062,7 +1220,7 @@ var init_check = __esm({
1062
1220
  }
1063
1221
  const blocksDir = path5.join(dir, "blocks");
1064
1222
  let markup = index;
1065
- if (existsSync4(blocksDir)) {
1223
+ if (existsSync5(blocksDir)) {
1066
1224
  for (const f of readdirSync(blocksDir)) {
1067
1225
  if (f.endsWith(".html")) {
1068
1226
  markup += read(path5.join(blocksDir, f));
@@ -1086,7 +1244,7 @@ var init_check = __esm({
1086
1244
  };
1087
1245
  checkPin = (dir, findings) => {
1088
1246
  const pinFile = path5.join(dir, "frogoe.json");
1089
- if (!existsSync4(pinFile)) {
1247
+ if (!existsSync5(pinFile)) {
1090
1248
  findings.push({
1091
1249
  code: "folder/contract-pin",
1092
1250
  file: "frogoe.json",
@@ -1123,6 +1281,7 @@ var init_check = __esm({
1123
1281
  };
1124
1282
  checkProject = (dir) => {
1125
1283
  const findings = [];
1284
+ checkArt(dir, findings);
1126
1285
  checkBrief(dir, findings);
1127
1286
  checkFolder(dir, findings);
1128
1287
  checkPin(dir, findings);
@@ -1140,1305 +1299,1831 @@ var init_check = __esm({
1140
1299
  var init_src = __esm({
1141
1300
  "../lint/src/index.ts"() {
1142
1301
  "use strict";
1302
+ init_art();
1143
1303
  init_brief();
1144
1304
  init_contrast();
1145
1305
  init_check();
1146
1306
  }
1147
1307
  });
1148
1308
 
1149
- // src/check.ts
1150
- var formatFindings;
1151
- var init_check2 = __esm({
1152
- "src/check.ts"() {
1309
+ // src/runtime-source.ts
1310
+ import { readFileSync as readFileSync6 } from "fs";
1311
+ import path6 from "path";
1312
+ import { fileURLToPath as fileURLToPath2 } from "url";
1313
+ var here2, cached, runtimeSource, functions, runtimeFunctions;
1314
+ var init_runtime_source = __esm({
1315
+ "src/runtime-source.ts"() {
1153
1316
  "use strict";
1154
- init_src();
1155
- init_src();
1156
- init_src();
1157
- formatFindings = (result) => result.findings.map((f) => {
1158
- const at = f.line !== void 0 ? `:${f.line}` : "";
1159
- const head = `${f.severity === "error" ? "\u2716" : "\u26A0"} ${f.code} ${f.file}${at}`;
1160
- return `${head}
1161
- ${f.message}
1162
- fix: ${f.fix}`;
1163
- }).join("\n") || "clean \u2014 0 findings";
1317
+ here2 = path6.dirname(fileURLToPath2(import.meta.url));
1318
+ cached = null;
1319
+ runtimeSource = () => {
1320
+ if (cached !== null) return cached;
1321
+ const candidates = [
1322
+ path6.join(here2, "injected-runtime.js"),
1323
+ // source mode (src/ dir)
1324
+ path6.join(here2, "dist", "injected-runtime.js"),
1325
+ // dist mode
1326
+ path6.join(here2, "..", "dist", "injected-runtime.js")
1327
+ // dist/cli.js sibling
1328
+ ];
1329
+ for (const p of candidates) {
1330
+ try {
1331
+ cached = readFileSync6(p, "utf-8");
1332
+ return cached;
1333
+ } catch {
1334
+ }
1335
+ }
1336
+ throw new Error("injected-runtime.js not found (looked in src/, dist/) \u2014 run `bun run build`");
1337
+ };
1338
+ functions = null;
1339
+ runtimeFunctions = () => {
1340
+ if (functions !== null) return functions;
1341
+ const src = runtimeSource();
1342
+ const factory = new Function(
1343
+ `${src}
1344
+ return { luminance, contrastRatio, compositionMetrics, analyzeTitleBand, analyzeIconCorners, findTitleZoneCollision };`
1345
+ );
1346
+ functions = factory();
1347
+ return functions;
1348
+ };
1164
1349
  }
1165
1350
  });
1166
1351
 
1167
- // src/live/sampler.ts
1168
- var PROBE_SCRIPT, DOM_PROBE_SCRIPT, CANVAS_PAINTED_SCRIPT, CANVAS_HASH_SCRIPT, GAME_STATE_SCRIPT, FINISH_EVENTS_SCRIPT, FPS_MARK_SCRIPT, FPS_SINCE_SCRIPT, fpsSinceScript, HUD_MEASURE_SCRIPT, RETRY_PRESENCE_SCRIPT, INTERRUPT_AUDIO_SCRIPT, AUDIO_STATES_SCRIPT;
1169
- var init_sampler = __esm({
1170
- "src/live/sampler.ts"() {
1352
+ // src/art-verify.ts
1353
+ var luminance, contrastRatio2, analyzeTitleBand, analyzeIconCorners, MIN_INK_SHARE, EDGE_MARGIN, verifyTitleReadability, findTitleZoneCollision, CORNER_DISTANCE, verifyIconFullbleed;
1354
+ var init_art_verify = __esm({
1355
+ "src/art-verify.ts"() {
1171
1356
  "use strict";
1172
- PROBE_SCRIPT = `(() => {
1173
- if (window.__frogoeProbe) return;
1174
- const probe = { audioContexts: [], audioEverRan: false, finish: [], fps: [] };
1175
- Object.defineProperty(window, "__frogoeProbe", { value: probe });
1176
- document.addEventListener("frogoe:finish", (e) => {
1177
- const d = e && e.detail;
1178
- const score = d && typeof d.score === "number" ? d.score : null;
1179
- probe.finish.push({ at: Math.round(performance.now()), score });
1180
- });
1181
- const wrap = (Native) => class extends Native {
1182
- constructor(...args) {
1183
- super(...args);
1184
- probe.audioContexts.push(this);
1185
- this.addEventListener("statechange", () => {
1186
- if (this.state === "running") probe.audioEverRan = true;
1187
- });
1188
- }
1189
- };
1190
- const AC = window.AudioContext;
1191
- if (AC && !AC.__frogoeHooked) {
1192
- Object.defineProperty(wrap(AC), "__frogoeHooked", { value: true });
1193
- window.AudioContext = wrap(AC);
1194
- }
1195
- if (window.webkitAudioContext) {
1196
- window.webkitAudioContext = wrap(window.webkitAudioContext);
1197
- }
1198
- let count = 0;
1199
- let secStart = performance.now();
1200
- const tick = () => {
1201
- count++;
1202
- const now = performance.now();
1203
- if (now - secStart >= 1000) {
1204
- probe.fps.push(count);
1205
- count = 0;
1206
- secStart = now;
1207
- }
1208
- requestAnimationFrame(tick);
1209
- };
1210
- requestAnimationFrame(tick);
1211
- })()`;
1212
- DOM_PROBE_SCRIPT = `(() => {
1213
- return {
1214
- canvasPresent: Boolean(document.querySelector("#c")),
1215
- hudPresent: Boolean(document.querySelector(".hud")),
1216
- state: window.__frogoe?.state ?? "(missing)",
1217
- };
1218
- })()`;
1219
- CANVAS_PAINTED_SCRIPT = `(() => {
1220
- const c = document.querySelector("#c");
1221
- if (!c) return false;
1222
- const g = c.getContext("2d");
1223
- if (!g) return false;
1224
- const px = Math.floor(c.width * 0.25), py = Math.floor(c.height * 0.25);
1225
- const pw = Math.floor(c.width * 0.5), ph = Math.floor(c.height * 0.5);
1226
- const s = g.getImageData(px, py, pw, ph).data;
1227
- for (let i = 3; i < s.length; i += 4) { if (s[i] !== 0) return true; }
1228
- return false;
1229
- })()`;
1230
- CANVAS_HASH_SCRIPT = `(() => {
1231
- const c = document.querySelector("#c");
1232
- if (!c) return null;
1233
- const g = c.getContext("2d");
1234
- if (!g) return null;
1235
- const cx = Math.floor(c.width * 0.25), cy = Math.floor(c.height * 0.25);
1236
- const cw = Math.floor(c.width * 0.5), ch = Math.floor(c.height * 0.5);
1237
- const d = g.getImageData(cx, cy, cw, ch).data;
1238
- let hash = 0;
1239
- for (let i = 0; i < d.length; i += 4) {
1240
- hash = (hash * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
1241
- }
1242
- return hash;
1243
- })()`;
1244
- GAME_STATE_SCRIPT = `window.__frogoe?.state ?? "(missing)"`;
1245
- FINISH_EVENTS_SCRIPT = `(() => {
1246
- const p = window.__frogoeProbe;
1247
- return p ? p.finish.slice() : [];
1248
- })()`;
1249
- FPS_MARK_SCRIPT = `window.__frogoeProbe ? window.__frogoeProbe.fps.length : 0`;
1250
- FPS_SINCE_SCRIPT = `(() => {
1251
- const p = window.__frogoeProbe;
1252
- const mark = __MARK__;
1253
- return p ? p.fps.slice(mark) : [];
1254
- })()`;
1255
- fpsSinceScript = (mark) => FPS_SINCE_SCRIPT.replaceAll("__MARK__", String(mark));
1256
- HUD_MEASURE_SCRIPT = `(() => {
1257
- const out = [];
1258
- for (const el of document.querySelectorAll(".hud *")) {
1259
- const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
1260
- if (!own) continue;
1261
- const text = el.textContent ?? "";
1262
- const s = getComputedStyle(el);
1263
- const hasOutline =
1264
- (s.webkitTextStroke && s.webkitTextStrokeWidth !== "0px") ||
1265
- (s.textShadow && s.textShadow !== "none");
1266
- const r = el.getBoundingClientRect();
1267
- out.push({ hasOutline, height: r.height, label: text.trim().slice(0, 24), width: r.width });
1268
- }
1269
- return out;
1270
- })()`;
1271
- RETRY_PRESENCE_SCRIPT = `(() => ({
1272
- gameover: Boolean(document.querySelector("[data-block-gameover]")),
1273
- retry: Boolean(document.querySelector("[data-block-retry]")),
1274
- }))()`;
1275
- INTERRUPT_AUDIO_SCRIPT = `(() => {
1276
- const p = window.__frogoeProbe;
1277
- if (!p) return false;
1278
- for (const c of p.audioContexts) {
1279
- try { void c.suspend(); } catch (e) {}
1280
- }
1281
- return true;
1282
- })()`;
1283
- AUDIO_STATES_SCRIPT = `(() => {
1284
- const p = window.__frogoeProbe;
1285
- if (!p) return { count: 0, everRan: false, running: 0 };
1286
- let running = 0;
1287
- for (const c of p.audioContexts) {
1288
- if (c.state === "running") running++;
1289
- }
1290
- return { count: p.audioContexts.length, everRan: p.audioEverRan === true, running };
1291
- })()`;
1357
+ luminance = (r, g, b) => {
1358
+ const channel = (c) => {
1359
+ const s = c / 255;
1360
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
1361
+ };
1362
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
1363
+ };
1364
+ contrastRatio2 = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
1365
+ analyzeTitleBand = (data, width, height) => {
1366
+ const fieldHeight = Math.floor(height * 0.65);
1367
+ const fieldBuckets = /* @__PURE__ */ new Map();
1368
+ for (let y = 0; y < fieldHeight; y += 2) {
1369
+ for (let x = 0; x < width; x += 2) {
1370
+ const at = (y * width + x) * 4;
1371
+ const key = (data[at] ?? 0) >> 4 << 8 | (data[at + 1] ?? 0) >> 4 << 4 | (data[at + 2] ?? 0) >> 4;
1372
+ fieldBuckets.set(key, (fieldBuckets.get(key) ?? 0) + 1);
1373
+ }
1374
+ }
1375
+ let groundKey = 0;
1376
+ let groundHits = -1;
1377
+ for (const [key, hits] of fieldBuckets) {
1378
+ if (hits > groundHits) {
1379
+ groundHits = hits;
1380
+ groundKey = key;
1381
+ }
1382
+ }
1383
+ const ground = [
1384
+ (groundKey >> 8 & 15) * 17,
1385
+ (groundKey >> 4 & 15) * 17,
1386
+ (groundKey & 15) * 17
1387
+ ];
1388
+ const groundLum = luminance(ground[0], ground[1], ground[2]);
1389
+ const isInk = (x, y) => {
1390
+ const at = (y * width + x) * 4;
1391
+ return contrastRatio2(luminance(data[at] ?? 0, data[at + 1] ?? 0, data[at + 2] ?? 0), groundLum) >= 3;
1392
+ };
1393
+ const rowInkMin = Math.ceil(width / 2 * 0.15);
1394
+ const wideRowMin = Math.ceil(width / 2 * 0.4);
1395
+ let inkCount = 0;
1396
+ let inkTotal = 0;
1397
+ let textBottom = -1;
1398
+ let bbox = null;
1399
+ for (let y = 0; y < fieldHeight; y += 2) {
1400
+ let rowInk = 0;
1401
+ for (let x = 0; x < width; x += 2) {
1402
+ if (isInk(x, y)) {
1403
+ rowInk++;
1404
+ }
1405
+ }
1406
+ inkTotal += Math.ceil(width / 2);
1407
+ if (rowInk >= wideRowMin) {
1408
+ textBottom = y;
1409
+ }
1410
+ if (rowInk >= rowInkMin) {
1411
+ inkCount += Math.ceil(width / 2);
1412
+ if (bbox === null) {
1413
+ bbox = [width, y, 0, y];
1414
+ }
1415
+ bbox = [bbox[0], bbox[1], bbox[2], y];
1416
+ }
1417
+ }
1418
+ if (bbox !== null) {
1419
+ const bins = /* @__PURE__ */ new Map();
1420
+ for (let y = bbox[1]; y <= bbox[3]; y += 2) {
1421
+ for (let x = 0; x < width; x += 2) {
1422
+ if (isInk(x, y)) {
1423
+ const bin = x >> 2;
1424
+ bins.set(bin, (bins.get(bin) ?? 0) + 1);
1425
+ }
1426
+ }
1427
+ }
1428
+ const sorted = [...bins.keys()].sort((a, b) => a - b);
1429
+ const GAP_BINS = 6;
1430
+ let best = null;
1431
+ let runFrom = -1;
1432
+ let runTo = -1;
1433
+ let runInk = 0;
1434
+ for (let i = 0; i <= sorted.length; i++) {
1435
+ const bin = sorted[i] ?? Number.MAX_SAFE_INTEGER;
1436
+ const prev = sorted[i - 1] ?? bin;
1437
+ const starts = i === 0 || bin - prev > GAP_BINS;
1438
+ if (starts && runFrom !== -1) {
1439
+ if (best === null || runInk > best[2]) {
1440
+ best = [runFrom, runTo, runInk];
1441
+ }
1442
+ runFrom = -1;
1443
+ runInk = 0;
1444
+ }
1445
+ if (i === sorted.length) break;
1446
+ if (runFrom === -1) {
1447
+ runFrom = bin;
1448
+ }
1449
+ runTo = bin;
1450
+ runInk += bins.get(bin) ?? 0;
1451
+ }
1452
+ if (best !== null) {
1453
+ bbox = [best[0] * 4, bbox[1], best[1] * 4 + 2, bbox[3]];
1454
+ } else {
1455
+ bbox = null;
1456
+ }
1457
+ }
1458
+ return { bandHeight: fieldHeight, ground, inkBBox: bbox, inkCount, inkTotal, textBottom };
1459
+ };
1460
+ analyzeIconCorners = (data, width, _height) => {
1461
+ const read2 = (x, y) => {
1462
+ let r = 0;
1463
+ let g = 0;
1464
+ let b = 0;
1465
+ let a = 0;
1466
+ for (let dy = 0; dy < 4; dy++) {
1467
+ for (let dx = 0; dx < 4; dx++) {
1468
+ const at = ((y + dy) * width + (x + dx)) * 4;
1469
+ r += data[at] ?? 0;
1470
+ g += data[at + 1] ?? 0;
1471
+ b += data[at + 2] ?? 0;
1472
+ a += data[at + 3] ?? 0;
1473
+ }
1474
+ }
1475
+ return [Math.round(r / 16), Math.round(g / 16), Math.round(b / 16), Math.round(a / 16)];
1476
+ };
1477
+ const last = width - 4;
1478
+ return {
1479
+ corners: [read2(0, 0), read2(last, 0), read2(0, last), read2(last, last)]
1480
+ };
1481
+ };
1482
+ MIN_INK_SHARE = 8e-3;
1483
+ EDGE_MARGIN = 8;
1484
+ verifyTitleReadability = (report, width) => {
1485
+ const share = report.inkTotal > 0 ? report.inkCount / report.inkTotal : 0;
1486
+ if (share < MIN_INK_SHARE) {
1487
+ return `bundle/art-title-readability \u2014 the poster's upper field carries almost no readable ink (${(share * 100).toFixed(2)}% \u22653:1 pixels): draw the logotype there (BRIEF font, strokeText outline), or add the stepped scrim if the world is busy \u2014 frogoe-creative \u2192 references/art.md`;
1488
+ }
1489
+ const box = report.inkBBox;
1490
+ if (box === null) {
1491
+ return "bundle/art-title-readability \u2014 no ink bbox";
1492
+ }
1493
+ const [x0, , x1] = box;
1494
+ if (x0 < EDGE_MARGIN || x1 > width - 1 - EDGE_MARGIN) {
1495
+ return `bundle/art-title-readability \u2014 the logotype runs to the canvas edge (bbox ${x0}..${x1} of ${width}): measure-fit the line to \u226486% of the stage width \u2014 frogoe-creative \u2192 references/art.md`;
1496
+ }
1497
+ return null;
1498
+ };
1499
+ findTitleZoneCollision = (data, width, height, band) => {
1500
+ if (band === null) return null;
1501
+ const x0 = Math.max(0, Math.round(band[0]));
1502
+ const y0 = Math.max(0, Math.round(band[1]));
1503
+ const x1 = Math.min(width - 1, Math.round(band[2]));
1504
+ const y1 = Math.min(height - 1, Math.round(band[3]));
1505
+ const lumOf = (x, y) => {
1506
+ const at = (y * width + x) * 4;
1507
+ return luminance(data[at] ?? 0, data[at + 1] ?? 0, data[at + 2] ?? 0);
1508
+ };
1509
+ const buckets = /* @__PURE__ */ new Map();
1510
+ for (let y = y0; y <= y1; y += 2) {
1511
+ for (let x = 0; x < width; x += 2) {
1512
+ const at = (y * width + x) * 4;
1513
+ const key = (data[at] ?? 0) >> 4 << 8 | (data[at + 1] ?? 0) >> 4 << 4 | (data[at + 2] ?? 0) >> 4;
1514
+ buckets.set(key, (buckets.get(key) ?? 0) + 1);
1515
+ }
1516
+ }
1517
+ let gk = 0;
1518
+ let gh = -1;
1519
+ for (const [k, h] of buckets) {
1520
+ if (h > gh) {
1521
+ gh = h;
1522
+ gk = k;
1523
+ }
1524
+ }
1525
+ const groundLum = luminance((gk >> 8 & 15) * 17, (gk >> 4 & 15) * 17, (gk & 15) * 17);
1526
+ const isLnk = (x, y) => contrastRatio2(lumOf(x, y), groundLum) >= 3;
1527
+ const ceiling = [];
1528
+ for (let x = 0; x < width; x += 4) {
1529
+ for (let y = 0; y < 6; y += 2) {
1530
+ if (isLnk(x, y)) {
1531
+ ceiling.push(x);
1532
+ break;
1533
+ }
1534
+ }
1535
+ }
1536
+ const nearCeiling = (x) => {
1537
+ for (const c of ceiling) {
1538
+ if (Math.abs(x - c) <= 8) return true;
1539
+ }
1540
+ return false;
1541
+ };
1542
+ const unit = width / 540;
1543
+ const margin = Math.round(24 * unit);
1544
+ const blobMin = Math.round(12 * unit);
1545
+ const countMin = Math.round(30 * (unit / 2));
1546
+ let floaters = 0;
1547
+ let widest = 0;
1548
+ const windowBottom = Math.min(height - 1, y1 + margin);
1549
+ for (let y = y1 + 2; y <= windowBottom; y += 2) {
1550
+ let run2 = 0;
1551
+ for (let x = x0; x <= x1 + 2; x += 2) {
1552
+ if (x <= x1 && isLnk(x, y) && !nearCeiling(x)) {
1553
+ run2 += 2;
1554
+ floaters++;
1555
+ } else {
1556
+ if (run2 > widest) widest = run2;
1557
+ run2 = 0;
1558
+ }
1559
+ }
1560
+ }
1561
+ if (floaters >= countMin && widest >= blobMin) {
1562
+ return `bundle/art-title-collision \u2014 a free-floating blob (${String(widest)}px wide) parks in the clearance margin under the lettering block (rows ${String(y1 + 2)}..${String(windowBottom)}): free world must clear the block by ~its own radius \u2014 relocate below or compose around it (ceiling-hung world is the one legal crosser) \u2014 frogoe-creative \u2192 references/art.md`;
1563
+ }
1564
+ return null;
1565
+ };
1566
+ CORNER_DISTANCE = 60;
1567
+ verifyIconFullbleed = (report, gameColors) => {
1568
+ const palette = gameColors.map((hex) => [
1569
+ Number.parseInt(hex.slice(1, 3) ?? "0", 16),
1570
+ Number.parseInt(hex.slice(3, 5) ?? "0", 16),
1571
+ Number.parseInt(hex.slice(5, 7) ?? "0", 16)
1572
+ ]);
1573
+ for (const [r, g, b, a] of report.corners) {
1574
+ if (a < 255) {
1575
+ return "bundle/art-icon-fullbleed \u2014 the icon has non-opaque corners: fill the whole square with the plate color (stores mask corners themselves; pre-rounding ships the alpha Apple rejects)";
1576
+ }
1577
+ const near = palette.some(
1578
+ ([pr, pg, pb]) => Math.abs(r - (pr ?? 0)) + Math.abs(g - (pg ?? 0)) + Math.abs(b - (pb ?? 0)) <= CORNER_DISTANCE
1579
+ );
1580
+ if (!near) {
1581
+ return `bundle/art-icon-fullbleed \u2014 corner color rgb(${r},${g},${b}) is not a game palette color: draw the plate as a full-bleed rect of a game color before the mark`;
1582
+ }
1583
+ }
1584
+ return null;
1585
+ };
1292
1586
  }
1293
1587
  });
1294
1588
 
1295
- // src/live/driver.ts
1296
- var createPuppeteerDriver;
1297
- var init_driver = __esm({
1298
- "src/live/driver.ts"() {
1589
+ // src/browser.ts
1590
+ import path7 from "path";
1591
+ var browserPath, ensureBrowser;
1592
+ var init_browser = __esm({
1593
+ "src/browser.ts"() {
1299
1594
  "use strict";
1300
- init_sampler();
1301
- createPuppeteerDriver = ({ page, size }) => {
1302
- const pageErrors = [];
1303
- const consoleErrors = [];
1304
- page.on("pageerror", (error) => {
1305
- pageErrors.push(String(error));
1306
- });
1307
- page.on("console", (message2) => {
1308
- if (message2.type() === "error") {
1309
- consoleErrors.push(message2.text());
1310
- }
1311
- });
1312
- page.evaluateOnNewDocument(PROBE_SCRIPT);
1313
- const read2 = async (script) => await page.evaluate(script);
1314
- return {
1315
- errors: () => pageErrors,
1316
- consoleErrors: () => consoleErrors,
1317
- async audioStates() {
1318
- return await read2(AUDIO_STATES_SCRIPT);
1319
- },
1320
- async interruptAudio() {
1321
- await read2(INTERRUPT_AUDIO_SCRIPT);
1322
- },
1323
- async domProbe() {
1324
- return await read2(DOM_PROBE_SCRIPT);
1325
- },
1326
- async canvasPainted() {
1327
- return await read2(CANVAS_PAINTED_SCRIPT);
1328
- },
1329
- async canvasHash() {
1330
- return await read2(CANVAS_HASH_SCRIPT);
1331
- },
1332
- async gameState() {
1333
- return await read2(GAME_STATE_SCRIPT);
1334
- },
1335
- async finishEvents() {
1336
- return await read2(FINISH_EVENTS_SCRIPT);
1337
- },
1338
- async fpsMark() {
1339
- return await read2(FPS_MARK_SCRIPT);
1340
- },
1341
- async fpsSince(mark) {
1342
- return await read2(fpsSinceScript(mark));
1343
- },
1344
- async hudMeasures() {
1345
- return await read2(HUD_MEASURE_SCRIPT);
1346
- },
1347
- async retryPresence() {
1348
- return await read2(RETRY_PRESENCE_SCRIPT);
1349
- },
1350
- async tap(x, y) {
1351
- await page.mouse.click(x, y);
1352
- },
1353
- async hold(x, y, ms) {
1354
- await page.mouse.move(x, y);
1355
- await page.mouse.down();
1356
- await new Promise((resolve2) => {
1357
- setTimeout(resolve2, ms);
1358
- });
1359
- await page.mouse.up();
1360
- },
1361
- async drag(x1, y1, x2, y2) {
1362
- await page.mouse.move(x1, y1);
1363
- await page.mouse.down();
1364
- await page.mouse.move(x2, y2, { steps: 6 });
1365
- await new Promise((resolve2) => {
1366
- setTimeout(resolve2, 80);
1367
- });
1368
- await page.mouse.up();
1369
- },
1370
- async clickRetryAwaitReload(timeoutMs) {
1371
- const interactable = `(() => {
1372
- const b = document.querySelector("[data-block-retry]");
1373
- if (!b) return false;
1374
- const s = getComputedStyle(b);
1375
- if (s.pointerEvents === "none" || s.visibility === "hidden" || s.display === "none") {
1376
- return false;
1377
- }
1378
- const r = b.getBoundingClientRect();
1379
- if (r.width < 4 || r.height < 4) return false;
1380
- const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
1381
- return Boolean(hit && (hit === b || b.contains(hit)));
1382
- })()`;
1383
- const grace = 3e3;
1384
- const started = Date.now();
1385
- for (; ; ) {
1386
- if (await page.evaluate(interactable) === true) {
1387
- break;
1388
- }
1389
- if (Date.now() - started > grace) {
1390
- return false;
1391
- }
1392
- await new Promise((resolve2) => {
1393
- setTimeout(resolve2, 100);
1394
- });
1395
- }
1396
- const navigated = page.waitForNavigation({ timeout: timeoutMs, waitUntil: "domcontentloaded" }).then(() => true).catch(() => false);
1397
- const button = await page.$("[data-block-retry]");
1398
- if (!button) {
1399
- return false;
1400
- }
1401
- await button.click();
1402
- return await navigated;
1403
- },
1404
- async setCpuThrottling(rate) {
1405
- await page.emulateCPUThrottling(rate === 1 ? null : rate);
1406
- },
1407
- async screenshot() {
1408
- return await page.screenshot({ encoding: "binary" });
1409
- },
1410
- viewport: () => size
1411
- };
1595
+ ensureBrowser = async () => {
1596
+ if (browserPath) {
1597
+ return browserPath;
1598
+ }
1599
+ const { Browser, getInstalledBrowsers, install } = await import("@puppeteer/browsers");
1600
+ const cacheDir = path7.resolve(process.cwd(), "node_modules/.frogoe-browser");
1601
+ const installed = await getInstalledBrowsers({ cacheDir });
1602
+ const existing = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
1603
+ browserPath = existing?.executablePath ?? (await install({
1604
+ browser: Browser.CHROMEHEADLESSSHELL,
1605
+ buildId: "131.0.6778.204",
1606
+ cacheDir,
1607
+ unpack: true
1608
+ })).executablePath;
1609
+ return browserPath;
1412
1610
  };
1413
1611
  }
1414
1612
  });
1415
1613
 
1416
- // src/live/types.ts
1417
- var finding;
1418
- var init_types = __esm({
1419
- "src/live/types.ts"() {
1420
- "use strict";
1421
- finding = (shape) => shape;
1422
- }
1423
- });
1424
-
1425
- // src/live/decisions.ts
1426
- var FPS_FLOOR, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
1427
- var init_decisions = __esm({
1428
- "src/live/decisions.ts"() {
1614
+ // src/net/ip.ts
1615
+ import { execSync } from "child_process";
1616
+ import os from "os";
1617
+ var VIRTUAL, isPrivateV4, isLoopback, selfAddresses, pickLanIp, interfaceFromRouteOutput, ROUTE_PROBES, routedInterfaceName, resolveLan;
1618
+ var init_ip = __esm({
1619
+ "src/net/ip.ts"() {
1429
1620
  "use strict";
1430
- init_types();
1431
- FPS_FLOOR = 30;
1432
- FPS_SUSTAINED_WINDOW = 3;
1433
- FROZEN_STREAK = 3;
1434
- outlineFinding = (measures) => {
1435
- const bare = measures.filter((m) => !m.hasOutline);
1436
- if (bare.length === 0 || !bare[0]) {
1437
- return null;
1621
+ VIRTUAL = /^(?:utun|awdl|llw|bridge|vboxnet|veth|docker|zd|zt|tailscale|tap|tun|anpi|ipsec|gif|stf|vethernet|virtualbox|vmware|vmnet|wsl|loopback|bluetooth)/u;
1622
+ isPrivateV4 = (ip) => {
1623
+ const octets = ip.split(".").map(Number);
1624
+ if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
1625
+ return false;
1438
1626
  }
1439
- return finding({
1440
- code: "live/hud-outline",
1441
- file: "index.html",
1442
- fix: `"${bare[0].label}" has no text-shadow or -webkit-text-stroke \u2014 game HUD text needs an outline to read on ANY background (registry blocks ship one; custom HUD must add it)`,
1443
- message: `${bare.length} HUD text element(s) have no outline`,
1444
- phase: "boot",
1445
- recipe: "frogoe-registry \u2192 block authoring (sticker depth pattern)",
1446
- severity: "error"
1447
- });
1448
- };
1449
- collapseFinding = (measures) => {
1450
- const collapsed = measures.filter((m) => m.width < 4 || m.height < 4);
1451
- if (collapsed.length === 0 || !collapsed[0]) {
1452
- return null;
1453
- }
1454
- const first = collapsed[0];
1455
- return finding({
1456
- code: "live/layout-collapse",
1457
- file: "index.html",
1458
- fix: `"${first.label}" has content but renders ${Math.round(first.width)}\xD7${Math.round(first.height)} \u2014 an element collapsed to zero (missing display, zero-size font, or an ancestor hiding it)`,
1459
- message: `${collapsed.length} HUD element(s) collapsed to zero size`,
1460
- phase: "boot",
1461
- severity: "error"
1462
- });
1463
- };
1464
- pageErrorFinding = (errors, viewport) => {
1465
- const first = errors[0];
1466
- if (first === void 0) {
1467
- return null;
1468
- }
1469
- return finding({
1470
- code: "live/page-error",
1471
- file: "game.js",
1472
- fix: `uncaught: ${first.slice(0, 140)}`,
1473
- message: `${errors.length} uncaught page error(s) [${viewport}]`,
1474
- phase: "boot",
1475
- severity: "error"
1476
- });
1477
- };
1478
- consoleErrorFinding = (entries, pageErrors, viewport) => {
1479
- const unique = entries.filter((entry) => !pageErrors.some((e) => e.includes(entry.slice(0, 60))));
1480
- const first = unique[0];
1481
- if (first === void 0) {
1482
- return null;
1483
- }
1484
- return finding({
1485
- code: "live/console-error",
1486
- file: "game.js",
1487
- fix: `console.error: ${first.slice(0, 140)} \u2014 recoverable failures should be handled, not logged`,
1488
- message: `${unique.length} console.error(s) [${viewport}]`,
1489
- phase: "boot",
1490
- severity: "warning"
1491
- });
1627
+ const [a = -1, b = -1] = octets;
1628
+ if (a === 10) return true;
1629
+ if (a === 172 && b >= 16 && b <= 31) return true;
1630
+ if (a === 192 && b === 168) return true;
1631
+ return false;
1492
1632
  };
1493
- canvasMissingFinding = (viewport) => finding({
1494
- code: "live/canvas-missing",
1495
- file: "index.html",
1496
- fix: 'the contract boots on <canvas id="c">',
1497
- message: `canvas missing [${viewport}]`,
1498
- phase: "boot",
1499
- severity: "error"
1500
- });
1501
- canvasUnpaintedFinding = (viewport, phase = "boot") => finding({
1502
- code: "live/canvas-unpainted",
1503
- file: "game.js",
1504
- fix: "loop.render never drew \u2014 fill loop.render = (ctx) => {...}",
1505
- message: `canvas stayed blank [${viewport}]`,
1506
- phase,
1507
- severity: "error"
1508
- });
1509
- contractMissingFinding = (viewport) => finding({
1510
- code: "live/contract-missing",
1511
- file: "index.html",
1512
- fix: 'import { defineGame } from "frogoe" \u2014 the runtime publishes window.__frogoe at boot',
1513
- message: `window.__frogoe absent [${viewport}]`,
1514
- phase: "boot",
1515
- severity: "error"
1516
- });
1517
- stateStuckFinding = (state, viewport, phase = "boot") => {
1518
- if (state !== "loading") {
1519
- return null;
1520
- }
1521
- return finding({
1522
- code: "live/state-stuck",
1523
- file: "game.js",
1524
- fix: 'state never left "loading" \u2014 defineGame() threw before start() or start() was never called',
1525
- message: `state stuck in "loading" [${viewport}]`,
1526
- phase,
1527
- severity: "error"
1528
- });
1633
+ isLoopback = (addr) => {
1634
+ if (!addr) return false;
1635
+ return addr === "::1" || addr.startsWith("127.") || addr.startsWith("::ffff:127.");
1529
1636
  };
1530
- earlyDeathFinding = (viewport, phase = "boot") => finding({
1531
- code: "live/early-death",
1532
- file: "game.js",
1533
- fix: 'state reached "over" before any input \u2014 the game kills itself on the ready screen; gate death (and the physics that cause it) behind the first input.on("down")',
1534
- message: `game ended before the first input [${viewport}]`,
1535
- phase,
1536
- severity: "error"
1537
- });
1538
- fpsFinding = (fps, viewport) => {
1539
- if (fps === void 0 || fps >= FPS_FLOOR) {
1540
- return null;
1637
+ selfAddresses = (interfaces) => {
1638
+ const own = /* @__PURE__ */ new Set();
1639
+ for (const entries of Object.values(interfaces)) {
1640
+ for (const entry of entries ?? []) {
1641
+ if (entry.family === "IPv4") {
1642
+ own.add(entry.address);
1643
+ own.add(`::ffff:${entry.address}`);
1644
+ } else if (entry.family === "IPv6") {
1645
+ own.add(entry.address);
1646
+ }
1647
+ }
1541
1648
  }
1542
- return finding({
1543
- code: "live/fps",
1544
- file: "game.js",
1545
- fix: `${fps.toFixed(0)} fps on ${viewport} (floor ${FPS_FLOOR}) \u2014 heavy per-frame work: cache gradients, cut particle counts, avoid shadowBlur on big shapes`,
1546
- message: `frame rate below the playability floor [${viewport}]`,
1547
- phase: "play",
1548
- recipe: "frogoe-creative \u2192 game-feel (motion rules)",
1549
- severity: "warning"
1550
- });
1649
+ return own;
1551
1650
  };
1552
- fpsSustainedFinding = (buckets, viewport) => {
1553
- if (buckets.length < FPS_SUSTAINED_WINDOW) {
1554
- return null;
1651
+ pickLanIp = (interfaces, routedName) => {
1652
+ const physical = [];
1653
+ const virtual = [];
1654
+ for (const [name, entries] of Object.entries(interfaces)) {
1655
+ for (const entry of entries ?? []) {
1656
+ if (entry.family !== "IPv4" || entry.internal || !isPrivateV4(entry.address)) continue;
1657
+ const item = { ip: entry.address, name };
1658
+ if (VIRTUAL.test(name.toLowerCase())) virtual.push(item);
1659
+ else physical.push(item);
1660
+ }
1555
1661
  }
1556
- let worst = Number.POSITIVE_INFINITY;
1557
- for (let i = 0; i + FPS_SUSTAINED_WINDOW <= buckets.length; i++) {
1558
- const window = buckets.slice(i, i + FPS_SUSTAINED_WINDOW);
1559
- const avg = window.reduce((a, b) => a + b, 0) / window.length;
1560
- if (avg < worst) {
1561
- worst = avg;
1662
+ physical.sort((a, b) => a.name.localeCompare(b.name));
1663
+ virtual.sort((a, b) => a.name.localeCompare(b.name));
1664
+ if (routedName) {
1665
+ const routed = physical.find((p) => p.name === routedName) ?? virtual.find((v) => v.name === routedName);
1666
+ if (routed) {
1667
+ return {
1668
+ candidates: [routed.ip],
1669
+ confidence: "routed",
1670
+ ip: routed.ip,
1671
+ virtual: !physical.includes(routed)
1672
+ };
1562
1673
  }
1563
1674
  }
1564
- if (worst >= FPS_FLOOR) {
1565
- return null;
1675
+ const chosen = physical[0] ?? virtual[0];
1676
+ if (!chosen) {
1677
+ return { candidates: [], confidence: "none", virtual: false };
1566
1678
  }
1567
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1568
1679
  return {
1569
- finding: finding({
1570
- code: "live/fps-sustained",
1571
- file: "game.js",
1572
- fix: `${worst.toFixed(0)} fps held for ${FPS_SUSTAINED_WINDOW}s+ on ${viewport} (floor ${FPS_FLOOR}) \u2014 the game is consistently slow, not jittering: cut per-frame allocations, shrink offscreen canvases, reduce draw calls`,
1573
- message: `sustained low frame rate [${viewport}]`,
1574
- phase: "play",
1575
- recipe: "frogoe-creative \u2192 game-feel (motion rules)",
1576
- severity: "error"
1577
- }),
1578
- mean
1680
+ candidates: physical.length > 0 ? physical.map((p) => p.ip) : virtual.map((v) => v.ip),
1681
+ confidence: "heuristic",
1682
+ ip: chosen.ip,
1683
+ virtual: !physical.includes(chosen)
1579
1684
  };
1580
1685
  };
1581
- frozenFrameFinding = (streak) => {
1582
- if (streak < FROZEN_STREAK) {
1583
- return null;
1584
- }
1585
- return finding({
1586
- code: "live/frozen-frame",
1587
- file: "game.js",
1588
- fix: `canvas held the same frame for ${streak} samples while playing \u2014 loop.render may have stopped or draws a static scene`,
1589
- message: "canvas froze during play",
1590
- phase: "play",
1591
- severity: "warning"
1592
- });
1686
+ interfaceFromRouteOutput = (output, style) => {
1687
+ if (style === "bsd") return /interface:\s*(\S+)/u.exec(output)?.[1];
1688
+ if (style === "linux") return /dev\s+(\S+)/u.exec(output)?.[1];
1689
+ return output.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0);
1593
1690
  };
1594
- pausedFinding = () => finding({
1595
- code: "live/paused",
1596
- file: "game.js",
1597
- fix: 'state read "paused" during scripted play \u2014 pause() ran without a matching resume(); check document.hidden handling and blur listeners',
1598
- message: "game paused itself during play",
1599
- phase: "play",
1600
- severity: "warning"
1601
- });
1602
- stateCorruptFinding = (state) => finding({
1603
- code: "live/state-corrupt",
1604
- file: "game.js",
1605
- fix: `state read "${state}" \u2014 outside the contract's set (loading/playing/paused/over); game code is mutating window.__frogoe directly`,
1606
- message: `state left the contract's state machine ("${state}")`,
1607
- phase: "play",
1608
- severity: "error"
1609
- });
1610
- playabilityFinding = (result) => {
1611
- if (result === "pass") {
1612
- return null;
1613
- }
1614
- if (result === "no-input") {
1615
- return finding({
1616
- code: "live/no-input",
1617
- file: "game.js",
1618
- fix: 'the game never registered input.on("down", ...) \u2014 wire the core verb before shipping',
1619
- message: "game has no input handler",
1620
- phase: "play",
1621
- severity: "error"
1622
- });
1691
+ ROUTE_PROBES = [
1692
+ { cmd: "route -n get default", style: "bsd" },
1693
+ // darwin/bsd; usage-errors elsewhere
1694
+ { cmd: "ip route show default", style: "linux" },
1695
+ // linux; absent on darwin/win
1696
+ {
1697
+ cmd: `powershell -NoProfile -Command "(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1).InterfaceAlias"`,
1698
+ style: "windows"
1623
1699
  }
1624
- return finding({
1625
- code: "live/not-playable",
1626
- file: "game.js",
1627
- fix: "scripted taps produced no canvas change \u2014 loop.update/loop.render may be wired but the game logic never runs",
1628
- message: "game did not respond to scripted input",
1629
- phase: "play",
1630
- severity: "error"
1631
- });
1632
- };
1633
- finishEventFinding = (stateOver, finishCount) => {
1634
- if (stateOver === finishCount > 0) {
1635
- return null;
1700
+ ];
1701
+ routedInterfaceName = () => {
1702
+ for (const probe of ROUTE_PROBES) {
1703
+ try {
1704
+ const out = execSync(probe.cmd, {
1705
+ encoding: "utf-8",
1706
+ stdio: ["ignore", "pipe", "ignore"],
1707
+ timeout: 3e3
1708
+ });
1709
+ const name = interfaceFromRouteOutput(out, probe.style);
1710
+ if (name) return name;
1711
+ } catch {
1712
+ }
1636
1713
  }
1637
- return finding({
1638
- code: "live/finish-event-missing",
1639
- file: "game.js",
1640
- fix: stateOver ? 'state reached "over" but frogoe:finish never fired \u2014 call finish(score) on death, never write __frogoe.state directly' : 'frogoe:finish fired but state is not "over" \u2014 the event was dispatched outside finish()',
1641
- message: "state/event mismatch at game over",
1642
- phase: "end",
1643
- severity: "error"
1644
- });
1645
- };
1646
- neverEndsFinding = (budgetMs) => finding({
1647
- code: "live/never-ends",
1648
- file: "game.js",
1649
- fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 fine for endless/sandbox games, but feed games are short replayable loops; most deaths should arrive in seconds`,
1650
- message: "game never reached the over state",
1651
- phase: "end",
1652
- severity: "warning"
1653
- });
1654
- audioLockedFinding = (audio) => {
1655
- if (audio.count === 0 || audio.running > 0) {
1656
- return null;
1657
- }
1658
- return finding({
1659
- code: "live/audio-locked",
1660
- file: "game.js",
1661
- fix: 'audio contexts exist but stayed suspended after an interruption + real input \u2014 resume inside a user gesture: Sfx.init() in input.on("down") AND ("up"), plus the 1-sample silent-buffer unlock (frogoe-core \u2192 references/audio.md)',
1662
- message: "audio never recovers \u2014 every phone interruption plays this game silent",
1663
- phase: "play",
1664
- recipe: "frogoe-core \u2192 references/audio.md",
1665
- severity: "error"
1666
- });
1667
- };
1668
- noGameoverCardFinding = () => finding({
1669
- code: "live/no-gameover-card",
1670
- file: "index.html",
1671
- fix: "no [data-block-gameover] overlay when the game ended \u2014 install the game-over-card block so death has a screen",
1672
- message: "game over has no overlay",
1673
- phase: "end",
1674
- recipe: "frogoe-registry \u2192 game-over-card",
1675
- severity: "warning"
1676
- });
1677
- THROTTLE_RATE = 4;
1678
- THROTTLED_FPS_FLOOR = 15;
1679
- fpsThrottledFinding = (buckets, viewport) => {
1680
- if (buckets.length === 0) return null;
1681
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1682
- if (mean >= THROTTLED_FPS_FLOOR) return null;
1683
- return finding({
1684
- code: "live/fps-throttled",
1685
- file: "game.js",
1686
- fix: `fps ${mean.toFixed(0)} under ${THROTTLE_RATE}x cpu throttle (${viewport}) \u2014 mid-range phones will feel this: cache gradients/patterns, cut per-frame allocations, shrink offscreen canvases, cap particles`,
1687
- message: "cpu-bound collapse under phone-class throttle",
1688
- phase: "play",
1689
- severity: "warning"
1690
- });
1691
- };
1692
- noRetryFinding = () => finding({
1693
- code: "live/no-retry",
1694
- file: "index.html",
1695
- fix: "no [data-block-retry] button anywhere \u2014 the player is hard-stuck after death; the retry affordance is the loop's exit",
1696
- message: "no retry affordance after game over",
1697
- phase: "end",
1698
- recipe: "frogoe-registry \u2192 game-over-card",
1699
- severity: "error"
1700
- });
1701
- retryDeadFinding = () => finding({
1702
- code: "live/retry-dead",
1703
- file: "game.js",
1704
- fix: 'clicking retry produced no reload \u2014 wire it: retry.addEventListener("click", () => location.reload())',
1705
- message: "retry button did not reload the page",
1706
- phase: "retry",
1707
- severity: "error"
1708
- });
1709
- rebootFinding = (state) => {
1710
- if (state === "playing") {
1711
- return null;
1712
- }
1713
- return finding({
1714
- code: "live/state-stuck",
1715
- file: "game.js",
1716
- fix: `after retry reloaded the page the state read "${state}" \u2014 the second boot is not healthy (check localStorage parsing and one-time init paths)`,
1717
- message: `retry boot stuck in "${state}"`,
1718
- phase: "retry",
1719
- severity: "error"
1720
- });
1714
+ return null;
1721
1715
  };
1716
+ resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
1722
1717
  }
1723
1718
  });
1724
1719
 
1725
- // src/live/phases.ts
1726
- var END_BUDGET_MS, RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep2, jitterX, jitterY, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
1727
- var init_phases = __esm({
1728
- "src/live/phases.ts"() {
1720
+ // src/telemetry/records.ts
1721
+ var FPS_FLOOR, formatClock, dipSpans, beaconToRecords, summarizeRecords;
1722
+ var init_records = __esm({
1723
+ "src/telemetry/records.ts"() {
1729
1724
  "use strict";
1730
- init_decisions();
1731
- END_BUDGET_MS = 45e3;
1732
- RETRY_NAV_MS = 8e3;
1733
- PLAY_STEPS = 7;
1734
- PLAY_STEP_MS = 850;
1735
- HOLD_STEP_INDEX = 3;
1736
- DRAG_STEP_INDEX = 5;
1737
- DRAG_SPAN = 90;
1738
- HOLD_MS = 400;
1739
- POLL_MS = 400;
1740
- GRACE_MS = 150;
1741
- STABILITY_CYCLES = 2;
1742
- START_BURST_TAPS = 3;
1743
- DESKTOP_FPS_MS = 2e3;
1744
- sleep2 = (ms) => new Promise((resolve2) => {
1745
- setTimeout(resolve2, ms);
1746
- });
1747
- jitterX = (step) => step * 37 % 121 - 60;
1748
- jitterY = (step) => step * 53 % 181 - 90;
1749
- hasError = (findings) => findings.some((f) => f.severity === "error");
1750
- runBootChecks = async (driver, ctx) => {
1751
- const findings = [];
1752
- const name = ctx.viewport.name;
1753
- const pageErr = pageErrorFinding(driver.errors(), name);
1754
- if (pageErr) {
1755
- findings.push(pageErr);
1756
- }
1757
- const conErr = consoleErrorFinding(driver.consoleErrors(), driver.errors(), name);
1758
- if (conErr) {
1759
- findings.push(conErr);
1760
- }
1761
- const probe = await driver.domProbe();
1762
- if (!probe.canvasPresent) {
1763
- findings.push(canvasMissingFinding(name));
1764
- } else if (!await driver.canvasPainted()) {
1765
- findings.push(canvasUnpaintedFinding(name));
1766
- }
1767
- if (probe.state === "(missing)") {
1768
- findings.push(contractMissingFinding(name));
1769
- } else if (probe.state === "over") {
1770
- findings.push(earlyDeathFinding(name));
1771
- } else {
1772
- const stuck = stateStuckFinding(probe.state, name);
1773
- if (stuck) {
1774
- findings.push(stuck);
1775
- }
1776
- }
1777
- if (probe.hudPresent) {
1778
- const measures = await driver.hudMeasures();
1779
- const outline = outlineFinding(measures);
1780
- if (outline) {
1781
- findings.push(outline);
1782
- }
1783
- const collapse = collapseFinding(measures);
1784
- if (collapse) {
1785
- findings.push(collapse);
1786
- }
1787
- }
1788
- return findings;
1725
+ FPS_FLOOR = 30;
1726
+ formatClock = (wall) => {
1727
+ const d = new Date(wall);
1728
+ const p = (n) => String(n).padStart(2, "0");
1729
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
1789
1730
  };
1790
- runDesktopPass = async (driver, ctx) => {
1791
- const doSleep = ctx.sleep ?? sleep2;
1792
- const findings = await runBootChecks(driver, ctx);
1793
- if (hasError(findings)) {
1794
- return { findings };
1795
- }
1796
- const mark = await driver.fpsMark();
1797
- await doSleep(DESKTOP_FPS_MS);
1798
- const buckets = await driver.fpsSince(mark);
1799
- if (buckets.length === 0) {
1800
- return { findings };
1801
- }
1802
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1803
- const warn = fpsFinding(mean, ctx.viewport.name);
1804
- if (warn) {
1805
- findings.push(warn);
1731
+ dipSpans = (buckets) => {
1732
+ const spans = [];
1733
+ let start = -1;
1734
+ let worst = 0;
1735
+ for (let i = 0; i <= buckets.length; i++) {
1736
+ const low = i < buckets.length && (buckets[i] ?? 0) > 0 && (buckets[i] ?? 0) < FPS_FLOOR;
1737
+ if (low && start === -1) {
1738
+ start = i;
1739
+ worst = buckets[i] ?? 0;
1740
+ } else if (low) {
1741
+ worst = Math.min(worst, buckets[i] ?? 0);
1742
+ } else if (start !== -1) {
1743
+ spans.push({ fps: worst, len: i - start, start });
1744
+ start = -1;
1745
+ }
1806
1746
  }
1807
- await ctx.shot?.("live-desktop.png");
1808
- return { findings, fps: Math.round(mean) };
1747
+ return spans;
1809
1748
  };
1810
- overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
1811
- retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
1812
- waitForOver = async (driver, doSleep) => {
1813
- for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
1814
- await doSleep(POLL_MS);
1815
- if (await driver.gameState() === "over") {
1816
- return true;
1749
+ beaconToRecords = (payload, arrivalWall) => {
1750
+ const out = [];
1751
+ const arrivalUp = payload.up;
1752
+ const wallAt = (up) => arrivalWall - Math.max(0, arrivalUp - up) * 1e3;
1753
+ const buckets = payload.fps ?? [];
1754
+ const spans = dipSpans(buckets);
1755
+ const spanEnd = new Map(spans.map((s) => [s.start + s.len - 1, s]));
1756
+ for (let i = 0; i < buckets.length; i++) {
1757
+ const fps = buckets[i] ?? 0;
1758
+ const up = Math.max(0, arrivalUp - (buckets.length - 1 - i));
1759
+ const wall = wallAt(up);
1760
+ const time = formatClock(wall);
1761
+ out.push({
1762
+ record: { fps, time, type: "fps", up, wall },
1763
+ text: ""
1764
+ });
1765
+ const span = spanEnd.get(i);
1766
+ if (span) {
1767
+ out.push({
1768
+ record: { fps: span.fps, time, type: "fps", up, wall },
1769
+ text: `\u26A0 ${time} fps ${span.fps} \u2014 dip ${span.len}s`
1770
+ });
1817
1771
  }
1818
1772
  }
1819
- return false;
1820
- };
1821
- runStartBurst = async (driver, ctx) => {
1822
- const doSleep = ctx.sleep ?? sleep2;
1823
- for (let step = 0; step < START_BURST_TAPS; step++) {
1824
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
1825
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
1826
- await driver.tap(x, y);
1827
- await doSleep(PLAY_STEP_MS);
1773
+ for (const event of payload.events ?? []) {
1774
+ const wall = wallAt(event.up);
1775
+ const time = formatClock(wall);
1776
+ const type = event.type === "error" || event.type === "rejection" || event.type === "hidden" || event.type === "visible" ? event.type : "error";
1777
+ const msg = (event.msg ?? "").slice(0, 160);
1778
+ const text = type === "hidden" ? `\xB7 ${time} phone hidden` : type === "visible" ? `\xB7 ${time} phone visible` : `\u2716 ${time} ${type === "rejection" ? "unhandled rejection" : "page error"}: ${msg}`;
1779
+ out.push({ record: { msg, time, type, up: event.up, wall }, text });
1828
1780
  }
1781
+ return out;
1829
1782
  };
1830
- verifyDeath = async (driver, ctx, findings, cycle) => {
1831
- const doSleep = ctx.sleep ?? sleep2;
1832
- let events = await driver.finishEvents();
1833
- if (events.length === 0) {
1834
- await doSleep(GRACE_MS);
1835
- events = await driver.finishEvents();
1783
+ summarizeRecords = (records) => {
1784
+ const fps = records.filter((r) => r.type === "fps" && typeof r.fps === "number");
1785
+ const buckets = fps.map((r) => r.fps ?? 0).filter((n) => n > 0);
1786
+ const spans = dipSpans(buckets);
1787
+ const orderKey = (r) => typeof r.wall === "number" ? r.wall : r.up * 1e3;
1788
+ let hiddenS = 0;
1789
+ let pendingHidden;
1790
+ for (const r of records) {
1791
+ if (pendingHidden !== void 0) {
1792
+ hiddenS += Math.max(0, orderKey(r) - pendingHidden) / 1e3;
1793
+ pendingHidden = void 0;
1794
+ }
1795
+ if (r.type === "hidden") {
1796
+ pendingHidden = orderKey(r);
1797
+ }
1836
1798
  }
1837
- const mismatch = finishEventFinding(true, events.length);
1838
- if (mismatch) {
1839
- findings.push(mismatch);
1799
+ const worstSpan = spans.reduce(
1800
+ (acc, s) => acc === void 0 || s.len > acc.len ? s : acc,
1801
+ void 0
1802
+ );
1803
+ const worst = worstSpan ? {
1804
+ fps: worstSpan.fps,
1805
+ len: worstSpan.len,
1806
+ time: fps[worstSpan.start + worstSpan.len - 1]?.time ?? "?"
1807
+ } : void 0;
1808
+ let pageLoads = records.length > 0 ? 1 : 0;
1809
+ let prevUp = -1;
1810
+ for (const r of records) {
1811
+ if (prevUp !== -1 && r.up + 2 < prevUp) pageLoads += 1;
1812
+ prevUp = r.up;
1840
1813
  }
1841
- const presence = await driver.retryPresence();
1842
- if (!presence.gameover) {
1843
- findings.push(noGameoverCardFinding());
1844
- }
1845
- if (!presence.retry) {
1846
- findings.push(noRetryFinding());
1814
+ const first = records[0];
1815
+ const last = records[records.length - 1];
1816
+ const durationS = first && last && typeof first.wall === "number" && typeof last.wall === "number" ? (
1817
+ // +1s: the first bucket already covers one second of play
1818
+ Math.round((last.wall - first.wall + 1e3) / 1e3)
1819
+ ) : Math.round(last?.up ?? 0);
1820
+ return {
1821
+ buckets: buckets.length,
1822
+ dips: spans.length,
1823
+ durationS,
1824
+ errors: records.filter((r) => r.type === "error" || r.type === "rejection").length,
1825
+ hiddenS: Math.round(hiddenS),
1826
+ meanFps: buckets.length > 0 ? Math.round(buckets.reduce((a, b) => a + b, 0) / buckets.length) : void 0,
1827
+ pageLoads,
1828
+ worst
1829
+ };
1830
+ };
1831
+ }
1832
+ });
1833
+
1834
+ // src/telemetry/session.ts
1835
+ import { appendFileSync, mkdirSync as mkdirSync3, readdirSync as readdirSync2 } from "fs";
1836
+ import path8 from "path";
1837
+ var pad, sessionStamp, createSessionStore, latestSessionFile;
1838
+ var init_session = __esm({
1839
+ "src/telemetry/session.ts"() {
1840
+ "use strict";
1841
+ pad = (n) => String(n).padStart(2, "0");
1842
+ sessionStamp = (wall) => {
1843
+ const d = new Date(wall);
1844
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1845
+ };
1846
+ createSessionStore = (gameDir, startedWall) => {
1847
+ const dir = path8.join(gameDir, ".frogoe", "sessions");
1848
+ let file;
1849
+ return {
1850
+ file: () => file,
1851
+ write: (records) => {
1852
+ if (records.length === 0) return;
1853
+ if (!file) {
1854
+ mkdirSync3(dir, { recursive: true });
1855
+ file = path8.join(dir, `${sessionStamp(startedWall)}.jsonl`);
1856
+ }
1857
+ appendFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
1858
+ }
1859
+ };
1860
+ };
1861
+ latestSessionFile = (gameDir) => {
1862
+ const dir = path8.join(gameDir, ".frogoe", "sessions");
1863
+ try {
1864
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
1865
+ return files.length > 0 ? path8.join(dir, files[files.length - 1] ?? "") : null;
1866
+ } catch {
1867
+ return null;
1847
1868
  }
1848
- await ctx.shot?.(overShotName(cycle));
1849
- return presence.retry;
1850
1869
  };
1851
- runLifecycle = async (driver, ctx) => {
1852
- const doSleep = ctx.sleep ?? sleep2;
1853
- const settle = ctx.settleMs ?? 2e3;
1854
- const name = ctx.viewport.name;
1855
- const findings = [];
1856
- const boot = await runBootChecks(driver, ctx);
1857
- findings.push(...boot);
1858
- await ctx.shot?.("live-mobile.png");
1859
- if (hasError(boot)) {
1860
- return { findings, lifecycle: { ends: false, retryReloads: 0 }, playability: "no-input" };
1870
+ }
1871
+ });
1872
+
1873
+ // src/run.ts
1874
+ var run_exports = {};
1875
+ __export(run_exports, {
1876
+ startServer: () => startServer
1877
+ });
1878
+ import { existsSync as existsSync6, readFileSync as readFileSync7, statSync, watch } from "fs";
1879
+ import os2 from "os";
1880
+ import path9 from "path";
1881
+ import { createAdaptorServer } from "@hono/node-server";
1882
+ import { getConnInfo } from "@hono/node-server/conninfo";
1883
+ import { Hono } from "hono";
1884
+ var buildDevScript, MIME2, startServer;
1885
+ var init_run = __esm({
1886
+ "src/run.ts"() {
1887
+ "use strict";
1888
+ init_ip();
1889
+ init_records();
1890
+ init_session();
1891
+ buildDevScript = (version) => `<script>(function(){var v="${String(version)}";try{var es=new EventSource("/__frogoe/reload");es.onmessage=function(){location.reload()};es.onerror=function(){es.close()};}catch(e){}setInterval(function(){fetch("/__frogoe/version",{cache:"no-store"}).then(function(r){return r.text()}).then(function(t){if(t!==v)location.reload()}).catch(function(){})},2000);var fps=[],cnt=0,sec=performance.now(),evs=[],up0=performance.now();function up(){return (performance.now()-up0)/1000}function tick(){cnt++;var n=performance.now();if(n-sec>=1000){fps.push(cnt);cnt=0;sec=n}requestAnimationFrame(tick)}requestAnimationFrame(tick);addEventListener("error",function(e){evs.push({type:"error",msg:String(e.message||e).slice(0,200),up:up()})});addEventListener("unhandledrejection",function(e){evs.push({type:"rejection",msg:String(e.reason).slice(0,200),up:up()})});document.addEventListener("visibilitychange",function(){evs.push({type:document.hidden?"hidden":"visible",up:up()})});function flush(beacon){var p={v:1,up:up(),fps:fps.splice(0),events:evs.splice(0)};if(performance.memory)p.mem=Math.round(performance.memory.usedJSHeapSize/1048576);var b=JSON.stringify(p);if(beacon&&navigator.sendBeacon){navigator.sendBeacon("/__frogoe/metrics",new Blob([b],{type:"application/json"}));return}fetch("/__frogoe/metrics",{method:"POST",body:b,headers:{"content-type":"application/json"},keepalive:true}).catch(function(){});}document.addEventListener("visibilitychange",function(){if(document.hidden)flush(true)});addEventListener("pagehide",function(){flush(true)});setInterval(function(){flush(false)},5000);})();</script>`;
1892
+ MIME2 = {
1893
+ css: "text/css; charset=utf-8",
1894
+ htm: "text/html; charset=utf-8",
1895
+ html: "text/html; charset=utf-8",
1896
+ ico: "image/x-icon",
1897
+ jpeg: "image/jpeg",
1898
+ js: "text/javascript; charset=utf-8",
1899
+ json: "application/json; charset=utf-8",
1900
+ mjs: "text/javascript; charset=utf-8",
1901
+ png: "image/png",
1902
+ svg: "image/svg+xml",
1903
+ txt: "text/plain; charset=utf-8",
1904
+ webp: "image/webp",
1905
+ woff2: "font/woff2"
1906
+ };
1907
+ startServer = async (dir, requestedPort = 0, telemetry) => {
1908
+ const root = path9.resolve(dir);
1909
+ if (!existsSync6(path9.join(root, "index.html"))) {
1910
+ throw new Error(`frogoe run: no index.html in ${root} \u2014 is this a game folder?`);
1861
1911
  }
1862
- const mark = await driver.fpsMark();
1863
- const hashes = [];
1864
- let streak = 0;
1865
- let maxStreak = 0;
1866
- let sawOver = false;
1867
- let sawPaused = false;
1868
- let sawStuck = false;
1869
- let corrupt = null;
1870
- for (let step = 0; step < PLAY_STEPS; step++) {
1871
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
1872
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
1873
- if (step === HOLD_STEP_INDEX) {
1874
- await driver.hold(x, y, HOLD_MS);
1875
- } else if (step === DRAG_STEP_INDEX) {
1876
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
1877
- } else {
1878
- await driver.tap(x, y);
1912
+ const clients = /* @__PURE__ */ new Set();
1913
+ let version = 0;
1914
+ let remoteSeen = false;
1915
+ let lastRemote = "";
1916
+ const own = selfAddresses(os2.networkInterfaces());
1917
+ const session = telemetry ? createSessionStore(root, Date.now()) : void 0;
1918
+ const app = new Hono();
1919
+ app.use("*", async (c, next) => {
1920
+ try {
1921
+ const address2 = getConnInfo(c).remote.address;
1922
+ if (address2 && !isLoopback(address2) && !own.has(address2)) {
1923
+ remoteSeen = true;
1924
+ lastRemote = address2;
1925
+ }
1926
+ } catch {
1879
1927
  }
1880
- await doSleep(PLAY_STEP_MS);
1881
- const state = await driver.gameState();
1882
- if (state === "over") {
1883
- sawOver = true;
1884
- break;
1928
+ await next();
1929
+ });
1930
+ app.get("/__frogoe/reload", (c) => {
1931
+ const stream = new ReadableStream({
1932
+ cancel() {
1933
+ },
1934
+ start(controller) {
1935
+ clients.add(controller);
1936
+ controller.enqueue(new TextEncoder().encode("retry: 3000\n\n"));
1937
+ }
1938
+ });
1939
+ return c.body(stream, {
1940
+ headers: {
1941
+ "cache-control": "no-store",
1942
+ connection: "keep-alive",
1943
+ "content-type": "text/event-stream"
1944
+ }
1945
+ });
1946
+ });
1947
+ app.get(
1948
+ "/__frogoe/version",
1949
+ (c) => c.text(String(version), 200, { "cache-control": "no-store" })
1950
+ );
1951
+ app.post("/__frogoe/metrics", async (c) => {
1952
+ try {
1953
+ const payload = JSON.parse(await c.req.text());
1954
+ if (!session) return c.body(null, 204);
1955
+ const lines = beaconToRecords(payload, Date.now());
1956
+ session.write(lines.map((l) => l.record));
1957
+ for (const line of lines) {
1958
+ if (line.text) telemetry?.onEvent?.(line.text);
1959
+ }
1960
+ return c.body(null, 204);
1961
+ } catch {
1962
+ return c.body(null, 400);
1885
1963
  }
1886
- if (state === "paused") {
1887
- sawPaused = true;
1888
- } else if (state === "loading") {
1889
- sawStuck = true;
1890
- } else if (state !== "playing" && state !== "(missing)") {
1891
- corrupt ??= state;
1964
+ });
1965
+ app.get("*", (c) => {
1966
+ const raw = decodeURIComponent(new URL(c.req.url).pathname);
1967
+ const safe = path9.normalize(raw).replaceAll("\\", "/");
1968
+ let file = path9.join(root, safe === "/" ? "index.html" : safe);
1969
+ if (!file.startsWith(root)) {
1970
+ return c.text("forbidden", 403);
1892
1971
  }
1893
- const hash = await driver.canvasHash();
1894
- if (hash !== null) {
1895
- if (hashes.length > 0 && hash === hashes[hashes.length - 1] && state === "playing") {
1896
- streak += 1;
1897
- maxStreak = Math.max(maxStreak, streak);
1898
- } else {
1899
- streak = 0;
1900
- }
1901
- hashes.push(hash);
1972
+ if (existsSync6(file) && statSync(file).isDirectory()) {
1973
+ file = path9.join(file, "index.html");
1902
1974
  }
1903
- }
1904
- const buckets = await driver.fpsSince(mark);
1905
- const mean = buckets.length > 0 ? buckets.reduce((a, b) => a + b, 0) / buckets.length : void 0;
1906
- if (corrupt !== null) {
1907
- findings.push(stateCorruptFinding(corrupt));
1908
- }
1909
- if (sawStuck) {
1910
- const stuck = stateStuckFinding("loading", name, "play");
1911
- if (stuck) {
1912
- findings.push(stuck);
1975
+ if (!existsSync6(file)) {
1976
+ return c.text(`frogoe run: not found: ${raw}`, 404);
1913
1977
  }
1914
- }
1915
- if (sawPaused) {
1916
- findings.push(pausedFinding());
1917
- }
1918
- const frozen = frozenFrameFinding(maxStreak);
1919
- if (frozen) {
1920
- findings.push(frozen);
1921
- }
1922
- const sustained = fpsSustainedFinding(buckets, name);
1923
- if (sustained) {
1924
- findings.push(sustained.finding);
1925
- } else {
1926
- const warn = fpsFinding(mean, name);
1927
- if (warn) {
1928
- findings.push(warn);
1978
+ const body = readFileSync7(file);
1979
+ const ext = path9.extname(file).slice(1).toLowerCase();
1980
+ const type = MIME2[ext] ?? "application/octet-stream";
1981
+ if (ext === "html" || ext === "htm") {
1982
+ const html = body.toString("utf-8");
1983
+ const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
1984
+ return c.body(injected, 200, {
1985
+ "cache-control": "no-store",
1986
+ "content-type": type
1987
+ });
1929
1988
  }
1989
+ return c.body(body, 200, { "content-type": type });
1990
+ });
1991
+ const server = createAdaptorServer({ fetch: app.fetch });
1992
+ await new Promise((resolve2, reject) => {
1993
+ server.once("error", reject);
1994
+ server.listen(requestedPort, "0.0.0.0", () => resolve2());
1995
+ });
1996
+ const address = server.address();
1997
+ const port = typeof address === "object" && address ? address.port : 0;
1998
+ if (!port) {
1999
+ server.closeAllConnections?.();
2000
+ server.close();
2001
+ throw new Error("frogoe run: server failed to bind a port");
1930
2002
  }
1931
- const responded = new Set(hashes).size > 1 || sawOver;
1932
- const playability = responded ? "pass" : "fail";
1933
- const play = playabilityFinding(playability);
1934
- if (play) {
1935
- findings.push(play);
1936
- }
1937
- const audioBefore = await driver.audioStates();
1938
- if (audioBefore.count > 0) {
1939
- await driver.interruptAudio();
1940
- await runStartBurst(driver, ctx);
1941
- await doSleep(600);
1942
- const audioFinding = audioLockedFinding(await driver.audioStates());
1943
- if (audioFinding) {
1944
- findings.push(audioFinding);
2003
+ const local = `http://localhost:${port}`;
2004
+ const lanInfo = resolveLan();
2005
+ const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
2006
+ let timer;
2007
+ const watcher = watch(root, { recursive: true }, (_event, file) => {
2008
+ const first = file?.split(path9.sep)[0];
2009
+ if (first === "snapshots" || first === ".frogoe" || first === "dist") {
2010
+ return;
2011
+ }
2012
+ clearTimeout(timer);
2013
+ timer = setTimeout(() => {
2014
+ version += 1;
2015
+ const payload = new TextEncoder().encode("data: reload\n\n");
2016
+ for (const client of clients) {
2017
+ try {
2018
+ client.enqueue(payload);
2019
+ } catch {
2020
+ clients.delete(client);
2021
+ }
2022
+ }
2023
+ }, 100);
2024
+ });
2025
+ return {
2026
+ port,
2027
+ sawRemote: () => remoteSeen,
2028
+ remote: () => remoteSeen ? lastRemote : void 0,
2029
+ stop() {
2030
+ clearTimeout(timer);
2031
+ watcher.close();
2032
+ server.closeAllConnections?.();
2033
+ server.close();
2034
+ },
2035
+ urls: { lan, local }
2036
+ };
2037
+ };
2038
+ }
2039
+ });
2040
+
2041
+ // src/raster.ts
2042
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
2043
+ import path10 from "path";
2044
+ var SCENES, sleep2, rasterScriptFor, rasterizeArt;
2045
+ var init_raster = __esm({
2046
+ "src/raster.ts"() {
2047
+ "use strict";
2048
+ init_src();
2049
+ init_runtime_source();
2050
+ init_art_verify();
2051
+ init_runtime_source();
2052
+ init_browser();
2053
+ SCENES = [
2054
+ {
2055
+ draw: "drawPoster",
2056
+ height: 1920,
2057
+ out: path10.join("dist", "assets", "poster.png"),
2058
+ size: "w, h",
2059
+ source: path10.join("assets", "poster.js"),
2060
+ width: 1080
2061
+ },
2062
+ {
2063
+ draw: "drawIcon",
2064
+ height: 1024,
2065
+ out: path10.join("dist", "assets", "icon.png"),
2066
+ size: "size",
2067
+ source: path10.join("assets", "icon.js"),
2068
+ width: 1024
2069
+ }
2070
+ ];
2071
+ sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
2072
+ rasterScriptFor = (scene, palette) => {
2073
+ const sizeArg = scene.draw === "drawIcon" ? String(scene.width) : `${String(scene.width)}, ${String(scene.height)}`;
2074
+ const analyze = scene.draw === "drawIcon" ? "analyzeIconCorners" : "analyzeTitleBand";
2075
+ return `(async () => {
2076
+ ${runtimeSource()}
2077
+ const mod = await import("./assets/${path10.basename(scene.source)}");
2078
+ const canvas = document.createElement("canvas");
2079
+ canvas.id = "__frogoe_raster";
2080
+ canvas.width = ${String(scene.width)};
2081
+ canvas.height = ${String(scene.height)};
2082
+ canvas.style.cssText =
2083
+ "position:fixed;left:0;top:0;width:${String(scene.width)}px;height:${String(scene.height)}px;z-index:2147483647;pointer-events:none;";
2084
+ document.body.append(canvas);
2085
+ try {
2086
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
2087
+ await document.fonts.ready;
2088
+ mod.${scene.draw}(ctx, ${sizeArg});
2089
+ const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
2090
+ window.__frogoeArtReport = ${analyze}(pixels, canvas.width, canvas.height);
2091
+ try { window.__frogoeTitleBand = ctx.__frogoeTitleBand ?? null; } catch (e) { window.__frogoeTitleBand = null; }
2092
+ window.__frogoeArtMetrics = compositionMetrics(
2093
+ pixels, canvas.width, canvas.height,
2094
+ ${String(scene.draw === "drawIcon" ? 10 : 21)},
2095
+ ${JSON.stringify(palette)},
2096
+ );
2097
+ window.__frogoeRaster = true;
2098
+ } catch (error) {
2099
+ window.__frogoeRasterError = String(error);
2100
+ }
2101
+ })()`;
2102
+ };
2103
+ rasterizeArt = async (options) => {
2104
+ const dir = path10.resolve(options.dir);
2105
+ const missing = SCENES.filter((scene) => !existsSync7(path10.join(dir, scene.source)));
2106
+ if (missing.length > 0) {
2107
+ throw new Error(
2108
+ `bundle/art-missing \u2014 author the identity scenes first (${missing.map((scene) => scene.source).join(", ")}); run \`frogoe check\` and see frogoe-creative \u2192 references/art.md`
2109
+ );
2110
+ }
2111
+ const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
2112
+ const server = await startServer2(dir);
2113
+ const { default: puppeteer } = await import("puppeteer-core");
2114
+ const executablePath = await ensureBrowser();
2115
+ const browser = await puppeteer.launch({
2116
+ args: ["--no-sandbox", "--disable-gpu"],
2117
+ defaultViewport: null,
2118
+ executablePath,
2119
+ headless: true
2120
+ });
2121
+ try {
2122
+ const base = server.urls.local.replace(/\/$/u, "");
2123
+ const brief = parseBrief(readFileSync8(path10.join(dir, "BRIEF.md"), "utf-8"));
2124
+ const palette = {
2125
+ accent: brief?.accent ?? "#ff3b3b",
2126
+ bg: brief?.bg ?? "#101418",
2127
+ fg: brief?.fg ?? "#ffffff",
2128
+ ...brief?.outline ? { outline: brief.outline } : {}
2129
+ };
2130
+ const files = [];
2131
+ const warnings = [];
2132
+ for (const scene of SCENES) {
2133
+ const page = await browser.newPage();
2134
+ try {
2135
+ await page.setViewport({ height: scene.height, width: scene.width });
2136
+ await page.goto(base, { timeout: 15e3, waitUntil: "domcontentloaded" });
2137
+ await page.waitForFunction("window.__frogoe !== undefined", { timeout: 15e3 });
2138
+ await page.evaluate(rasterScriptFor(scene, palette));
2139
+ await page.waitForFunction("window.__frogoeRaster === true", { timeout: 2e4 });
2140
+ const failed = await page.evaluate("window.__frogoeRasterError ?? null");
2141
+ if (failed !== null) {
2142
+ throw new Error(`bundle/art-crash \u2014 ${scene.source}: ${failed.slice(0, 160)}`);
2143
+ }
2144
+ const metrics = await page.evaluate("window.__frogoeArtMetrics");
2145
+ if (scene.draw === "drawPoster") {
2146
+ const report = await page.evaluate("window.__frogoeArtReport");
2147
+ const verdict = verifyTitleReadability(report, scene.width);
2148
+ if (verdict !== null) throw new Error(verdict);
2149
+ const band = await page.evaluate("window.__frogoeTitleBand ?? null");
2150
+ if (band !== null) {
2151
+ const dpr = scene.width / 540;
2152
+ const collision = runtimeFunctions().findTitleZoneCollision(
2153
+ await page.evaluate(
2154
+ `(() => { const c = document.getElementById("__frogoe_raster"); return c.getContext("2d").getImageData(0, 0, c.width, c.height).data; })()`
2155
+ ),
2156
+ scene.width,
2157
+ scene.height,
2158
+ [band[0] * dpr, band[1] * dpr, band[2] * dpr, band[3] * dpr]
2159
+ );
2160
+ if (collision !== null) warnings.push(collision);
2161
+ }
2162
+ if (metrics.deadRows / Math.max(1, metrics.rows) > 0.7) {
2163
+ warnings.push(
2164
+ `bundle/art-dead-bands \u2014 ${metrics.deadRows}/${metrics.rows} of the poster's upper-field rows are empty: compose a moment with density (storm column, flight line), see \`frogoe vision\` \u2014 frogoe-creative \u2192 references/art.md`
2165
+ );
2166
+ }
2167
+ } else {
2168
+ const report = await page.evaluate("window.__frogoeArtReport");
2169
+ const gameSource = readFileSync8(path10.join(dir, "game.js"), "utf-8");
2170
+ const verdict = verifyIconFullbleed(report, [...hexColorsOf(gameSource)]);
2171
+ if (verdict !== null) throw new Error(verdict);
2172
+ if (metrics.coverage < 0.08) {
2173
+ warnings.push(
2174
+ `bundle/art-icon-fill \u2014 the mark covers only ${(metrics.coverage * 100).toFixed(1)}% of the icon: let it nearly fill the plate (55-70%), see \`frogoe vision\` \u2014 frogoe-creative \u2192 references/art.md`
2175
+ );
2176
+ }
2177
+ }
2178
+ await sleep2(80);
2179
+ const canvas = await page.$("#__frogoe_raster");
2180
+ if (canvas === null) throw new Error(`bundle/art-crash \u2014 ${scene.source}: no canvas`);
2181
+ const outPath = path10.join(dir, scene.out);
2182
+ mkdirSync4(path10.dirname(outPath), { recursive: true });
2183
+ await canvas.screenshot({ omitBackground: true, path: outPath, type: "png" });
2184
+ files.push({ bytes: statSync2(outPath).size, file: scene.out });
2185
+ } finally {
2186
+ await page.close();
2187
+ }
2188
+ }
2189
+ return { files, warnings };
2190
+ } finally {
2191
+ await browser.close();
2192
+ server.stop();
2193
+ }
2194
+ };
2195
+ }
2196
+ });
2197
+
2198
+ // src/commands/bundle.ts
2199
+ var bundle_exports = {};
2200
+ __export(bundle_exports, {
2201
+ command: () => command2
2202
+ });
2203
+ import { defineCommand as defineCommand2 } from "citty";
2204
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
2205
+ import path11 from "path";
2206
+ var command2;
2207
+ var init_bundle2 = __esm({
2208
+ "src/commands/bundle.ts"() {
2209
+ "use strict";
2210
+ init_bundle();
2211
+ init_raster();
2212
+ command2 = defineCommand2({
2213
+ args: {
2214
+ dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2215
+ json: { type: "boolean", description: "machine-readable report" },
2216
+ out: { type: "string", description: "output path (default: dist/index.html)" }
2217
+ },
2218
+ async run({ args }) {
2219
+ const dir = args.dir ? String(args.dir) : process.cwd();
2220
+ const report = await bundle({ dir });
2221
+ const outPath = args.out ? path11.resolve(String(args.out)) : path11.join(dir, "dist", "index.html");
2222
+ mkdirSync5(path11.dirname(outPath), { recursive: true });
2223
+ writeFileSync3(outPath, report.artifact, "utf-8");
2224
+ const art = await rasterizeArt({ dir });
2225
+ for (const warning of [...report.warnings, ...art.warnings]) {
2226
+ console.log(` \u26A0 ${warning}`);
2227
+ }
2228
+ if (args.json) {
2229
+ console.log(
2230
+ JSON.stringify(
2231
+ {
2232
+ art: art.files,
2233
+ artifact: outPath,
2234
+ assets: report.assets,
2235
+ bytes: report.bytes,
2236
+ sha256: report.sha256,
2237
+ warnings: report.warnings
2238
+ },
2239
+ null,
2240
+ 2
2241
+ )
2242
+ );
2243
+ } else {
2244
+ console.log(` frogoe bundle \u2192 ${outPath}`);
2245
+ console.log(
2246
+ ` ${report.bytes} bytes \xB7 ${report.assets.length} dissolved asset(s) \xB7 sha256 ${report.sha256.slice(0, 12)}`
2247
+ );
2248
+ for (const asset of report.assets) {
2249
+ console.log(` ${asset.kind.padEnd(5)} ${asset.source}`);
2250
+ }
2251
+ for (const file of art.files) {
2252
+ console.log(` art ${file.file} (${file.bytes} bytes)`);
2253
+ }
2254
+ }
2255
+ },
2256
+ meta: { description: "dissolve externals into one self-contained HTML" }
2257
+ });
2258
+ }
2259
+ });
2260
+
2261
+ // src/check.ts
2262
+ var formatFindings;
2263
+ var init_check2 = __esm({
2264
+ "src/check.ts"() {
2265
+ "use strict";
2266
+ init_src();
2267
+ init_src();
2268
+ init_src();
2269
+ formatFindings = (result) => result.findings.map((f) => {
2270
+ const at = f.line !== void 0 ? `:${f.line}` : "";
2271
+ const head = `${f.severity === "error" ? "\u2716" : "\u26A0"} ${f.code} ${f.file}${at}`;
2272
+ return `${head}
2273
+ ${f.message}
2274
+ fix: ${f.fix}`;
2275
+ }).join("\n") || "clean \u2014 0 findings";
2276
+ }
2277
+ });
2278
+
2279
+ // src/live/sampler.ts
2280
+ var PROBE_SCRIPT, DOM_PROBE_SCRIPT, CANVAS_PAINTED_SCRIPT, CANVAS_HASH_SCRIPT, GAME_STATE_SCRIPT, FINISH_EVENTS_SCRIPT, FPS_MARK_SCRIPT, FPS_SINCE_SCRIPT, fpsSinceScript, HUD_MEASURE_SCRIPT, RETRY_PRESENCE_SCRIPT, INTERRUPT_AUDIO_SCRIPT, AUDIO_STATES_SCRIPT;
2281
+ var init_sampler = __esm({
2282
+ "src/live/sampler.ts"() {
2283
+ "use strict";
2284
+ PROBE_SCRIPT = `(() => {
2285
+ if (window.__frogoeProbe) return;
2286
+ const probe = { audioContexts: [], audioEverRan: false, finish: [], fps: [] };
2287
+ Object.defineProperty(window, "__frogoeProbe", { value: probe });
2288
+ document.addEventListener("frogoe:finish", (e) => {
2289
+ const d = e && e.detail;
2290
+ const score = d && typeof d.score === "number" ? d.score : null;
2291
+ probe.finish.push({ at: Math.round(performance.now()), score });
2292
+ });
2293
+ const wrap = (Native) => class extends Native {
2294
+ constructor(...args) {
2295
+ super(...args);
2296
+ probe.audioContexts.push(this);
2297
+ this.addEventListener("statechange", () => {
2298
+ if (this.state === "running") probe.audioEverRan = true;
2299
+ });
2300
+ }
2301
+ };
2302
+ const AC = window.AudioContext;
2303
+ if (AC && !AC.__frogoeHooked) {
2304
+ Object.defineProperty(wrap(AC), "__frogoeHooked", { value: true });
2305
+ window.AudioContext = wrap(AC);
2306
+ }
2307
+ if (window.webkitAudioContext) {
2308
+ window.webkitAudioContext = wrap(window.webkitAudioContext);
2309
+ }
2310
+ let count = 0;
2311
+ let secStart = performance.now();
2312
+ const tick = () => {
2313
+ count++;
2314
+ const now = performance.now();
2315
+ if (now - secStart >= 1000) {
2316
+ probe.fps.push(count);
2317
+ count = 0;
2318
+ secStart = now;
2319
+ }
2320
+ requestAnimationFrame(tick);
2321
+ };
2322
+ requestAnimationFrame(tick);
2323
+ })()`;
2324
+ DOM_PROBE_SCRIPT = `(() => {
2325
+ return {
2326
+ canvasPresent: Boolean(document.querySelector("#c")),
2327
+ hudPresent: Boolean(document.querySelector(".hud")),
2328
+ state: window.__frogoe?.state ?? "(missing)",
2329
+ };
2330
+ })()`;
2331
+ CANVAS_PAINTED_SCRIPT = `(() => {
2332
+ const c = document.querySelector("#c");
2333
+ if (!c) return false;
2334
+ const g = c.getContext("2d");
2335
+ if (!g) return false;
2336
+ const px = Math.floor(c.width * 0.25), py = Math.floor(c.height * 0.25);
2337
+ const pw = Math.floor(c.width * 0.5), ph = Math.floor(c.height * 0.5);
2338
+ const s = g.getImageData(px, py, pw, ph).data;
2339
+ for (let i = 3; i < s.length; i += 4) { if (s[i] !== 0) return true; }
2340
+ return false;
2341
+ })()`;
2342
+ CANVAS_HASH_SCRIPT = `(() => {
2343
+ const c = document.querySelector("#c");
2344
+ if (!c) return null;
2345
+ const g = c.getContext("2d");
2346
+ if (!g) return null;
2347
+ const cx = Math.floor(c.width * 0.25), cy = Math.floor(c.height * 0.25);
2348
+ const cw = Math.floor(c.width * 0.5), ch = Math.floor(c.height * 0.5);
2349
+ const d = g.getImageData(cx, cy, cw, ch).data;
2350
+ let hash = 0;
2351
+ for (let i = 0; i < d.length; i += 4) {
2352
+ hash = (hash * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
2353
+ }
2354
+ return hash;
2355
+ })()`;
2356
+ GAME_STATE_SCRIPT = `window.__frogoe?.state ?? "(missing)"`;
2357
+ FINISH_EVENTS_SCRIPT = `(() => {
2358
+ const p = window.__frogoeProbe;
2359
+ return p ? p.finish.slice() : [];
2360
+ })()`;
2361
+ FPS_MARK_SCRIPT = `window.__frogoeProbe ? window.__frogoeProbe.fps.length : 0`;
2362
+ FPS_SINCE_SCRIPT = `(() => {
2363
+ const p = window.__frogoeProbe;
2364
+ const mark = __MARK__;
2365
+ return p ? p.fps.slice(mark) : [];
2366
+ })()`;
2367
+ fpsSinceScript = (mark) => FPS_SINCE_SCRIPT.replaceAll("__MARK__", String(mark));
2368
+ HUD_MEASURE_SCRIPT = `(() => {
2369
+ const out = [];
2370
+ for (const el of document.querySelectorAll(".hud *")) {
2371
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
2372
+ if (!own) continue;
2373
+ const text = el.textContent ?? "";
2374
+ const s = getComputedStyle(el);
2375
+ const hasOutline =
2376
+ (s.webkitTextStroke && s.webkitTextStrokeWidth !== "0px") ||
2377
+ (s.textShadow && s.textShadow !== "none");
2378
+ const r = el.getBoundingClientRect();
2379
+ out.push({ hasOutline, height: r.height, label: text.trim().slice(0, 24), width: r.width });
2380
+ }
2381
+ return out;
2382
+ })()`;
2383
+ RETRY_PRESENCE_SCRIPT = `(() => ({
2384
+ gameover: Boolean(document.querySelector("[data-block-gameover]")),
2385
+ retry: Boolean(document.querySelector("[data-block-retry]")),
2386
+ }))()`;
2387
+ INTERRUPT_AUDIO_SCRIPT = `(() => {
2388
+ const p = window.__frogoeProbe;
2389
+ if (!p) return false;
2390
+ for (const c of p.audioContexts) {
2391
+ try { void c.suspend(); } catch (e) {}
2392
+ }
2393
+ return true;
2394
+ })()`;
2395
+ AUDIO_STATES_SCRIPT = `(() => {
2396
+ const p = window.__frogoeProbe;
2397
+ if (!p) return { count: 0, everRan: false, running: 0 };
2398
+ let running = 0;
2399
+ for (const c of p.audioContexts) {
2400
+ if (c.state === "running") running++;
2401
+ }
2402
+ return { count: p.audioContexts.length, everRan: p.audioEverRan === true, running };
2403
+ })()`;
2404
+ }
2405
+ });
2406
+
2407
+ // src/live/driver.ts
2408
+ var createPuppeteerDriver;
2409
+ var init_driver = __esm({
2410
+ "src/live/driver.ts"() {
2411
+ "use strict";
2412
+ init_sampler();
2413
+ createPuppeteerDriver = ({ page, size }) => {
2414
+ const pageErrors = [];
2415
+ const consoleErrors = [];
2416
+ page.on("pageerror", (error) => {
2417
+ pageErrors.push(String(error));
2418
+ });
2419
+ page.on("console", (message2) => {
2420
+ if (message2.type() === "error") {
2421
+ consoleErrors.push(message2.text());
2422
+ }
2423
+ });
2424
+ page.evaluateOnNewDocument(PROBE_SCRIPT);
2425
+ const read2 = async (script) => await page.evaluate(script);
2426
+ return {
2427
+ errors: () => pageErrors,
2428
+ consoleErrors: () => consoleErrors,
2429
+ async audioStates() {
2430
+ return await read2(AUDIO_STATES_SCRIPT);
2431
+ },
2432
+ async interruptAudio() {
2433
+ await read2(INTERRUPT_AUDIO_SCRIPT);
2434
+ },
2435
+ async domProbe() {
2436
+ return await read2(DOM_PROBE_SCRIPT);
2437
+ },
2438
+ async canvasPainted() {
2439
+ return await read2(CANVAS_PAINTED_SCRIPT);
2440
+ },
2441
+ async canvasHash() {
2442
+ return await read2(CANVAS_HASH_SCRIPT);
2443
+ },
2444
+ async gameState() {
2445
+ return await read2(GAME_STATE_SCRIPT);
2446
+ },
2447
+ async finishEvents() {
2448
+ return await read2(FINISH_EVENTS_SCRIPT);
2449
+ },
2450
+ async fpsMark() {
2451
+ return await read2(FPS_MARK_SCRIPT);
2452
+ },
2453
+ async fpsSince(mark) {
2454
+ return await read2(fpsSinceScript(mark));
2455
+ },
2456
+ async hudMeasures() {
2457
+ return await read2(HUD_MEASURE_SCRIPT);
2458
+ },
2459
+ async retryPresence() {
2460
+ return await read2(RETRY_PRESENCE_SCRIPT);
2461
+ },
2462
+ async tap(x, y) {
2463
+ await page.mouse.click(x, y);
2464
+ },
2465
+ async hold(x, y, ms) {
2466
+ await page.mouse.move(x, y);
2467
+ await page.mouse.down();
2468
+ await new Promise((resolve2) => {
2469
+ setTimeout(resolve2, ms);
2470
+ });
2471
+ await page.mouse.up();
2472
+ },
2473
+ async drag(x1, y1, x2, y2) {
2474
+ await page.mouse.move(x1, y1);
2475
+ await page.mouse.down();
2476
+ await page.mouse.move(x2, y2, { steps: 6 });
2477
+ await new Promise((resolve2) => {
2478
+ setTimeout(resolve2, 80);
2479
+ });
2480
+ await page.mouse.up();
2481
+ },
2482
+ async clickRetryAwaitReload(timeoutMs) {
2483
+ const interactable = `(() => {
2484
+ const b = document.querySelector("[data-block-retry]");
2485
+ if (!b) return false;
2486
+ const s = getComputedStyle(b);
2487
+ if (s.pointerEvents === "none" || s.visibility === "hidden" || s.display === "none") {
2488
+ return false;
1945
2489
  }
1946
- }
1947
- let ends = false;
1948
- let retryReloads = 0;
1949
- if (!sawOver) {
1950
- sawOver = await waitForOver(driver, doSleep);
1951
- }
1952
- if (!sawOver) {
1953
- findings.push(neverEndsFinding(END_BUDGET_MS));
1954
- } else {
1955
- ends = true;
1956
- let canRetry = await verifyDeath(driver, ctx, findings, 0);
1957
- for (let cycle = 0; cycle < STABILITY_CYCLES && canRetry; cycle++) {
1958
- const reloaded = await driver.clickRetryAwaitReload(RETRY_NAV_MS);
1959
- if (!reloaded) {
1960
- findings.push(retryDeadFinding());
1961
- break;
1962
- }
1963
- retryReloads += 1;
1964
- await doSleep(settle);
1965
- const rebootState = await driver.gameState();
1966
- if (rebootState === "over") {
1967
- findings.push(earlyDeathFinding(name, "retry"));
1968
- } else {
1969
- const reboot = rebootFinding(rebootState);
1970
- if (reboot) {
1971
- findings.push(reboot);
1972
- }
1973
- }
1974
- if (!await driver.canvasPainted()) {
1975
- findings.push(canvasUnpaintedFinding(name, "retry"));
1976
- }
1977
- await ctx.shot?.(retryShotName(cycle));
1978
- if (cycle < STABILITY_CYCLES - 1) {
1979
- await runStartBurst(driver, ctx);
1980
- const overAgain = await waitForOver(driver, doSleep);
1981
- if (!overAgain) {
1982
- findings.push(neverEndsFinding(END_BUDGET_MS));
2490
+ const r = b.getBoundingClientRect();
2491
+ if (r.width < 4 || r.height < 4) return false;
2492
+ const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
2493
+ return Boolean(hit && (hit === b || b.contains(hit)));
2494
+ })()`;
2495
+ const grace = 3e3;
2496
+ const started = Date.now();
2497
+ for (; ; ) {
2498
+ if (await page.evaluate(interactable) === true) {
1983
2499
  break;
1984
2500
  }
1985
- canRetry = await verifyDeath(driver, ctx, findings, cycle + 1);
2501
+ if (Date.now() - started > grace) {
2502
+ return false;
2503
+ }
2504
+ await new Promise((resolve2) => {
2505
+ setTimeout(resolve2, 100);
2506
+ });
1986
2507
  }
1987
- }
1988
- }
1989
- const throttleMark = await driver.fpsMark();
1990
- await driver.setCpuThrottling(THROTTLE_RATE);
1991
- await runStartBurst(driver, ctx);
1992
- for (let step = 0; step < PLAY_STEPS; step++) {
1993
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
1994
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
1995
- if (step === HOLD_STEP_INDEX) {
1996
- await driver.hold(x, y, HOLD_MS);
1997
- } else if (step === DRAG_STEP_INDEX) {
1998
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
1999
- } else {
2000
- await driver.tap(x, y);
2001
- }
2002
- await doSleep(PLAY_STEP_MS);
2003
- }
2004
- const throttledBuckets = await driver.fpsSince(throttleMark);
2005
- await driver.setCpuThrottling(1);
2006
- const throttled = fpsThrottledFinding(throttledBuckets, name);
2007
- if (throttled) {
2008
- findings.push(throttled);
2009
- }
2010
- return {
2011
- findings,
2012
- lifecycle: { ends, retryReloads },
2013
- mobileFps: mean !== void 0 ? Math.round(mean) : void 0,
2014
- playability
2508
+ const navigated = page.waitForNavigation({ timeout: timeoutMs, waitUntil: "domcontentloaded" }).then(() => true).catch(() => false);
2509
+ const button = await page.$("[data-block-retry]");
2510
+ if (!button) {
2511
+ return false;
2512
+ }
2513
+ await button.click();
2514
+ return await navigated;
2515
+ },
2516
+ async setCpuThrottling(rate) {
2517
+ await page.emulateCPUThrottling(rate === 1 ? null : rate);
2518
+ },
2519
+ async screenshot() {
2520
+ return await page.screenshot({ encoding: "binary" });
2521
+ },
2522
+ viewport: () => size
2015
2523
  };
2016
2524
  };
2017
2525
  }
2018
2526
  });
2019
2527
 
2020
- // src/net/ip.ts
2021
- import { execSync } from "child_process";
2022
- import os from "os";
2023
- var VIRTUAL, isPrivateV4, isLoopback, selfAddresses, pickLanIp, interfaceFromRouteOutput, ROUTE_PROBES, routedInterfaceName, resolveLan;
2024
- var init_ip = __esm({
2025
- "src/net/ip.ts"() {
2528
+ // src/live/types.ts
2529
+ var finding;
2530
+ var init_types = __esm({
2531
+ "src/live/types.ts"() {
2026
2532
  "use strict";
2027
- VIRTUAL = /^(?:utun|awdl|llw|bridge|vboxnet|veth|docker|zd|zt|tailscale|tap|tun|anpi|ipsec|gif|stf|vethernet|virtualbox|vmware|vmnet|wsl|loopback|bluetooth)/u;
2028
- isPrivateV4 = (ip) => {
2029
- const octets = ip.split(".").map(Number);
2030
- if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
2031
- return false;
2533
+ finding = (shape) => shape;
2534
+ }
2535
+ });
2536
+
2537
+ // src/live/decisions.ts
2538
+ var FPS_FLOOR2, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
2539
+ var init_decisions = __esm({
2540
+ "src/live/decisions.ts"() {
2541
+ "use strict";
2542
+ init_types();
2543
+ FPS_FLOOR2 = 30;
2544
+ FPS_SUSTAINED_WINDOW = 3;
2545
+ FROZEN_STREAK = 3;
2546
+ outlineFinding = (measures) => {
2547
+ const bare = measures.filter((m) => !m.hasOutline);
2548
+ if (bare.length === 0 || !bare[0]) {
2549
+ return null;
2032
2550
  }
2033
- const [a = -1, b = -1] = octets;
2034
- if (a === 10) return true;
2035
- if (a === 172 && b >= 16 && b <= 31) return true;
2036
- if (a === 192 && b === 168) return true;
2037
- return false;
2551
+ return finding({
2552
+ code: "live/hud-outline",
2553
+ file: "index.html",
2554
+ fix: `"${bare[0].label}" has no text-shadow or -webkit-text-stroke \u2014 game HUD text needs an outline to read on ANY background (registry blocks ship one; custom HUD must add it)`,
2555
+ message: `${bare.length} HUD text element(s) have no outline`,
2556
+ phase: "boot",
2557
+ recipe: "frogoe-registry \u2192 block authoring (sticker depth pattern)",
2558
+ severity: "error"
2559
+ });
2038
2560
  };
2039
- isLoopback = (addr) => {
2040
- if (!addr) return false;
2041
- return addr === "::1" || addr.startsWith("127.") || addr.startsWith("::ffff:127.");
2561
+ collapseFinding = (measures) => {
2562
+ const collapsed = measures.filter((m) => m.width < 4 || m.height < 4);
2563
+ if (collapsed.length === 0 || !collapsed[0]) {
2564
+ return null;
2565
+ }
2566
+ const first = collapsed[0];
2567
+ return finding({
2568
+ code: "live/layout-collapse",
2569
+ file: "index.html",
2570
+ fix: `"${first.label}" has content but renders ${Math.round(first.width)}\xD7${Math.round(first.height)} \u2014 an element collapsed to zero (missing display, zero-size font, or an ancestor hiding it)`,
2571
+ message: `${collapsed.length} HUD element(s) collapsed to zero size`,
2572
+ phase: "boot",
2573
+ severity: "error"
2574
+ });
2042
2575
  };
2043
- selfAddresses = (interfaces) => {
2044
- const own = /* @__PURE__ */ new Set();
2045
- for (const entries of Object.values(interfaces)) {
2046
- for (const entry of entries ?? []) {
2047
- if (entry.family === "IPv4") {
2048
- own.add(entry.address);
2049
- own.add(`::ffff:${entry.address}`);
2050
- } else if (entry.family === "IPv6") {
2051
- own.add(entry.address);
2052
- }
2053
- }
2576
+ pageErrorFinding = (errors, viewport) => {
2577
+ const first = errors[0];
2578
+ if (first === void 0) {
2579
+ return null;
2054
2580
  }
2055
- return own;
2581
+ return finding({
2582
+ code: "live/page-error",
2583
+ file: "game.js",
2584
+ fix: `uncaught: ${first.slice(0, 140)}`,
2585
+ message: `${errors.length} uncaught page error(s) [${viewport}]`,
2586
+ phase: "boot",
2587
+ severity: "error"
2588
+ });
2589
+ };
2590
+ consoleErrorFinding = (entries, pageErrors, viewport) => {
2591
+ const unique = entries.filter((entry) => !pageErrors.some((e) => e.includes(entry.slice(0, 60))));
2592
+ const first = unique[0];
2593
+ if (first === void 0) {
2594
+ return null;
2595
+ }
2596
+ return finding({
2597
+ code: "live/console-error",
2598
+ file: "game.js",
2599
+ fix: `console.error: ${first.slice(0, 140)} \u2014 recoverable failures should be handled, not logged`,
2600
+ message: `${unique.length} console.error(s) [${viewport}]`,
2601
+ phase: "boot",
2602
+ severity: "warning"
2603
+ });
2604
+ };
2605
+ canvasMissingFinding = (viewport) => finding({
2606
+ code: "live/canvas-missing",
2607
+ file: "index.html",
2608
+ fix: 'the contract boots on <canvas id="c">',
2609
+ message: `canvas missing [${viewport}]`,
2610
+ phase: "boot",
2611
+ severity: "error"
2612
+ });
2613
+ canvasUnpaintedFinding = (viewport, phase = "boot") => finding({
2614
+ code: "live/canvas-unpainted",
2615
+ file: "game.js",
2616
+ fix: "loop.render never drew \u2014 fill loop.render = (ctx) => {...}",
2617
+ message: `canvas stayed blank [${viewport}]`,
2618
+ phase,
2619
+ severity: "error"
2620
+ });
2621
+ contractMissingFinding = (viewport) => finding({
2622
+ code: "live/contract-missing",
2623
+ file: "index.html",
2624
+ fix: 'import { defineGame } from "frogoe" \u2014 the runtime publishes window.__frogoe at boot',
2625
+ message: `window.__frogoe absent [${viewport}]`,
2626
+ phase: "boot",
2627
+ severity: "error"
2628
+ });
2629
+ stateStuckFinding = (state, viewport, phase = "boot") => {
2630
+ if (state !== "loading") {
2631
+ return null;
2632
+ }
2633
+ return finding({
2634
+ code: "live/state-stuck",
2635
+ file: "game.js",
2636
+ fix: 'state never left "loading" \u2014 defineGame() threw before start() or start() was never called',
2637
+ message: `state stuck in "loading" [${viewport}]`,
2638
+ phase,
2639
+ severity: "error"
2640
+ });
2641
+ };
2642
+ earlyDeathFinding = (viewport, phase = "boot") => finding({
2643
+ code: "live/early-death",
2644
+ file: "game.js",
2645
+ fix: 'state reached "over" before any input \u2014 the game kills itself on the ready screen; gate death (and the physics that cause it) behind the first input.on("down")',
2646
+ message: `game ended before the first input [${viewport}]`,
2647
+ phase,
2648
+ severity: "error"
2649
+ });
2650
+ fpsFinding = (fps, viewport) => {
2651
+ if (fps === void 0 || fps >= FPS_FLOOR2) {
2652
+ return null;
2653
+ }
2654
+ return finding({
2655
+ code: "live/fps",
2656
+ file: "game.js",
2657
+ fix: `${fps.toFixed(0)} fps on ${viewport} (floor ${FPS_FLOOR2}) \u2014 heavy per-frame work: cache gradients, cut particle counts, avoid shadowBlur on big shapes`,
2658
+ message: `frame rate below the playability floor [${viewport}]`,
2659
+ phase: "play",
2660
+ recipe: "frogoe-creative \u2192 game-feel (motion rules)",
2661
+ severity: "warning"
2662
+ });
2056
2663
  };
2057
- pickLanIp = (interfaces, routedName) => {
2058
- const physical = [];
2059
- const virtual = [];
2060
- for (const [name, entries] of Object.entries(interfaces)) {
2061
- for (const entry of entries ?? []) {
2062
- if (entry.family !== "IPv4" || entry.internal || !isPrivateV4(entry.address)) continue;
2063
- const item = { ip: entry.address, name };
2064
- if (VIRTUAL.test(name.toLowerCase())) virtual.push(item);
2065
- else physical.push(item);
2066
- }
2664
+ fpsSustainedFinding = (buckets, viewport) => {
2665
+ if (buckets.length < FPS_SUSTAINED_WINDOW) {
2666
+ return null;
2067
2667
  }
2068
- physical.sort((a, b) => a.name.localeCompare(b.name));
2069
- virtual.sort((a, b) => a.name.localeCompare(b.name));
2070
- if (routedName) {
2071
- const routed = physical.find((p) => p.name === routedName) ?? virtual.find((v) => v.name === routedName);
2072
- if (routed) {
2073
- return {
2074
- candidates: [routed.ip],
2075
- confidence: "routed",
2076
- ip: routed.ip,
2077
- virtual: !physical.includes(routed)
2078
- };
2668
+ let worst = Number.POSITIVE_INFINITY;
2669
+ for (let i = 0; i + FPS_SUSTAINED_WINDOW <= buckets.length; i++) {
2670
+ const window = buckets.slice(i, i + FPS_SUSTAINED_WINDOW);
2671
+ const avg = window.reduce((a, b) => a + b, 0) / window.length;
2672
+ if (avg < worst) {
2673
+ worst = avg;
2079
2674
  }
2080
2675
  }
2081
- const chosen = physical[0] ?? virtual[0];
2082
- if (!chosen) {
2083
- return { candidates: [], confidence: "none", virtual: false };
2676
+ if (worst >= FPS_FLOOR2) {
2677
+ return null;
2084
2678
  }
2679
+ const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
2085
2680
  return {
2086
- candidates: physical.length > 0 ? physical.map((p) => p.ip) : virtual.map((v) => v.ip),
2087
- confidence: "heuristic",
2088
- ip: chosen.ip,
2089
- virtual: !physical.includes(chosen)
2681
+ finding: finding({
2682
+ code: "live/fps-sustained",
2683
+ file: "game.js",
2684
+ fix: `${worst.toFixed(0)} fps held for ${FPS_SUSTAINED_WINDOW}s+ on ${viewport} (floor ${FPS_FLOOR2}) \u2014 the game is consistently slow, not jittering: cut per-frame allocations, shrink offscreen canvases, reduce draw calls`,
2685
+ message: `sustained low frame rate [${viewport}]`,
2686
+ phase: "play",
2687
+ recipe: "frogoe-creative \u2192 game-feel (motion rules)",
2688
+ severity: "error"
2689
+ }),
2690
+ mean
2090
2691
  };
2091
2692
  };
2092
- interfaceFromRouteOutput = (output, style) => {
2093
- if (style === "bsd") return /interface:\s*(\S+)/u.exec(output)?.[1];
2094
- if (style === "linux") return /dev\s+(\S+)/u.exec(output)?.[1];
2095
- return output.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0);
2693
+ frozenFrameFinding = (streak) => {
2694
+ if (streak < FROZEN_STREAK) {
2695
+ return null;
2696
+ }
2697
+ return finding({
2698
+ code: "live/frozen-frame",
2699
+ file: "game.js",
2700
+ fix: `canvas held the same frame for ${streak} samples while playing \u2014 loop.render may have stopped or draws a static scene`,
2701
+ message: "canvas froze during play",
2702
+ phase: "play",
2703
+ severity: "warning"
2704
+ });
2096
2705
  };
2097
- ROUTE_PROBES = [
2098
- { cmd: "route -n get default", style: "bsd" },
2099
- // darwin/bsd; usage-errors elsewhere
2100
- { cmd: "ip route show default", style: "linux" },
2101
- // linux; absent on darwin/win
2102
- {
2103
- cmd: `powershell -NoProfile -Command "(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1).InterfaceAlias"`,
2104
- style: "windows"
2706
+ pausedFinding = () => finding({
2707
+ code: "live/paused",
2708
+ file: "game.js",
2709
+ fix: 'state read "paused" during scripted play \u2014 pause() ran without a matching resume(); check document.hidden handling and blur listeners',
2710
+ message: "game paused itself during play",
2711
+ phase: "play",
2712
+ severity: "warning"
2713
+ });
2714
+ stateCorruptFinding = (state) => finding({
2715
+ code: "live/state-corrupt",
2716
+ file: "game.js",
2717
+ fix: `state read "${state}" \u2014 outside the contract's set (loading/playing/paused/over); game code is mutating window.__frogoe directly`,
2718
+ message: `state left the contract's state machine ("${state}")`,
2719
+ phase: "play",
2720
+ severity: "error"
2721
+ });
2722
+ playabilityFinding = (result) => {
2723
+ if (result === "pass") {
2724
+ return null;
2105
2725
  }
2106
- ];
2107
- routedInterfaceName = () => {
2108
- for (const probe of ROUTE_PROBES) {
2109
- try {
2110
- const out = execSync(probe.cmd, {
2111
- encoding: "utf-8",
2112
- stdio: ["ignore", "pipe", "ignore"],
2113
- timeout: 3e3
2114
- });
2115
- const name = interfaceFromRouteOutput(out, probe.style);
2116
- if (name) return name;
2117
- } catch {
2118
- }
2726
+ if (result === "no-input") {
2727
+ return finding({
2728
+ code: "live/no-input",
2729
+ file: "game.js",
2730
+ fix: 'the game never registered input.on("down", ...) \u2014 wire the core verb before shipping',
2731
+ message: "game has no input handler",
2732
+ phase: "play",
2733
+ severity: "error"
2734
+ });
2119
2735
  }
2120
- return null;
2736
+ return finding({
2737
+ code: "live/not-playable",
2738
+ file: "game.js",
2739
+ fix: "scripted taps produced no canvas change \u2014 loop.update/loop.render may be wired but the game logic never runs",
2740
+ message: "game did not respond to scripted input",
2741
+ phase: "play",
2742
+ severity: "error"
2743
+ });
2744
+ };
2745
+ finishEventFinding = (stateOver, finishCount) => {
2746
+ if (stateOver === finishCount > 0) {
2747
+ return null;
2748
+ }
2749
+ return finding({
2750
+ code: "live/finish-event-missing",
2751
+ file: "game.js",
2752
+ fix: stateOver ? 'state reached "over" but frogoe:finish never fired \u2014 call finish(score) on death, never write __frogoe.state directly' : 'frogoe:finish fired but state is not "over" \u2014 the event was dispatched outside finish()',
2753
+ message: "state/event mismatch at game over",
2754
+ phase: "end",
2755
+ severity: "error"
2756
+ });
2757
+ };
2758
+ neverEndsFinding = (budgetMs) => finding({
2759
+ code: "live/never-ends",
2760
+ file: "game.js",
2761
+ fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 fine for endless/sandbox games, but feed games are short replayable loops; most deaths should arrive in seconds`,
2762
+ message: "game never reached the over state",
2763
+ phase: "end",
2764
+ severity: "warning"
2765
+ });
2766
+ audioLockedFinding = (audio) => {
2767
+ if (audio.count === 0 || audio.running > 0) {
2768
+ return null;
2769
+ }
2770
+ return finding({
2771
+ code: "live/audio-locked",
2772
+ file: "game.js",
2773
+ fix: 'audio contexts exist but stayed suspended after an interruption + real input \u2014 resume inside a user gesture: Sfx.init() in input.on("down") AND ("up"), plus the 1-sample silent-buffer unlock (frogoe-core \u2192 references/audio.md)',
2774
+ message: "audio never recovers \u2014 every phone interruption plays this game silent",
2775
+ phase: "play",
2776
+ recipe: "frogoe-core \u2192 references/audio.md",
2777
+ severity: "error"
2778
+ });
2779
+ };
2780
+ noGameoverCardFinding = () => finding({
2781
+ code: "live/no-gameover-card",
2782
+ file: "index.html",
2783
+ fix: "no [data-block-gameover] overlay when the game ended \u2014 install the game-over-card block so death has a screen",
2784
+ message: "game over has no overlay",
2785
+ phase: "end",
2786
+ recipe: "frogoe-registry \u2192 game-over-card",
2787
+ severity: "warning"
2788
+ });
2789
+ THROTTLE_RATE = 4;
2790
+ THROTTLED_FPS_FLOOR = 15;
2791
+ fpsThrottledFinding = (buckets, viewport) => {
2792
+ if (buckets.length === 0) return null;
2793
+ const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
2794
+ if (mean >= THROTTLED_FPS_FLOOR) return null;
2795
+ return finding({
2796
+ code: "live/fps-throttled",
2797
+ file: "game.js",
2798
+ fix: `fps ${mean.toFixed(0)} under ${THROTTLE_RATE}x cpu throttle (${viewport}) \u2014 mid-range phones will feel this: cache gradients/patterns, cut per-frame allocations, shrink offscreen canvases, cap particles`,
2799
+ message: "cpu-bound collapse under phone-class throttle",
2800
+ phase: "play",
2801
+ severity: "warning"
2802
+ });
2803
+ };
2804
+ noRetryFinding = () => finding({
2805
+ code: "live/no-retry",
2806
+ file: "index.html",
2807
+ fix: "no [data-block-retry] button anywhere \u2014 the player is hard-stuck after death; the retry affordance is the loop's exit",
2808
+ message: "no retry affordance after game over",
2809
+ phase: "end",
2810
+ recipe: "frogoe-registry \u2192 game-over-card",
2811
+ severity: "error"
2812
+ });
2813
+ retryDeadFinding = () => finding({
2814
+ code: "live/retry-dead",
2815
+ file: "game.js",
2816
+ fix: 'clicking retry produced no reload \u2014 wire it: retry.addEventListener("click", () => location.reload())',
2817
+ message: "retry button did not reload the page",
2818
+ phase: "retry",
2819
+ severity: "error"
2820
+ });
2821
+ rebootFinding = (state) => {
2822
+ if (state === "playing") {
2823
+ return null;
2824
+ }
2825
+ return finding({
2826
+ code: "live/state-stuck",
2827
+ file: "game.js",
2828
+ fix: `after retry reloaded the page the state read "${state}" \u2014 the second boot is not healthy (check localStorage parsing and one-time init paths)`,
2829
+ message: `retry boot stuck in "${state}"`,
2830
+ phase: "retry",
2831
+ severity: "error"
2832
+ });
2121
2833
  };
2122
- resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
2123
2834
  }
2124
2835
  });
2125
2836
 
2126
- // src/telemetry/records.ts
2127
- var FPS_FLOOR2, formatClock, dipSpans, beaconToRecords, summarizeRecords;
2128
- var init_records = __esm({
2129
- "src/telemetry/records.ts"() {
2837
+ // src/live/phases.ts
2838
+ var END_BUDGET_MS, RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep3, jitterX, jitterY, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
2839
+ var init_phases = __esm({
2840
+ "src/live/phases.ts"() {
2130
2841
  "use strict";
2131
- FPS_FLOOR2 = 30;
2132
- formatClock = (wall) => {
2133
- const d = new Date(wall);
2134
- const p = (n) => String(n).padStart(2, "0");
2135
- return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
2136
- };
2137
- dipSpans = (buckets) => {
2138
- const spans = [];
2139
- let start = -1;
2140
- let worst = 0;
2141
- for (let i = 0; i <= buckets.length; i++) {
2142
- const low = i < buckets.length && (buckets[i] ?? 0) > 0 && (buckets[i] ?? 0) < FPS_FLOOR2;
2143
- if (low && start === -1) {
2144
- start = i;
2145
- worst = buckets[i] ?? 0;
2146
- } else if (low) {
2147
- worst = Math.min(worst, buckets[i] ?? 0);
2148
- } else if (start !== -1) {
2149
- spans.push({ fps: worst, len: i - start, start });
2150
- start = -1;
2151
- }
2842
+ init_decisions();
2843
+ END_BUDGET_MS = 45e3;
2844
+ RETRY_NAV_MS = 8e3;
2845
+ PLAY_STEPS = 7;
2846
+ PLAY_STEP_MS = 850;
2847
+ HOLD_STEP_INDEX = 3;
2848
+ DRAG_STEP_INDEX = 5;
2849
+ DRAG_SPAN = 90;
2850
+ HOLD_MS = 400;
2851
+ POLL_MS = 400;
2852
+ GRACE_MS = 150;
2853
+ STABILITY_CYCLES = 2;
2854
+ START_BURST_TAPS = 3;
2855
+ DESKTOP_FPS_MS = 2e3;
2856
+ sleep3 = (ms) => new Promise((resolve2) => {
2857
+ setTimeout(resolve2, ms);
2858
+ });
2859
+ jitterX = (step) => step * 37 % 121 - 60;
2860
+ jitterY = (step) => step * 53 % 181 - 90;
2861
+ hasError = (findings) => findings.some((f) => f.severity === "error");
2862
+ runBootChecks = async (driver, ctx) => {
2863
+ const findings = [];
2864
+ const name = ctx.viewport.name;
2865
+ const pageErr = pageErrorFinding(driver.errors(), name);
2866
+ if (pageErr) {
2867
+ findings.push(pageErr);
2152
2868
  }
2153
- return spans;
2154
- };
2155
- beaconToRecords = (payload, arrivalWall) => {
2156
- const out = [];
2157
- const arrivalUp = payload.up;
2158
- const wallAt = (up) => arrivalWall - Math.max(0, arrivalUp - up) * 1e3;
2159
- const buckets = payload.fps ?? [];
2160
- const spans = dipSpans(buckets);
2161
- const spanEnd = new Map(spans.map((s) => [s.start + s.len - 1, s]));
2162
- for (let i = 0; i < buckets.length; i++) {
2163
- const fps = buckets[i] ?? 0;
2164
- const up = Math.max(0, arrivalUp - (buckets.length - 1 - i));
2165
- const wall = wallAt(up);
2166
- const time = formatClock(wall);
2167
- out.push({
2168
- record: { fps, time, type: "fps", up, wall },
2169
- text: ""
2170
- });
2171
- const span = spanEnd.get(i);
2172
- if (span) {
2173
- out.push({
2174
- record: { fps: span.fps, time, type: "fps", up, wall },
2175
- text: `\u26A0 ${time} fps ${span.fps} \u2014 dip ${span.len}s`
2176
- });
2177
- }
2869
+ const conErr = consoleErrorFinding(driver.consoleErrors(), driver.errors(), name);
2870
+ if (conErr) {
2871
+ findings.push(conErr);
2178
2872
  }
2179
- for (const event of payload.events ?? []) {
2180
- const wall = wallAt(event.up);
2181
- const time = formatClock(wall);
2182
- const type = event.type === "error" || event.type === "rejection" || event.type === "hidden" || event.type === "visible" ? event.type : "error";
2183
- const msg = (event.msg ?? "").slice(0, 160);
2184
- const text = type === "hidden" ? `\xB7 ${time} phone hidden` : type === "visible" ? `\xB7 ${time} phone visible` : `\u2716 ${time} ${type === "rejection" ? "unhandled rejection" : "page error"}: ${msg}`;
2185
- out.push({ record: { msg, time, type, up: event.up, wall }, text });
2873
+ const probe = await driver.domProbe();
2874
+ if (!probe.canvasPresent) {
2875
+ findings.push(canvasMissingFinding(name));
2876
+ } else if (!await driver.canvasPainted()) {
2877
+ findings.push(canvasUnpaintedFinding(name));
2186
2878
  }
2187
- return out;
2188
- };
2189
- summarizeRecords = (records) => {
2190
- const fps = records.filter((r) => r.type === "fps" && typeof r.fps === "number");
2191
- const buckets = fps.map((r) => r.fps ?? 0).filter((n) => n > 0);
2192
- const spans = dipSpans(buckets);
2193
- const orderKey = (r) => typeof r.wall === "number" ? r.wall : r.up * 1e3;
2194
- let hiddenS = 0;
2195
- let pendingHidden;
2196
- for (const r of records) {
2197
- if (pendingHidden !== void 0) {
2198
- hiddenS += Math.max(0, orderKey(r) - pendingHidden) / 1e3;
2199
- pendingHidden = void 0;
2200
- }
2201
- if (r.type === "hidden") {
2202
- pendingHidden = orderKey(r);
2879
+ if (probe.state === "(missing)") {
2880
+ findings.push(contractMissingFinding(name));
2881
+ } else if (probe.state === "over") {
2882
+ findings.push(earlyDeathFinding(name));
2883
+ } else {
2884
+ const stuck = stateStuckFinding(probe.state, name);
2885
+ if (stuck) {
2886
+ findings.push(stuck);
2203
2887
  }
2204
2888
  }
2205
- const worstSpan = spans.reduce(
2206
- (acc, s) => acc === void 0 || s.len > acc.len ? s : acc,
2207
- void 0
2208
- );
2209
- const worst = worstSpan ? {
2210
- fps: worstSpan.fps,
2211
- len: worstSpan.len,
2212
- time: fps[worstSpan.start + worstSpan.len - 1]?.time ?? "?"
2213
- } : void 0;
2214
- let pageLoads = records.length > 0 ? 1 : 0;
2215
- let prevUp = -1;
2216
- for (const r of records) {
2217
- if (prevUp !== -1 && r.up + 2 < prevUp) pageLoads += 1;
2218
- prevUp = r.up;
2889
+ if (probe.hudPresent) {
2890
+ const measures = await driver.hudMeasures();
2891
+ const outline = outlineFinding(measures);
2892
+ if (outline) {
2893
+ findings.push(outline);
2894
+ }
2895
+ const collapse = collapseFinding(measures);
2896
+ if (collapse) {
2897
+ findings.push(collapse);
2898
+ }
2219
2899
  }
2220
- const first = records[0];
2221
- const last = records[records.length - 1];
2222
- const durationS = first && last && typeof first.wall === "number" && typeof last.wall === "number" ? (
2223
- // +1s: the first bucket already covers one second of play
2224
- Math.round((last.wall - first.wall + 1e3) / 1e3)
2225
- ) : Math.round(last?.up ?? 0);
2226
- return {
2227
- buckets: buckets.length,
2228
- dips: spans.length,
2229
- durationS,
2230
- errors: records.filter((r) => r.type === "error" || r.type === "rejection").length,
2231
- hiddenS: Math.round(hiddenS),
2232
- meanFps: buckets.length > 0 ? Math.round(buckets.reduce((a, b) => a + b, 0) / buckets.length) : void 0,
2233
- pageLoads,
2234
- worst
2235
- };
2900
+ return findings;
2236
2901
  };
2237
- }
2238
- });
2239
-
2240
- // src/telemetry/session.ts
2241
- import { appendFileSync, mkdirSync as mkdirSync4, readdirSync as readdirSync2 } from "fs";
2242
- import path6 from "path";
2243
- var pad, sessionStamp, createSessionStore, latestSessionFile;
2244
- var init_session = __esm({
2245
- "src/telemetry/session.ts"() {
2246
- "use strict";
2247
- pad = (n) => String(n).padStart(2, "0");
2248
- sessionStamp = (wall) => {
2249
- const d = new Date(wall);
2250
- return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
2902
+ runDesktopPass = async (driver, ctx) => {
2903
+ const doSleep = ctx.sleep ?? sleep3;
2904
+ const findings = await runBootChecks(driver, ctx);
2905
+ if (hasError(findings)) {
2906
+ return { findings };
2907
+ }
2908
+ const mark = await driver.fpsMark();
2909
+ await doSleep(DESKTOP_FPS_MS);
2910
+ const buckets = await driver.fpsSince(mark);
2911
+ if (buckets.length === 0) {
2912
+ return { findings };
2913
+ }
2914
+ const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
2915
+ const warn = fpsFinding(mean, ctx.viewport.name);
2916
+ if (warn) {
2917
+ findings.push(warn);
2918
+ }
2919
+ await ctx.shot?.("live-desktop.png");
2920
+ return { findings, fps: Math.round(mean) };
2251
2921
  };
2252
- createSessionStore = (gameDir, startedWall) => {
2253
- const dir = path6.join(gameDir, ".frogoe", "sessions");
2254
- let file;
2255
- return {
2256
- file: () => file,
2257
- write: (records) => {
2258
- if (records.length === 0) return;
2259
- if (!file) {
2260
- mkdirSync4(dir, { recursive: true });
2261
- file = path6.join(dir, `${sessionStamp(startedWall)}.jsonl`);
2262
- }
2263
- appendFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
2922
+ overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
2923
+ retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
2924
+ waitForOver = async (driver, doSleep) => {
2925
+ for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
2926
+ await doSleep(POLL_MS);
2927
+ if (await driver.gameState() === "over") {
2928
+ return true;
2264
2929
  }
2265
- };
2266
- };
2267
- latestSessionFile = (gameDir) => {
2268
- const dir = path6.join(gameDir, ".frogoe", "sessions");
2269
- try {
2270
- const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
2271
- return files.length > 0 ? path6.join(dir, files[files.length - 1] ?? "") : null;
2272
- } catch {
2273
- return null;
2274
2930
  }
2931
+ return false;
2275
2932
  };
2276
- }
2277
- });
2278
-
2279
- // src/run.ts
2280
- var run_exports = {};
2281
- __export(run_exports, {
2282
- startServer: () => startServer
2283
- });
2284
- import { existsSync as existsSync5, readFileSync as readFileSync5, statSync, watch } from "fs";
2285
- import os2 from "os";
2286
- import path7 from "path";
2287
- import { createAdaptorServer } from "@hono/node-server";
2288
- import { getConnInfo } from "@hono/node-server/conninfo";
2289
- import { Hono } from "hono";
2290
- var buildDevScript, MIME2, startServer;
2291
- var init_run = __esm({
2292
- "src/run.ts"() {
2293
- "use strict";
2294
- init_ip();
2295
- init_records();
2296
- init_session();
2297
- buildDevScript = (version) => `<script>(function(){var v="${String(version)}";try{var es=new EventSource("/__frogoe/reload");es.onmessage=function(){location.reload()};es.onerror=function(){es.close()};}catch(e){}setInterval(function(){fetch("/__frogoe/version",{cache:"no-store"}).then(function(r){return r.text()}).then(function(t){if(t!==v)location.reload()}).catch(function(){})},2000);var fps=[],cnt=0,sec=performance.now(),evs=[],up0=performance.now();function up(){return (performance.now()-up0)/1000}function tick(){cnt++;var n=performance.now();if(n-sec>=1000){fps.push(cnt);cnt=0;sec=n}requestAnimationFrame(tick)}requestAnimationFrame(tick);addEventListener("error",function(e){evs.push({type:"error",msg:String(e.message||e).slice(0,200),up:up()})});addEventListener("unhandledrejection",function(e){evs.push({type:"rejection",msg:String(e.reason).slice(0,200),up:up()})});document.addEventListener("visibilitychange",function(){evs.push({type:document.hidden?"hidden":"visible",up:up()})});function flush(beacon){var p={v:1,up:up(),fps:fps.splice(0),events:evs.splice(0)};if(performance.memory)p.mem=Math.round(performance.memory.usedJSHeapSize/1048576);var b=JSON.stringify(p);if(beacon&&navigator.sendBeacon){navigator.sendBeacon("/__frogoe/metrics",new Blob([b],{type:"application/json"}));return}fetch("/__frogoe/metrics",{method:"POST",body:b,headers:{"content-type":"application/json"},keepalive:true}).catch(function(){});}document.addEventListener("visibilitychange",function(){if(document.hidden)flush(true)});addEventListener("pagehide",function(){flush(true)});setInterval(function(){flush(false)},5000);})();</script>`;
2298
- MIME2 = {
2299
- css: "text/css; charset=utf-8",
2300
- htm: "text/html; charset=utf-8",
2301
- html: "text/html; charset=utf-8",
2302
- ico: "image/x-icon",
2303
- jpeg: "image/jpeg",
2304
- js: "text/javascript; charset=utf-8",
2305
- json: "application/json; charset=utf-8",
2306
- mjs: "text/javascript; charset=utf-8",
2307
- png: "image/png",
2308
- svg: "image/svg+xml",
2309
- txt: "text/plain; charset=utf-8",
2310
- webp: "image/webp",
2311
- woff2: "font/woff2"
2933
+ runStartBurst = async (driver, ctx) => {
2934
+ const doSleep = ctx.sleep ?? sleep3;
2935
+ for (let step = 0; step < START_BURST_TAPS; step++) {
2936
+ const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
2937
+ const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
2938
+ await driver.tap(x, y);
2939
+ await doSleep(PLAY_STEP_MS);
2940
+ }
2312
2941
  };
2313
- startServer = async (dir, requestedPort = 0, telemetry) => {
2314
- const root = path7.resolve(dir);
2315
- if (!existsSync5(path7.join(root, "index.html"))) {
2316
- throw new Error(`frogoe run: no index.html in ${root} \u2014 is this a game folder?`);
2942
+ verifyDeath = async (driver, ctx, findings, cycle) => {
2943
+ const doSleep = ctx.sleep ?? sleep3;
2944
+ let events = await driver.finishEvents();
2945
+ if (events.length === 0) {
2946
+ await doSleep(GRACE_MS);
2947
+ events = await driver.finishEvents();
2317
2948
  }
2318
- const clients = /* @__PURE__ */ new Set();
2319
- let version = 0;
2320
- let remoteSeen = false;
2321
- let lastRemote = "";
2322
- const own = selfAddresses(os2.networkInterfaces());
2323
- const session = telemetry ? createSessionStore(root, Date.now()) : void 0;
2324
- const app = new Hono();
2325
- app.use("*", async (c, next) => {
2326
- try {
2327
- const address2 = getConnInfo(c).remote.address;
2328
- if (address2 && !isLoopback(address2) && !own.has(address2)) {
2329
- remoteSeen = true;
2330
- lastRemote = address2;
2331
- }
2332
- } catch {
2949
+ const mismatch = finishEventFinding(true, events.length);
2950
+ if (mismatch) {
2951
+ findings.push(mismatch);
2952
+ }
2953
+ const presence = await driver.retryPresence();
2954
+ if (!presence.gameover) {
2955
+ findings.push(noGameoverCardFinding());
2956
+ }
2957
+ if (!presence.retry) {
2958
+ findings.push(noRetryFinding());
2959
+ }
2960
+ await ctx.shot?.(overShotName(cycle));
2961
+ return presence.retry;
2962
+ };
2963
+ runLifecycle = async (driver, ctx) => {
2964
+ const doSleep = ctx.sleep ?? sleep3;
2965
+ const settle = ctx.settleMs ?? 2e3;
2966
+ const name = ctx.viewport.name;
2967
+ const findings = [];
2968
+ const boot = await runBootChecks(driver, ctx);
2969
+ findings.push(...boot);
2970
+ await ctx.shot?.("live-mobile.png");
2971
+ if (hasError(boot)) {
2972
+ return { findings, lifecycle: { ends: false, retryReloads: 0 }, playability: "no-input" };
2973
+ }
2974
+ const mark = await driver.fpsMark();
2975
+ const hashes = [];
2976
+ let streak = 0;
2977
+ let maxStreak = 0;
2978
+ let sawOver = false;
2979
+ let sawPaused = false;
2980
+ let sawStuck = false;
2981
+ let corrupt = null;
2982
+ for (let step = 0; step < PLAY_STEPS; step++) {
2983
+ const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
2984
+ const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
2985
+ if (step === HOLD_STEP_INDEX) {
2986
+ await driver.hold(x, y, HOLD_MS);
2987
+ } else if (step === DRAG_STEP_INDEX) {
2988
+ await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
2989
+ } else {
2990
+ await driver.tap(x, y);
2333
2991
  }
2334
- await next();
2335
- });
2336
- app.get("/__frogoe/reload", (c) => {
2337
- const stream = new ReadableStream({
2338
- cancel() {
2339
- },
2340
- start(controller) {
2341
- clients.add(controller);
2342
- controller.enqueue(new TextEncoder().encode("retry: 3000\n\n"));
2343
- }
2344
- });
2345
- return c.body(stream, {
2346
- headers: {
2347
- "cache-control": "no-store",
2348
- connection: "keep-alive",
2349
- "content-type": "text/event-stream"
2350
- }
2351
- });
2352
- });
2353
- app.get(
2354
- "/__frogoe/version",
2355
- (c) => c.text(String(version), 200, { "cache-control": "no-store" })
2356
- );
2357
- app.post("/__frogoe/metrics", async (c) => {
2358
- try {
2359
- const payload = JSON.parse(await c.req.text());
2360
- if (!session) return c.body(null, 204);
2361
- const lines = beaconToRecords(payload, Date.now());
2362
- session.write(lines.map((l) => l.record));
2363
- for (const line of lines) {
2364
- if (line.text) telemetry?.onEvent?.(line.text);
2365
- }
2366
- return c.body(null, 204);
2367
- } catch {
2368
- return c.body(null, 400);
2992
+ await doSleep(PLAY_STEP_MS);
2993
+ const state = await driver.gameState();
2994
+ if (state === "over") {
2995
+ sawOver = true;
2996
+ break;
2369
2997
  }
2370
- });
2371
- app.get("*", (c) => {
2372
- const raw = decodeURIComponent(new URL(c.req.url).pathname);
2373
- const safe = path7.normalize(raw).replaceAll("\\", "/");
2374
- let file = path7.join(root, safe === "/" ? "index.html" : safe);
2375
- if (!file.startsWith(root)) {
2376
- return c.text("forbidden", 403);
2998
+ if (state === "paused") {
2999
+ sawPaused = true;
3000
+ } else if (state === "loading") {
3001
+ sawStuck = true;
3002
+ } else if (state !== "playing" && state !== "(missing)") {
3003
+ corrupt ??= state;
2377
3004
  }
2378
- if (existsSync5(file) && statSync(file).isDirectory()) {
2379
- file = path7.join(file, "index.html");
3005
+ const hash = await driver.canvasHash();
3006
+ if (hash !== null) {
3007
+ if (hashes.length > 0 && hash === hashes[hashes.length - 1] && state === "playing") {
3008
+ streak += 1;
3009
+ maxStreak = Math.max(maxStreak, streak);
3010
+ } else {
3011
+ streak = 0;
3012
+ }
3013
+ hashes.push(hash);
2380
3014
  }
2381
- if (!existsSync5(file)) {
2382
- return c.text(`frogoe run: not found: ${raw}`, 404);
3015
+ }
3016
+ const buckets = await driver.fpsSince(mark);
3017
+ const mean = buckets.length > 0 ? buckets.reduce((a, b) => a + b, 0) / buckets.length : void 0;
3018
+ if (corrupt !== null) {
3019
+ findings.push(stateCorruptFinding(corrupt));
3020
+ }
3021
+ if (sawStuck) {
3022
+ const stuck = stateStuckFinding("loading", name, "play");
3023
+ if (stuck) {
3024
+ findings.push(stuck);
2383
3025
  }
2384
- const body = readFileSync5(file);
2385
- const ext = path7.extname(file).slice(1).toLowerCase();
2386
- const type = MIME2[ext] ?? "application/octet-stream";
2387
- if (ext === "html" || ext === "htm") {
2388
- const html = body.toString("utf-8");
2389
- const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
2390
- return c.body(injected, 200, {
2391
- "cache-control": "no-store",
2392
- "content-type": type
2393
- });
3026
+ }
3027
+ if (sawPaused) {
3028
+ findings.push(pausedFinding());
3029
+ }
3030
+ const frozen = frozenFrameFinding(maxStreak);
3031
+ if (frozen) {
3032
+ findings.push(frozen);
3033
+ }
3034
+ const sustained = fpsSustainedFinding(buckets, name);
3035
+ if (sustained) {
3036
+ findings.push(sustained.finding);
3037
+ } else {
3038
+ const warn = fpsFinding(mean, name);
3039
+ if (warn) {
3040
+ findings.push(warn);
2394
3041
  }
2395
- return c.body(body, 200, { "content-type": type });
2396
- });
2397
- const server = createAdaptorServer({ fetch: app.fetch });
2398
- await new Promise((resolve2, reject) => {
2399
- server.once("error", reject);
2400
- server.listen(requestedPort, "0.0.0.0", () => resolve2());
2401
- });
2402
- const address = server.address();
2403
- const port = typeof address === "object" && address ? address.port : 0;
2404
- if (!port) {
2405
- server.closeAllConnections?.();
2406
- server.close();
2407
- throw new Error("frogoe run: server failed to bind a port");
2408
3042
  }
2409
- const local = `http://localhost:${port}`;
2410
- const lanInfo = resolveLan();
2411
- const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
2412
- let timer;
2413
- const watcher = watch(root, { recursive: true }, (_event, file) => {
2414
- const first = file?.split(path7.sep)[0];
2415
- if (first === "snapshots" || first === ".frogoe") {
2416
- return;
3043
+ const responded = new Set(hashes).size > 1 || sawOver;
3044
+ const playability = responded ? "pass" : "fail";
3045
+ const play = playabilityFinding(playability);
3046
+ if (play) {
3047
+ findings.push(play);
3048
+ }
3049
+ const audioBefore = await driver.audioStates();
3050
+ if (audioBefore.count > 0) {
3051
+ await driver.interruptAudio();
3052
+ await runStartBurst(driver, ctx);
3053
+ await doSleep(600);
3054
+ const audioFinding = audioLockedFinding(await driver.audioStates());
3055
+ if (audioFinding) {
3056
+ findings.push(audioFinding);
2417
3057
  }
2418
- clearTimeout(timer);
2419
- timer = setTimeout(() => {
2420
- version += 1;
2421
- const payload = new TextEncoder().encode("data: reload\n\n");
2422
- for (const client of clients) {
2423
- try {
2424
- client.enqueue(payload);
2425
- } catch {
2426
- clients.delete(client);
3058
+ }
3059
+ let ends = false;
3060
+ let retryReloads = 0;
3061
+ if (!sawOver) {
3062
+ sawOver = await waitForOver(driver, doSleep);
3063
+ }
3064
+ if (!sawOver) {
3065
+ findings.push(neverEndsFinding(END_BUDGET_MS));
3066
+ } else {
3067
+ ends = true;
3068
+ let canRetry = await verifyDeath(driver, ctx, findings, 0);
3069
+ for (let cycle = 0; cycle < STABILITY_CYCLES && canRetry; cycle++) {
3070
+ const reloaded = await driver.clickRetryAwaitReload(RETRY_NAV_MS);
3071
+ if (!reloaded) {
3072
+ findings.push(retryDeadFinding());
3073
+ break;
3074
+ }
3075
+ retryReloads += 1;
3076
+ await doSleep(settle);
3077
+ const rebootState = await driver.gameState();
3078
+ if (rebootState === "over") {
3079
+ findings.push(earlyDeathFinding(name, "retry"));
3080
+ } else {
3081
+ const reboot = rebootFinding(rebootState);
3082
+ if (reboot) {
3083
+ findings.push(reboot);
2427
3084
  }
2428
3085
  }
2429
- }, 100);
2430
- });
3086
+ if (!await driver.canvasPainted()) {
3087
+ findings.push(canvasUnpaintedFinding(name, "retry"));
3088
+ }
3089
+ await ctx.shot?.(retryShotName(cycle));
3090
+ if (cycle < STABILITY_CYCLES - 1) {
3091
+ await runStartBurst(driver, ctx);
3092
+ const overAgain = await waitForOver(driver, doSleep);
3093
+ if (!overAgain) {
3094
+ findings.push(neverEndsFinding(END_BUDGET_MS));
3095
+ break;
3096
+ }
3097
+ canRetry = await verifyDeath(driver, ctx, findings, cycle + 1);
3098
+ }
3099
+ }
3100
+ }
3101
+ const throttleMark = await driver.fpsMark();
3102
+ await driver.setCpuThrottling(THROTTLE_RATE);
3103
+ await runStartBurst(driver, ctx);
3104
+ for (let step = 0; step < PLAY_STEPS; step++) {
3105
+ const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
3106
+ const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
3107
+ if (step === HOLD_STEP_INDEX) {
3108
+ await driver.hold(x, y, HOLD_MS);
3109
+ } else if (step === DRAG_STEP_INDEX) {
3110
+ await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
3111
+ } else {
3112
+ await driver.tap(x, y);
3113
+ }
3114
+ await doSleep(PLAY_STEP_MS);
3115
+ }
3116
+ const throttledBuckets = await driver.fpsSince(throttleMark);
3117
+ await driver.setCpuThrottling(1);
3118
+ const throttled = fpsThrottledFinding(throttledBuckets, name);
3119
+ if (throttled) {
3120
+ findings.push(throttled);
3121
+ }
2431
3122
  return {
2432
- port,
2433
- sawRemote: () => remoteSeen,
2434
- remote: () => remoteSeen ? lastRemote : void 0,
2435
- stop() {
2436
- clearTimeout(timer);
2437
- watcher.close();
2438
- server.closeAllConnections?.();
2439
- server.close();
2440
- },
2441
- urls: { lan, local }
3123
+ findings,
3124
+ lifecycle: { ends, retryReloads },
3125
+ mobileFps: mean !== void 0 ? Math.round(mean) : void 0,
3126
+ playability
2442
3127
  };
2443
3128
  };
2444
3129
  }
@@ -2449,34 +3134,19 @@ var live_exports = {};
2449
3134
  __export(live_exports, {
2450
3135
  collectLive: () => collectLive
2451
3136
  });
2452
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2453
- import path8 from "path";
2454
- var VIEWPORTS, browserPath, ensureBrowser, waitForServer, collectLive;
3137
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync4 } from "fs";
3138
+ import path12 from "path";
3139
+ var VIEWPORTS, waitForServer, collectLive;
2455
3140
  var init_live = __esm({
2456
3141
  "src/live/index.ts"() {
2457
- "use strict";
2458
- init_driver();
2459
- init_phases();
2460
- VIEWPORTS = [
2461
- { height: 844, name: "mobile", width: 390 },
2462
- { height: 800, name: "desktop", width: 1280 }
2463
- ];
2464
- ensureBrowser = async () => {
2465
- if (browserPath) {
2466
- return browserPath;
2467
- }
2468
- const { Browser, getInstalledBrowsers, install } = await import("@puppeteer/browsers");
2469
- const cacheDir = path8.resolve(process.cwd(), "node_modules/.frogoe-browser");
2470
- const installed = await getInstalledBrowsers({ cacheDir });
2471
- const existing = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
2472
- browserPath = existing?.executablePath ?? (await install({
2473
- browser: Browser.CHROMEHEADLESSSHELL,
2474
- buildId: "131.0.6778.204",
2475
- cacheDir,
2476
- unpack: true
2477
- })).executablePath;
2478
- return browserPath;
2479
- };
3142
+ "use strict";
3143
+ init_browser();
3144
+ init_driver();
3145
+ init_phases();
3146
+ VIEWPORTS = [
3147
+ { height: 844, name: "mobile", width: 390 },
3148
+ { height: 800, name: "desktop", width: 1280 }
3149
+ ];
2480
3150
  waitForServer = async (url) => {
2481
3151
  for (let attempt = 0; attempt < 10; attempt++) {
2482
3152
  try {
@@ -2486,11 +3156,11 @@ var init_live = __esm({
2486
3156
  }
2487
3157
  } catch {
2488
3158
  }
2489
- await sleep2(500);
3159
+ await sleep3(500);
2490
3160
  }
2491
3161
  };
2492
3162
  collectLive = async (options) => {
2493
- const dir = path8.resolve(options.dir);
3163
+ const dir = path12.resolve(options.dir);
2494
3164
  const settle = options.settleMs ?? 2e3;
2495
3165
  const findings = [];
2496
3166
  const screenshots = [];
@@ -2500,8 +3170,8 @@ var init_live = __esm({
2500
3170
  };
2501
3171
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
2502
3172
  const server = await startServer2(dir);
2503
- const snapshotDir = path8.join(dir, "snapshots");
2504
- mkdirSync5(snapshotDir, { recursive: true });
3173
+ const snapshotDir = path12.join(dir, "snapshots");
3174
+ mkdirSync6(snapshotDir, { recursive: true });
2505
3175
  const { default: puppeteer } = await import("puppeteer-core");
2506
3176
  const executablePath = await ensureBrowser();
2507
3177
  const browser = await puppeteer.launch({
@@ -2521,15 +3191,15 @@ var init_live = __esm({
2521
3191
  size: { height: viewport2.height, width: viewport2.width }
2522
3192
  });
2523
3193
  const shot = async (name) => {
2524
- writeFileSync4(path8.join(snapshotDir, name), await driver.screenshot());
2525
- screenshots.push(path8.join("snapshots", name));
3194
+ writeFileSync4(path12.join(snapshotDir, name), await driver.screenshot());
3195
+ screenshots.push(path12.join("snapshots", name));
2526
3196
  };
2527
3197
  if (!serverReady) {
2528
3198
  await waitForServer(server.urls.local);
2529
3199
  serverReady = true;
2530
3200
  }
2531
3201
  await page.goto(server.urls.local, { timeout: 15e3, waitUntil: "domcontentloaded" });
2532
- await sleep2(settle);
3202
+ await sleep3(settle);
2533
3203
  if (viewport2.name === "mobile") {
2534
3204
  const outcome = await runLifecycle(driver, {
2535
3205
  settleMs: settle,
@@ -2588,6 +3258,7 @@ var init_check3 = __esm({
2588
3258
  args: {
2589
3259
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2590
3260
  json: { type: "boolean", description: "machine-readable findings" },
3261
+ fast: { type: "boolean", description: "static only, no Chrome (quick iteration)" },
2591
3262
  live: {
2592
3263
  type: "boolean",
2593
3264
  description: "deprecated no-op \u2014 the live sandbox always runs now"
@@ -2599,16 +3270,22 @@ var init_check3 = __esm({
2599
3270
  }
2600
3271
  const dir = args.dir ? String(args.dir) : process.cwd();
2601
3272
  const result = checkProject(dir);
2602
- const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
2603
- console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
2604
- const live = await collectLive2({ dir });
2605
- result.findings = [...result.findings, ...live.findings].sort(
2606
- (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
2607
- );
2608
- result.errors = result.findings.filter((f) => f.severity === "error").length;
2609
- result.warnings = result.findings.filter((f) => f.severity === "warning").length;
2610
- if (live.screenshots.length > 0) {
2611
- console.log(` snapshots: ${live.screenshots.join(", ")}`);
3273
+ if (args.fast === true) {
3274
+ if (!args.json) {
3275
+ console.log(" (fast: static only \u2014 run full `frogoe check` before shipping)");
3276
+ }
3277
+ } else {
3278
+ const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
3279
+ console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
3280
+ const live = await collectLive2({ dir });
3281
+ result.findings = [...result.findings, ...live.findings].sort(
3282
+ (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
3283
+ );
3284
+ result.errors = result.findings.filter((f) => f.severity === "error").length;
3285
+ result.warnings = result.findings.filter((f) => f.severity === "warning").length;
3286
+ if (live.screenshots.length > 0) {
3287
+ console.log(` snapshots: ${live.screenshots.join(", ")}`);
3288
+ }
2612
3289
  }
2613
3290
  if (args.json) {
2614
3291
  console.log(JSON.stringify(result, null, 2));
@@ -2628,18 +3305,825 @@ var init_check3 = __esm({
2628
3305
  }
2629
3306
  });
2630
3307
 
3308
+ // src/embed/card.ts
3309
+ var BOOT_TIMEOUT_MS, LOADING_TIMEOUT_MS, CARD_ELEMENT_SCRIPT, composeEmbedHtml, safeJson, escapeHtml;
3310
+ var init_card = __esm({
3311
+ "src/embed/card.ts"() {
3312
+ "use strict";
3313
+ BOOT_TIMEOUT_MS = 1e4;
3314
+ LOADING_TIMEOUT_MS = 45e3;
3315
+ CARD_ELEMENT_SCRIPT = `
3316
+ if (!customElements.get("frogoe-card")) {
3317
+ customElements.define("frogoe-card", class FrogoeCard extends HTMLElement {
3318
+ static get observedAttributes() {
3319
+ return ["src", "width", "height", "poster", "payload"];
3320
+ }
3321
+ constructor() {
3322
+ super();
3323
+ this._frame = null;
3324
+ this._card = null;
3325
+ this._bootTimer = null;
3326
+ this._loadingTimer = null;
3327
+ this._state = "loading";
3328
+ this._score = null;
3329
+ this._manifest = null;
3330
+ }
3331
+ connectedCallback() { this._build(); }
3332
+ disconnectedCallback() { this._destroy(); }
3333
+ attributeChangedCallback(name, oldVal, newVal) {
3334
+ if (oldVal === newVal) return;
3335
+ if (name === "src" && this._frame) this._frame.src = newVal;
3336
+ if (name === "width") this.style.width = newVal + "px";
3337
+ if (name === "height") this.style.height = newVal + "px";
3338
+ if ((name === "poster" || name === "payload") && this._frame) this._build();
3339
+ }
3340
+ get state() { return this._state; }
3341
+ get score() { return this._score; }
3342
+ get manifest() { return this._manifest; }
3343
+ get cardWindow() { return this._card ? this._card.__frogoeCard : null; }
3344
+ pause() { if (this._card) this._card.__frogoeCard.pause(); }
3345
+ resume() { if (this._card) this._card.__frogoeCard.resume(); }
3346
+ mute(m) { if (this._card) this._card.__frogoeCard.mute(m); }
3347
+ restart() { if (this._card) this._card.__frogoeCard.restart(); }
3348
+ _build() {
3349
+ this._destroy();
3350
+ const shadow = this.shadowRoot || this.attachShadow({ mode: "open" });
3351
+ shadow.innerHTML = "";
3352
+ const frame = document.createElement("iframe");
3353
+ frame.style.cssText = "border:0;display:block;width:100%;height:100%";
3354
+ frame.sandbox = "allow-scripts allow-same-origin";
3355
+ frame.referrerPolicy = "no-referrer";
3356
+ frame.title = "frogoe game";
3357
+ const src = this.getAttribute("src");
3358
+ if (src) frame.src = src;
3359
+ shadow.appendChild(frame);
3360
+ this._frame = frame;
3361
+ frame.addEventListener("load", () => {
3362
+ try {
3363
+ const card = frame.contentWindow;
3364
+ if (card && card.__frogoeCard) {
3365
+ this._card = card;
3366
+ card.addEventListener("frogoe:card-state", (e) => {
3367
+ this._state = e.detail.state;
3368
+ this._score = e.detail.score;
3369
+ this.dispatchEvent(new CustomEvent("frogoe:card-state", { detail: e.detail, bubbles: true }));
3370
+ });
3371
+ }
3372
+ } catch (e) { /* cross-origin */ }
3373
+ });
3374
+ }
3375
+ _destroy() {
3376
+ if (this._frame) {
3377
+ this._frame.remove();
3378
+ this._frame = null;
3379
+ }
3380
+ this._card = null;
3381
+ this._state = "loading";
3382
+ this._score = null;
3383
+ }
3384
+ });
3385
+ }
3386
+ `;
3387
+ composeEmbedHtml = (options) => {
3388
+ const { manifest, payloadB64, posterDataUri, iconDataUri } = options;
3389
+ const p = manifest.palette;
3390
+ return `<!doctype html>
3391
+ <html lang="en">
3392
+ <head>
3393
+ <meta charset="utf-8">
3394
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
3395
+ <title>${escapeHtml(manifest.title)}</title>
3396
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'">
3397
+ ${iconDataUri ? `<link rel="icon" href="${iconDataUri}">` : ""}
3398
+ <style>
3399
+ html,body{margin:0;height:100%;overflow:hidden;background:${p.bg || "#111"};}
3400
+ iframe{border:0;display:block;width:100%;height:100%}
3401
+ .p{position:absolute;inset:0;z-index:2;background:${posterDataUri ? `url("${posterDataUri}") center / cover no-repeat` : p.bg || "#111"};transition:opacity 500ms ease}
3402
+ .p.off{opacity:0;pointer-events:none}
3403
+ @media (prefers-reduced-motion: reduce){.p{transition:none}}
3404
+ </style>
3405
+ </head>
3406
+ <body>
3407
+ <script type="application/octet-stream" id="frogoe-game">${payloadB64}</script>
3408
+ <iframe id="frame" title="${escapeHtml(manifest.title)}" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
3409
+ <div class="p" id="poster"></div>
3410
+ <script>
3411
+ (function () {
3412
+ var frame = document.getElementById("frame");
3413
+ var poster = document.getElementById("poster");
3414
+ var BOOT_TIMEOUT = ${String(BOOT_TIMEOUT_MS)};
3415
+ var LOADING_TIMEOUT = ${String(LOADING_TIMEOUT_MS)};
3416
+ var bootTimer, loadingTimer;
3417
+
3418
+ var postControl = function (payload) {
3419
+ try {
3420
+ frame.contentWindow.postMessage(
3421
+ Object.assign({ source: "frogoe-card-host", v: 1, type: "control" }, payload),
3422
+ "*"
3423
+ );
3424
+ } catch (e) {}
3425
+ };
3426
+ window.__frogoeCard = { state: "loading", score: null,
3427
+ restart: function () { boot(); },
3428
+ pause: function () { postControl({ action: "pause" }); },
3429
+ resume: function () { postControl({ action: "resume" }); },
3430
+ mute: function (m) { muted = m === true; postControl({ action: "mute", muted: m === true }); },
3431
+ destroy: function () {
3432
+ clearTimeout(bootTimer); clearTimeout(loadingTimer);
3433
+ postControl({ action: "pause" });
3434
+ try { frame.srcdoc = ""; } catch (e) {}
3435
+ frame.remove();
3436
+ poster.remove();
3437
+ var carrier = document.getElementById("frogoe-game");
3438
+ if (carrier) carrier.remove();
3439
+ window.__frogoeCard.state = "destroyed";
3440
+ } };
3441
+ window.__frogoeManifest = ${safeJson(manifest)};
3442
+
3443
+ function setState(state, score) {
3444
+ window.__frogoeCard.state = state;
3445
+ if (state === "over") window.__frogoeCard.score = score === undefined ? 0 : score;
3446
+ if (state === "running") {
3447
+ poster.classList.add("off");
3448
+ try { frame.contentWindow?.focus(); } catch (e) {}
3449
+ if (muted) postControl({ action: "mute", muted: true });
3450
+ }
3451
+ document.dispatchEvent(new CustomEvent("frogoe:card-state", {
3452
+ detail: { state: state, score: window.__frogoeCard.score }
3453
+ }));
3454
+ }
3455
+ function fail() { setState("error"); }
3456
+
3457
+ var muted = false;
3458
+ function boot() {
3459
+ clearTimeout(bootTimer); clearTimeout(loadingTimer);
3460
+ poster.classList.remove("off");
3461
+ window.__frogoeCard.state = "loading"; window.__frogoeCard.score = null;
3462
+ var b64 = document.getElementById("frogoe-game").textContent;
3463
+ var bytes = Uint8Array.from(atob(b64), function (c) { return c.charCodeAt(0); });
3464
+ frame.srcdoc = new TextDecoder().decode(bytes);
3465
+ bootTimer = setTimeout(function () { if (window.__frogoeCard.state === "loading") fail(); }, BOOT_TIMEOUT);
3466
+ loadingTimer = setTimeout(function () {
3467
+ if (window.__frogoeCard.state === "loading") fail();
3468
+ }, LOADING_TIMEOUT);
3469
+ }
3470
+
3471
+ function parseMsg(data) {
3472
+ if (typeof data !== "object" || data === null) return null;
3473
+ if (data.v !== 1 || data.source !== "frogoe-card") return null;
3474
+ if (data.type === "error") return { type: "error" };
3475
+ if (data.type !== "state") return null;
3476
+ var s = data.state;
3477
+ if (s !== "loading" && s !== "running" && s !== "over") return null;
3478
+ if (s === "over") {
3479
+ if (typeof data.score !== "number" || !isFinite(data.score)) return null;
3480
+ return { type: "state", state: "over", score: data.score };
3481
+ }
3482
+ if ("score" in data) return null;
3483
+ return { type: "state", state: s };
3484
+ }
3485
+
3486
+ window.addEventListener("message", function (event) {
3487
+ if (event.source !== frame.contentWindow) return;
3488
+ var parsed = parseMsg(event.data);
3489
+ if (!parsed) return;
3490
+ clearTimeout(bootTimer); clearTimeout(loadingTimer);
3491
+ if (parsed.type === "error") { fail(); return; }
3492
+ if (parsed.state === "loading") return;
3493
+ setState(parsed.state, parsed.score);
3494
+ try { parent.postMessage(event.data, "*"); } catch (e) {}
3495
+ });
3496
+
3497
+ boot();
3498
+ })();
3499
+ </script>
3500
+ <script>
3501
+ ${CARD_ELEMENT_SCRIPT}
3502
+ </script>
3503
+ </body>
3504
+ </html>`;
3505
+ };
3506
+ safeJson = (value) => JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
3507
+ escapeHtml = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
3508
+ }
3509
+ });
3510
+
3511
+ // src/embed/protocol.ts
3512
+ var CARD_PROTOCOL_VERSION, CARD_SOURCE_TAG, HOST_SOURCE_TAG, parseHostControl;
3513
+ var init_protocol = __esm({
3514
+ "src/embed/protocol.ts"() {
3515
+ "use strict";
3516
+ CARD_PROTOCOL_VERSION = 1;
3517
+ CARD_SOURCE_TAG = "frogoe-card";
3518
+ HOST_SOURCE_TAG = "frogoe-card-host";
3519
+ parseHostControl = (data) => {
3520
+ if (typeof data !== "object" || data === null) return null;
3521
+ const message2 = data;
3522
+ if (message2.v !== CARD_PROTOCOL_VERSION) return null;
3523
+ if (message2.source !== HOST_SOURCE_TAG) return null;
3524
+ if (message2.type !== "control") return null;
3525
+ const action = message2.action;
3526
+ if (action === "pause" || action === "resume") return { action };
3527
+ if (action === "mute") {
3528
+ if (typeof message2.muted !== "boolean") return null;
3529
+ return { action: "mute", muted: message2.muted };
3530
+ }
3531
+ return null;
3532
+ };
3533
+ }
3534
+ });
3535
+
3536
+ // src/embed/relay.ts
3537
+ var RELAY_SCRIPT;
3538
+ var init_relay = __esm({
3539
+ "src/embed/relay.ts"() {
3540
+ "use strict";
3541
+ init_protocol();
3542
+ RELAY_SCRIPT = `<script>
3543
+ (function () {
3544
+ // the pure protocol validator, injected as source \u2014 the tested code IS
3545
+ // the shipped code (same pattern as the raster/vision analyzers)
3546
+ var parseHostControl = ${parseHostControl.toString()};
3547
+ var post = function (payload) {
3548
+ try {
3549
+ window.parent.postMessage(
3550
+ Object.assign({ source: "${CARD_SOURCE_TAG}", v: ${String(CARD_PROTOCOL_VERSION)} }, payload),
3551
+ "*"
3552
+ );
3553
+ } catch (e) { /* parent gone \u2014 sandbox lifecycle, not a crash */ }
3554
+ };
3555
+ var score = 0;
3556
+ var last = "";
3557
+ var map = function (state) {
3558
+ if (state === "over") return "over";
3559
+ if (state === "playing" || state === "paused") return "running";
3560
+ return "loading";
3561
+ };
3562
+ var emit = function (card) {
3563
+ if (card === last) return;
3564
+ last = card;
3565
+ if (card === "over") post({ type: "state", state: "over", score: score });
3566
+ else post({ type: "state", state: card });
3567
+ };
3568
+ // The embed context mark: this relay exists ONLY inside embed cards,
3569
+ // so the class is the truthful "you are embedded" signal \u2014 games use
3570
+ // it to surface touch controls (iframes cannot rely on keyboards).
3571
+ try { document.documentElement.classList.add("frogoe-embed"); } catch (e) {}
3572
+ document.addEventListener("frogoe:finish", function (event) {
3573
+ var detail = event && event.detail;
3574
+ score = detail && typeof detail.score === "number" && isFinite(detail.score)
3575
+ ? detail.score
3576
+ : 0;
3577
+ emit("over");
3578
+ });
3579
+ window.addEventListener("error", function () {
3580
+ post({ type: "error" });
3581
+ });
3582
+
3583
+ // host \u2192 game control (play/pause/mute parity): validated by version,
3584
+ // source tag AND frame identity (event.source must be the parent) \u2014
3585
+ // the relay only ever calls the contract's own handle, never game code
3586
+ window.addEventListener("message", function (event) {
3587
+ if (event.source !== window.parent) return;
3588
+ var control = parseHostControl(event.data);
3589
+ if (!control) return;
3590
+ try {
3591
+ var api = window.__frogoe;
3592
+ if (!api) return;
3593
+ if (control.action === "pause" && typeof api.pause === "function") api.pause();
3594
+ if (control.action === "resume" && typeof api.resume === "function") api.resume();
3595
+ if (control.action === "mute" && typeof api.mute === "function") api.mute(control.muted);
3596
+ } catch (e) { /* control is best-effort \u2014 never kills the game */ }
3597
+ });
3598
+ emit("loading"); // alive-signal: the host may have booted before us
3599
+ setInterval(function () {
3600
+ var api = window.__frogoe;
3601
+ if (!api || typeof api.state !== "string") return;
3602
+ emit(map(api.state));
3603
+ }, 200);
3604
+ })();
3605
+ </script>`;
3606
+ }
3607
+ });
3608
+
3609
+ // src/embed/compose.ts
3610
+ var injectRelay, composePayload;
3611
+ var init_compose = __esm({
3612
+ "src/embed/compose.ts"() {
3613
+ "use strict";
3614
+ init_relay();
3615
+ injectRelay = (gameHtml, relayScript) => {
3616
+ const at = gameHtml.lastIndexOf("</body>");
3617
+ if (at === -1) {
3618
+ return `${gameHtml}${relayScript}`;
3619
+ }
3620
+ return `${gameHtml.slice(0, at)}${relayScript}${gameHtml.slice(at)}`;
3621
+ };
3622
+ composePayload = (gameHtml) => {
3623
+ const srcdoc = injectRelay(gameHtml, RELAY_SCRIPT);
3624
+ return { payloadB64: Buffer.from(srcdoc, "utf-8").toString("base64"), srcdoc };
3625
+ };
3626
+ }
3627
+ });
3628
+
3629
+ // src/manifest.ts
3630
+ import { createHash as createHash2 } from "crypto";
3631
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3632
+ import path13 from "path";
3633
+ var DESCRIPTION_CAP, descriptionFrom, buildManifest;
3634
+ var init_manifest = __esm({
3635
+ "src/manifest.ts"() {
3636
+ "use strict";
3637
+ init_src();
3638
+ DESCRIPTION_CAP = 280;
3639
+ descriptionFrom = (source) => {
3640
+ const split = source.split(/^---\r?\n[\s\S]*?\r?\n---/u);
3641
+ const body = split[1] ?? split[0] ?? "";
3642
+ for (const block of body.split(/\n\s*\n/u)) {
3643
+ const text = block.replaceAll(/\s+/gu, " ").trim();
3644
+ if (text.length > 0) {
3645
+ return text.length > DESCRIPTION_CAP ? `${text.slice(0, DESCRIPTION_CAP - 1).trimEnd()}\u2026` : text;
3646
+ }
3647
+ }
3648
+ return null;
3649
+ };
3650
+ buildManifest = (options) => {
3651
+ const dir = path13.resolve(options.dir);
3652
+ const briefSource = readFileSync9(path13.join(dir, "BRIEF.md"), "utf-8");
3653
+ const brief = parseBrief(briefSource);
3654
+ if (!brief?.title) return null;
3655
+ const pin = existsSync8(path13.join(dir, "frogoe.json")) ? JSON.parse(readFileSync9(path13.join(dir, "frogoe.json"), "utf-8")).contract ?? "0.1.0" : "0.1.0";
3656
+ const artifact = options.artifactHtml ?? readFileSync9(path13.join(dir, "dist", "index.html"), "utf-8");
3657
+ const sha2562 = createHash2("sha256").update(artifact, "utf-8").digest("hex");
3658
+ const mediaSha = (file) => {
3659
+ const full = path13.join(dir, "dist", "assets", file);
3660
+ return existsSync8(full) ? createHash2("sha256").update(readFileSync9(full)).digest("hex") : null;
3661
+ };
3662
+ return {
3663
+ artifact: "index.html",
3664
+ artifactSha256: sha2562,
3665
+ contract: pin,
3666
+ description: descriptionFrom(briefSource),
3667
+ entry: "index.html",
3668
+ fonts: brief.fonts ?? null,
3669
+ icon: existsSync8(path13.join(dir, "dist", "assets", "icon.png")) ? "assets/icon.png" : null,
3670
+ iconSha256: mediaSha("icon.png"),
3671
+ mood: brief.mood ?? null,
3672
+ palette: {
3673
+ accent: brief.accent ?? "",
3674
+ bg: brief.bg ?? "",
3675
+ fg: brief.fg ?? "",
3676
+ ...brief.outline ? { outline: brief.outline } : {}
3677
+ },
3678
+ poster: existsSync8(path13.join(dir, "dist", "assets", "poster.png")) ? "assets/poster.png" : null,
3679
+ posterSha256: mediaSha("poster.png"),
3680
+ title: brief.title,
3681
+ verb: brief.verb ?? "tap"
3682
+ };
3683
+ };
3684
+ }
3685
+ });
3686
+
3687
+ // src/commands/embed.ts
3688
+ var embed_exports = {};
3689
+ __export(embed_exports, {
3690
+ command: () => command4
3691
+ });
3692
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync5 } from "fs";
3693
+ import path14 from "path";
3694
+ import { defineCommand as defineCommand4 } from "citty";
3695
+ var dataUri2, command4;
3696
+ var init_embed = __esm({
3697
+ "src/commands/embed.ts"() {
3698
+ "use strict";
3699
+ init_src();
3700
+ init_card();
3701
+ init_compose();
3702
+ init_manifest();
3703
+ dataUri2 = (file) => existsSync9(file) ? `data:image/png;base64,${readFileSync10(file).toString("base64")}` : null;
3704
+ command4 = defineCommand4({
3705
+ args: {
3706
+ dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
3707
+ },
3708
+ async run({ args }) {
3709
+ const dir = args.dir ? String(args.dir) : process.cwd();
3710
+ const artifactPath = path14.join(dir, "dist", "index.html");
3711
+ if (!existsSync9(artifactPath)) {
3712
+ throw new Error(
3713
+ "frogoe embed: no dist/index.html \u2014 run `frogoe bundle` first (and `frogoe check` before it: check \u2192 bundle \u2192 embed)"
3714
+ );
3715
+ }
3716
+ const staticResult = checkProject(dir);
3717
+ if (staticResult.errors > 0) {
3718
+ const first = staticResult.findings.find((f) => f.severity === "error");
3719
+ throw new Error(
3720
+ `frogoe embed: project is not gate-clean (${first?.code ?? "unknown"} at ${first?.file ?? "?"}) \u2014 run \`frogoe check\` first`
3721
+ );
3722
+ }
3723
+ const manifest = buildManifest({ dir });
3724
+ if (!manifest) {
3725
+ throw new Error("frogoe embed: could not build the manifest from BRIEF.md");
3726
+ }
3727
+ const posterDataUri = dataUri2(path14.join(dir, "dist", "assets", "poster.png"));
3728
+ const iconDataUri = dataUri2(path14.join(dir, "dist", "assets", "icon.png"));
3729
+ const gameHtml = readFileSync10(artifactPath, "utf-8");
3730
+ const { payloadB64 } = composePayload(gameHtml);
3731
+ const html = composeEmbedHtml({ iconDataUri, manifest, payloadB64, posterDataUri });
3732
+ mkdirSync7(path14.join(dir, "dist"), { recursive: true });
3733
+ writeFileSync5(
3734
+ path14.join(dir, "dist", "manifest.json"),
3735
+ `${JSON.stringify(manifest, null, 2)}
3736
+ `,
3737
+ "utf-8"
3738
+ );
3739
+ const outPath = path14.join(dir, "dist", "embed.html");
3740
+ writeFileSync5(outPath, html, "utf-8");
3741
+ console.log(` frogoe embed \u2192 ${outPath}`);
3742
+ console.log(
3743
+ ` ${html.length.toLocaleString("en-US")} bytes \xB7 sandbox allow-scripts \xB7 sha256 ${manifest.artifactSha256.slice(0, 12)}`
3744
+ );
3745
+ console.log(
3746
+ ` poster: ${manifest.poster ? "authored art (rasterized)" : "MISSING \u2014 run frogoe bundle"} \xB7 icon: ${manifest.icon ? "authored art (rasterized)" : "MISSING \u2014 run frogoe bundle"}`
3747
+ );
3748
+ },
3749
+ meta: {
3750
+ description: "wrap the bundle in a card (poster loading state + manifest)"
3751
+ }
3752
+ });
3753
+ }
3754
+ });
3755
+
3756
+ // src/art-eyes.ts
3757
+ var charFor, mapToChars, mapToPretty, compositionMetrics;
3758
+ var init_art_eyes = __esm({
3759
+ "src/art-eyes.ts"() {
3760
+ "use strict";
3761
+ charFor = (r, g, b, palette) => {
3762
+ const entries = [
3763
+ [palette.bg ?? "", "."],
3764
+ [palette.fg ?? "", "O"],
3765
+ [palette.accent ?? "", "X"],
3766
+ [palette.outline ?? "", "#"]
3767
+ ];
3768
+ let bestGlyph = "";
3769
+ let bestDist = 90;
3770
+ for (const [hex, glyph] of entries) {
3771
+ if (hex.length !== 7) continue;
3772
+ const pr = Number.parseInt(hex.slice(1, 3) ?? "0", 16);
3773
+ const pg = Number.parseInt(hex.slice(3, 5) ?? "0", 16);
3774
+ const pb = Number.parseInt(hex.slice(5, 7) ?? "0", 16);
3775
+ const dist = Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb);
3776
+ if (dist < bestDist) {
3777
+ bestDist = dist;
3778
+ bestGlyph = glyph;
3779
+ }
3780
+ }
3781
+ if (bestGlyph !== "") return bestGlyph;
3782
+ const lum = (r * 299 + g * 587 + b * 114) / 1e3;
3783
+ return "@%&*8=+~;:,-^`' "[Math.min(15, Math.max(0, Math.round(lum / 255 * 15)))] ?? " ";
3784
+ };
3785
+ mapToChars = (data, width, height, cols, rows, palette) => {
3786
+ const lines = [];
3787
+ for (let gy = 0; gy < rows; gy++) {
3788
+ let line = "";
3789
+ for (let gx = 0; gx < cols; gx++) {
3790
+ const x0 = Math.floor(gx * width / cols);
3791
+ const x1 = Math.max(x0 + 1, Math.floor((gx + 1) * width / cols));
3792
+ const y0 = Math.floor(gy * height / rows);
3793
+ const y1 = Math.max(y0 + 1, Math.floor((gy + 1) * height / rows));
3794
+ let ar = 0;
3795
+ let ag = 0;
3796
+ let ab = 0;
3797
+ let n = 0;
3798
+ for (let y = y0; y < y1 && y < height; y++) {
3799
+ for (let x = x0; x < x1 && x < width; x++) {
3800
+ const at = (y * width + x) * 4;
3801
+ ar += data[at] ?? 0;
3802
+ ag += data[at + 1] ?? 0;
3803
+ ab += data[at + 2] ?? 0;
3804
+ n++;
3805
+ }
3806
+ }
3807
+ line += charFor(Math.round(ar / n), Math.round(ag / n), Math.round(ab / n), palette);
3808
+ }
3809
+ lines.push(line);
3810
+ }
3811
+ return lines.join("\n");
3812
+ };
3813
+ mapToPretty = (data, width, height, cols, rows) => {
3814
+ const lines = [];
3815
+ for (let gy = 0; gy < rows; gy++) {
3816
+ let line = "";
3817
+ for (let gx = 0; gx < cols; gx++) {
3818
+ const px = (fy) => {
3819
+ const x = Math.min(width - 1, Math.floor((gx + 0.5) * width / cols));
3820
+ const y = Math.min(height - 1, Math.floor((gy + fy) * height / rows));
3821
+ const at = (y * width + x) * 4;
3822
+ return [data[at] ?? 0, data[at + 1] ?? 0, data[at + 2] ?? 0];
3823
+ };
3824
+ const [tr, tg, tb] = px(0.25);
3825
+ const [br, bg, bb] = px(0.75);
3826
+ line += `\x1B[38;2;${String(tr)};${String(tg)};${String(tb)}m\x1B[48;2;${String(br)};${String(bg)};${String(bb)}m\u2580`;
3827
+ }
3828
+ lines.push(`${line}\x1B[0m`);
3829
+ }
3830
+ return lines.join("\n");
3831
+ };
3832
+ compositionMetrics = (data, width, height, rows, palette) => {
3833
+ const hex = palette.bg ?? "#000000";
3834
+ const pr = Number.parseInt(hex.slice(1, 3) ?? "0", 16);
3835
+ const pg = Number.parseInt(hex.slice(3, 5) ?? "0", 16);
3836
+ const pb = Number.parseInt(hex.slice(5, 7) ?? "0", 16);
3837
+ let ink = 0;
3838
+ let total = 0;
3839
+ let deadRows = 0;
3840
+ for (let gy = 0; gy < rows; gy++) {
3841
+ let rowInk = 0;
3842
+ let rowTotal = 0;
3843
+ const y0 = Math.floor(gy * height / rows);
3844
+ const y1 = Math.floor((gy + 1) * height / rows);
3845
+ for (let y = y0; y < y1; y += 2) {
3846
+ for (let x = 0; x < width; x += 2) {
3847
+ const at = (y * width + x) * 4;
3848
+ const isBg = Math.abs((data[at] ?? 0) - pr) + Math.abs((data[at + 1] ?? 0) - pg) + Math.abs((data[at + 2] ?? 0) - pb) < 60;
3849
+ rowTotal++;
3850
+ rowInk += isBg ? 0 : 1;
3851
+ }
3852
+ }
3853
+ if (rowTotal > 0 && rowInk / rowTotal < 0.08) deadRows++;
3854
+ ink += rowInk;
3855
+ total += rowTotal;
3856
+ }
3857
+ return { coverage: total > 0 ? ink / total : 0, deadRows, rows };
3858
+ };
3859
+ }
3860
+ });
3861
+
3862
+ // src/commands/vision.ts
3863
+ var vision_exports = {};
3864
+ __export(vision_exports, {
3865
+ command: () => command5
3866
+ });
3867
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
3868
+ import path15 from "path";
3869
+ import { defineCommand as defineCommand5 } from "citty";
3870
+ var HELPERS, PAGE_SCRIPT, show, metricLine, command5;
3871
+ var init_vision = __esm({
3872
+ "src/commands/vision.ts"() {
3873
+ "use strict";
3874
+ init_src();
3875
+ init_art_eyes();
3876
+ init_art_verify();
3877
+ init_browser();
3878
+ HELPERS = [
3879
+ luminance,
3880
+ contrastRatio2,
3881
+ charFor,
3882
+ mapToChars,
3883
+ mapToPretty,
3884
+ compositionMetrics,
3885
+ analyzeTitleBand,
3886
+ analyzeIconCorners,
3887
+ findTitleZoneCollision
3888
+ ].map((fn) => `const ${fn.name} = ${String(fn)};`).join("\n");
3889
+ PAGE_SCRIPT = (palette, cols = 96) => `(async () => {
3890
+ ${HELPERS}
3891
+ const PAL = ${JSON.stringify(palette)};
3892
+ const map = (d, w, h, useCols) => {
3893
+ const rows = Math.max(1, Math.round((useCols * (h / w)) / 2));
3894
+ return {
3895
+ cols: useCols, rows,
3896
+ plain: mapToChars(d, w, h, useCols, rows, PAL),
3897
+ pretty: mapToPretty(d, w, h, useCols, rows),
3898
+ metrics: compositionMetrics(d, w, h, Math.max(4, Math.round(rows / 4)), PAL),
3899
+ };
3900
+ };
3901
+ const grab = (canvas) => {
3902
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
3903
+ return { d: ctx.getImageData(0, 0, canvas.width, canvas.height).data, w: canvas.width, h: canvas.height };
3904
+ };
3905
+ const render = (w, h, draw) => {
3906
+ const c = document.createElement("canvas");
3907
+ c.width = w; c.height = h;
3908
+ const ctx = c.getContext("2d", { willReadFrequently: true });
3909
+ ctx.fillStyle = PAL.bg;
3910
+ ctx.fillRect(0, 0, w, h);
3911
+ draw(ctx, c);
3912
+ return grab(c);
3913
+ };
3914
+ const out = { objects: null, gameplay: [], identity: {} };
3915
+ // gameplay frames come from Node-side page.screenshot (full page:
3916
+ // canvas + DOM HUD \u2014 the HUD is half the composition); this maps them
3917
+ window.__frogoeMapShot = async (b64) => {
3918
+ const img = new Image();
3919
+ img.src = "data:image/png;base64," + b64;
3920
+ await img.decode();
3921
+ const c = document.createElement("canvas");
3922
+ c.width = img.naturalWidth; c.height = img.naturalHeight;
3923
+ const ctx = c.getContext("2d", { willReadFrequently: true });
3924
+ ctx.drawImage(img, 0, 0);
3925
+ const d = ctx.getImageData(0, 0, c.width, c.height).data;
3926
+ return map(d, c.width, c.height, 96);
3927
+ };
3928
+
3929
+ // \u2500\u2500 OBJECTS \u2014 every SPRITES entry, isolated on the palette ground \u2500
3930
+ try {
3931
+ const game = await import("./game.js");
3932
+ if (game.SPRITES) {
3933
+ out.objects = [];
3934
+ for (const [name, sp] of Object.entries(game.SPRITES)) {
3935
+ try {
3936
+ const w = sp.w ?? 200, h = sp.h ?? 200;
3937
+ const { d } = render(w, h, (ctx) => sp.draw(ctx));
3938
+ out.objects.push({ name, ...map(d, w, h, ${String(Math.min(48, cols))}), error: null });
3939
+ } catch (error) {
3940
+ out.objects.push({ name, error: String(error) });
3941
+ }
3942
+ }
3943
+ }
3944
+ } catch (_) { /* game import issues surface in GAMEPLAY */ }
3945
+
3946
+ // \u2500\u2500 IDENTITY \u2014 the authored scenes + tier-2 verdicts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3947
+ try {
3948
+ const poster = await import("./assets/poster.js");
3949
+ const { d, w, h } = render(540, 960, (ctx) => poster.drawPoster(ctx, 540, 960));
3950
+ const titleReport = analyzeTitleBand(d, w, h);
3951
+ let band = null;
3952
+ try { band = ctx.__frogoeTitleBand ?? null; } catch (e) { band = null; }
3953
+ out.identity.poster = {
3954
+ ...map(d, w, h, ${String(cols)}),
3955
+ titleReport,
3956
+ titleCollision: findTitleZoneCollision(d, w, h, band),
3957
+ titleBandDeclared: band !== null,
3958
+ };
3959
+ } catch (error) {
3960
+ out.identity.poster = { error: String(error) };
3961
+ }
3962
+ try {
3963
+ const icon = await import("./assets/icon.js");
3964
+ const { d, w, h } = render(512, 512, (ctx) => icon.drawIcon(ctx, 512));
3965
+ out.identity.icon = { ...map(d, w, h, 32), cornerReport: analyzeIconCorners(d, w, h) };
3966
+ } catch (error) {
3967
+ out.identity.icon = { error: String(error) };
3968
+ }
3969
+ return out;
3970
+ })()`;
3971
+ show = (view, pretty) => pretty ? view.pretty : view.plain;
3972
+ metricLine = (view) => `coverage ${(view.metrics.coverage * 100).toFixed(0)}% \xB7 dead-rows ${view.metrics.deadRows}/${view.metrics.rows}`;
3973
+ command5 = defineCommand5({
3974
+ args: {
3975
+ dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
3976
+ pretty: { type: "boolean", description: "truecolor half-block maps (human eyes)" },
3977
+ full: {
3978
+ type: "boolean",
3979
+ description: "all windows at full resolution (default: poster + gameplay, compact)"
3980
+ },
3981
+ poster: { type: "boolean", description: "poster map only" },
3982
+ icon: { type: "boolean", description: "icon map only" },
3983
+ gameplay: { type: "boolean", description: "gameplay frames only" },
3984
+ objects: { type: "boolean", description: "SPRITES objects only" }
3985
+ },
3986
+ async run({ args }) {
3987
+ const dir = path15.resolve(args.dir ? String(args.dir) : process.cwd());
3988
+ const briefPath = path15.join(dir, "BRIEF.md");
3989
+ if (!existsSync10(briefPath)) {
3990
+ throw new Error(
3991
+ "frogoe vision: no BRIEF.md in this folder \u2014 run this from a game (frogoe init)"
3992
+ );
3993
+ }
3994
+ const brief = parseBrief(readFileSync11(briefPath, "utf-8"));
3995
+ if (!brief) {
3996
+ throw new Error("frogoe vision: BRIEF.md is present but unparsable \u2014 fill the frontmatter");
3997
+ }
3998
+ const palette = {
3999
+ accent: brief.accent ?? "#ff3b3b",
4000
+ bg: brief.bg ?? "#101418",
4001
+ fg: brief.fg ?? "#ffffff",
4002
+ ...brief.outline ? { outline: brief.outline } : {}
4003
+ };
4004
+ const pretty = args.pretty === true;
4005
+ const wantAll = args.full === true;
4006
+ const wantPoster = args.poster === true || wantAll;
4007
+ const wantIcon = args.icon === true || wantAll;
4008
+ const wantGameplay = args.gameplay === true || !args.poster && !args.icon && !args.objects || wantAll;
4009
+ const wantObjects = args.objects === true || wantAll;
4010
+ const targeted = args.poster === true || args.icon === true || args.gameplay === true || args.objects === true;
4011
+ const compact = !wantAll;
4012
+ const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
4013
+ const server = await startServer2(dir);
4014
+ const { default: puppeteer } = await import("puppeteer-core");
4015
+ const browser = await puppeteer.launch({
4016
+ args: ["--no-sandbox", "--disable-gpu"],
4017
+ defaultViewport: { height: 844, width: 390 },
4018
+ executablePath: await ensureBrowser(),
4019
+ headless: true
4020
+ });
4021
+ let report;
4022
+ try {
4023
+ const page = await browser.newPage();
4024
+ await page.goto(server.urls.local, { timeout: 15e3, waitUntil: "domcontentloaded" });
4025
+ await page.waitForFunction("window.__frogoe !== undefined", { timeout: 15e3 });
4026
+ report = await page.evaluate(PAGE_SCRIPT(palette, compact ? 48 : 96));
4027
+ const frame = async (label) => {
4028
+ const b64 = await page.screenshot({ encoding: "base64", type: "png" });
4029
+ const view = await page.evaluate(`__frogoeMapShot(${JSON.stringify(b64)})`);
4030
+ report.gameplay.push({ label, ...view });
4031
+ };
4032
+ await new Promise((resolve2) => setTimeout(resolve2, 1200));
4033
+ await frame("ready");
4034
+ await page.evaluate(
4035
+ '(() => { const cx = innerWidth / 2, cy = innerHeight / 2; for (const t of ["pointerdown", "pointerup"]) window.dispatchEvent(new PointerEvent(t, { clientX: cx, clientY: cy, bubbles: true })); })()'
4036
+ );
4037
+ await new Promise((resolve2) => setTimeout(resolve2, 800));
4038
+ await frame("action");
4039
+ } finally {
4040
+ await browser.close();
4041
+ server.stop();
4042
+ }
4043
+ const legend = `bg '${palette.bg}' \u2192 . fg '${palette.fg}' \u2192 O accent '${palette.accent}' \u2192 X${palette.outline ? ` outline '${palette.outline}' \u2192 #` : ""} ramp @%&*8=+~;:,-^\`' (dark\u2192light)`;
4044
+ console.log(`frogoe vision \u2014 ${dir}`);
4045
+ console.log(`palette: ${legend}
4046
+ `);
4047
+ if (wantObjects) {
4048
+ console.log("\u2500\u2500 OBJECTS (SPRITES) " + "\u2500".repeat(28));
4049
+ if (report.objects === null) {
4050
+ console.log(" (none \u2014 export SPRITES from game.js to see each object)\n");
4051
+ } else {
4052
+ for (const obj of report.objects) {
4053
+ if (obj.error !== null && obj.error !== void 0) {
4054
+ console.log(`${obj.name}: ERROR ${obj.error.slice(0, 120)}`);
4055
+ continue;
4056
+ }
4057
+ console.log(`${obj.name} (${obj.cols}\xD7${obj.rows} map) \u2014 ${metricLine(obj)}`);
4058
+ console.log(show(obj, pretty));
4059
+ console.log();
4060
+ }
4061
+ }
4062
+ }
4063
+ if (wantGameplay) {
4064
+ console.log("\u2500\u2500 GAMEPLAY (full page: world + HUD) " + "\u2500".repeat(16));
4065
+ for (const frame of report.gameplay) {
4066
+ console.log(`${frame.label} \u2014 ${metricLine(frame)}`);
4067
+ console.log(show(frame, pretty));
4068
+ console.log();
4069
+ }
4070
+ }
4071
+ if (wantPoster || wantIcon) {
4072
+ console.log("\u2500\u2500 IDENTITY " + "\u2500".repeat(38));
4073
+ }
4074
+ if (wantPoster) {
4075
+ const poster = report.identity.poster;
4076
+ if (poster === void 0 || "error" in poster) {
4077
+ console.log(
4078
+ `poster: MISSING (${String(poster?.error ?? "assets/poster.js not found").slice(0, 90)})`
4079
+ );
4080
+ } else {
4081
+ const verdict = verifyTitleReadability(poster.titleReport, 540);
4082
+ const share = poster.titleReport.inkTotal > 0 ? poster.titleReport.inkCount / poster.titleReport.inkTotal : 0;
4083
+ console.log(
4084
+ `poster \u2014 ${metricLine(poster)} \xB7 title ${verdict === null ? "\u2713" : `\u2717 ${verdict.slice(0, 100)}`} \xB7 ink ${(share * 100).toFixed(1)}%`
4085
+ );
4086
+ console.log(show(poster, pretty));
4087
+ }
4088
+ }
4089
+ if (wantIcon) {
4090
+ const icon = report.identity.icon;
4091
+ if (icon === void 0 || "error" in icon) {
4092
+ console.log(
4093
+ `icon: MISSING (${String(icon?.error ?? "assets/icon.js not found").slice(0, 90)})`
4094
+ );
4095
+ } else {
4096
+ const hexes = [palette.bg, palette.fg, palette.accent, palette.outline ?? palette.bg];
4097
+ const verdict = verifyIconFullbleed(icon.cornerReport, hexes);
4098
+ console.log(
4099
+ `icon \u2014 ${metricLine(icon)} \xB7 fullbleed ${verdict === null ? "\u2713" : `\u2717 ${verdict.slice(0, 100)}`}`
4100
+ );
4101
+ console.log(show(icon, pretty));
4102
+ }
4103
+ }
4104
+ if (!targeted && !existsSync10(path15.join(dir, "assets", "poster.js"))) {
4105
+ console.log("\n(note: run `frogoe check` first \u2014 vision only looks, it never gates)");
4106
+ }
4107
+ },
4108
+ meta: {
4109
+ description: "eyes for draw code: objects, gameplay frames, and identity art as ASCII maps"
4110
+ }
4111
+ });
4112
+ }
4113
+ });
4114
+
2631
4115
  // src/commands/init.ts
2632
4116
  var init_exports = {};
2633
4117
  __export(init_exports, {
2634
- command: () => command4
4118
+ command: () => command6
2635
4119
  });
2636
- import { defineCommand as defineCommand4 } from "citty";
2637
- var command4;
4120
+ import { defineCommand as defineCommand6 } from "citty";
4121
+ var command6;
2638
4122
  var init_init2 = __esm({
2639
4123
  "src/commands/init.ts"() {
2640
4124
  "use strict";
2641
4125
  init_init();
2642
- command4 = defineCommand4({
4126
+ command6 = defineCommand6({
2643
4127
  args: {
2644
4128
  force: { type: "boolean", description: "rematerialize over an existing game" },
2645
4129
  name: { type: "positional", description: "folder to create" }
@@ -2662,15 +4146,15 @@ var init_init2 = __esm({
2662
4146
  // src/commands/lint.ts
2663
4147
  var lint_exports = {};
2664
4148
  __export(lint_exports, {
2665
- command: () => command5
4149
+ command: () => command7
2666
4150
  });
2667
- import { defineCommand as defineCommand5 } from "citty";
2668
- var command5;
4151
+ import { defineCommand as defineCommand7 } from "citty";
4152
+ var command7;
2669
4153
  var init_lint = __esm({
2670
4154
  "src/commands/lint.ts"() {
2671
4155
  "use strict";
2672
4156
  init_check2();
2673
- command5 = defineCommand5({
4157
+ command7 = defineCommand7({
2674
4158
  args: {
2675
4159
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2676
4160
  json: { type: "boolean", description: "machine-readable findings" }
@@ -2697,17 +4181,17 @@ var init_lint = __esm({
2697
4181
  // src/commands/report.ts
2698
4182
  var report_exports = {};
2699
4183
  __export(report_exports, {
2700
- command: () => command6
4184
+ command: () => command8
2701
4185
  });
2702
- import { readFileSync as readFileSync6 } from "fs";
2703
- import { defineCommand as defineCommand6 } from "citty";
2704
- var command6;
4186
+ import { readFileSync as readFileSync12 } from "fs";
4187
+ import { defineCommand as defineCommand8 } from "citty";
4188
+ var command8;
2705
4189
  var init_report = __esm({
2706
4190
  "src/commands/report.ts"() {
2707
4191
  "use strict";
2708
4192
  init_records();
2709
4193
  init_session();
2710
- command6 = defineCommand6({
4194
+ command8 = defineCommand8({
2711
4195
  args: {
2712
4196
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
2713
4197
  },
@@ -2718,7 +4202,7 @@ var init_report = __esm({
2718
4202
  console.log(`frogoe report: no sessions in ${dir} \u2014 play a run under \`frogoe run\` first`);
2719
4203
  return;
2720
4204
  }
2721
- const records = readFileSync6(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
4205
+ const records = readFileSync12(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
2722
4206
  const s = summarizeRecords(records);
2723
4207
  console.log(`
2724
4208
  frogoe report \u2014 ${file}`);
@@ -2756,8 +4240,8 @@ var init_firewall = __esm({
2756
4240
  return null;
2757
4241
  }
2758
4242
  };
2759
- binaryBlocked = (path10) => {
2760
- const out = run(`--getappblocked "${path10}"`);
4243
+ binaryBlocked = (path17) => {
4244
+ const out = run(`--getappblocked "${path17}"`);
2761
4245
  if (out === null) return null;
2762
4246
  if (/blocked/iu.test(out)) return true;
2763
4247
  if (/allowed/iu.test(out)) return false;
@@ -2815,9 +4299,9 @@ var init_plan = __esm({
2815
4299
 
2816
4300
  // src/net/tunnel.ts
2817
4301
  import { spawn, spawnSync } from "child_process";
2818
- import { existsSync as existsSync6, mkdirSync as mkdirSync6, chmodSync, writeFileSync as writeFileSync5 } from "fs";
4302
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, chmodSync, writeFileSync as writeFileSync6 } from "fs";
2819
4303
  import os3 from "os";
2820
- import path9 from "path";
4304
+ import path16 from "path";
2821
4305
  import { gunzipSync } from "zlib";
2822
4306
  var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
2823
4307
  var init_tunnel = __esm({
@@ -2874,9 +4358,9 @@ var init_tunnel = __esm({
2874
4358
  };
2875
4359
  binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
2876
4360
  cacheBase = (platform, env, home) => {
2877
- if (platform === "darwin") return path9.join(home, "Library", "Caches");
2878
- if (platform === "win32") return env.LOCALAPPDATA ?? path9.join(home, "AppData", "Local");
2879
- return path9.join(home, ".cache");
4361
+ if (platform === "darwin") return path16.join(home, "Library", "Caches");
4362
+ if (platform === "win32") return env.LOCALAPPDATA ?? path16.join(home, "AppData", "Local");
4363
+ return path16.join(home, ".cache");
2880
4364
  };
2881
4365
  resolveLatestTag = async () => {
2882
4366
  const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
@@ -2927,15 +4411,15 @@ var init_tunnel = __esm({
2927
4411
  );
2928
4412
  }
2929
4413
  const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
2930
- const root = path9.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
2931
- const dir = path9.join(root, tag);
2932
- const bin = path9.join(dir, binaryFileName(platform));
2933
- const rootResolved = path9.resolve(root);
2934
- const binResolved = path9.resolve(bin);
2935
- if (!binResolved.startsWith(rootResolved + path9.sep)) {
4414
+ const root = path16.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
4415
+ const dir = path16.join(root, tag);
4416
+ const bin = path16.join(dir, binaryFileName(platform));
4417
+ const rootResolved = path16.resolve(root);
4418
+ const binResolved = path16.resolve(bin);
4419
+ if (!binResolved.startsWith(rootResolved + path16.sep)) {
2936
4420
  throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
2937
4421
  }
2938
- if (existsSync6(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
4422
+ if (existsSync11(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
2939
4423
  onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
2940
4424
  const res = await fetch(
2941
4425
  `https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
@@ -2946,8 +4430,8 @@ var init_tunnel = __esm({
2946
4430
  });
2947
4431
  const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
2948
4432
  if (!binary) throw new Error("cloudflared archive did not contain the binary");
2949
- mkdirSync6(dir, { recursive: true });
2950
- writeFileSync5(bin, binary);
4433
+ mkdirSync8(dir, { recursive: true });
4434
+ writeFileSync6(bin, binary);
2951
4435
  if (platform !== "win32") chmodSync(bin, 493);
2952
4436
  if (!binaryEchoes(bin, tag)) {
2953
4437
  throw new Error(
@@ -3034,10 +4518,10 @@ var init_tunnel = __esm({
3034
4518
  // src/commands/run.ts
3035
4519
  var run_exports2 = {};
3036
4520
  __export(run_exports2, {
3037
- command: () => command7
4521
+ command: () => command9
3038
4522
  });
3039
- import { defineCommand as defineCommand7 } from "citty";
3040
- var NUDGE_MS, printQr, message, command7;
4523
+ import { defineCommand as defineCommand9 } from "citty";
4524
+ var NUDGE_MS, printQr, message, command9;
3041
4525
  var init_run2 = __esm({
3042
4526
  "src/commands/run.ts"() {
3043
4527
  "use strict";
@@ -3048,6 +4532,7 @@ var init_run2 = __esm({
3048
4532
  init_run();
3049
4533
  NUDGE_MS = 1e4;
3050
4534
  printQr = (url) => {
4535
+ if (!process.stdout.isTTY) return;
3051
4536
  void import("qrcode-terminal").then((mod) => {
3052
4537
  const qrcode = mod.default ?? mod;
3053
4538
  qrcode.generate(url, { small: true }, (qr) => console.log(qr));
@@ -3056,7 +4541,7 @@ var init_run2 = __esm({
3056
4541
  });
3057
4542
  };
3058
4543
  message = (error) => error instanceof Error ? error.message : String(error);
3059
- command7 = defineCommand7({
4544
+ command9 = defineCommand9({
3060
4545
  args: {
3061
4546
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
3062
4547
  port: { type: "string", description: "port (default: random free)" },
@@ -3158,8 +4643,8 @@ var init_run2 = __esm({
3158
4643
 
3159
4644
  // src/utils/skillsManifest.ts
3160
4645
  import { execFile } from "child_process";
3161
- import { createHash as createHash2 } from "crypto";
3162
- import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
4646
+ import { createHash as createHash3 } from "crypto";
4647
+ import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
3163
4648
  import { homedir } from "os";
3164
4649
  import { isAbsolute, join, relative, resolve, sep } from "path";
3165
4650
  import { promisify } from "util";
@@ -3172,7 +4657,7 @@ function listFilesSorted(dir) {
3172
4657
  for (const name of readdirSync3(d)) {
3173
4658
  if (name === ".DS_Store") continue;
3174
4659
  const p = join(d, name);
3175
- if (statSync2(p).isDirectory()) walk(p);
4660
+ if (statSync3(p).isDirectory()) walk(p);
3176
4661
  else out.push(p);
3177
4662
  }
3178
4663
  };
@@ -3181,21 +4666,21 @@ function listFilesSorted(dir) {
3181
4666
  }
3182
4667
  function hashSkillBundle(skillDir) {
3183
4668
  const files = listFilesSorted(skillDir);
3184
- const h = createHash2("sha256");
4669
+ const h = createHash3("sha256");
3185
4670
  for (const f of files) {
3186
4671
  const rel = relative(skillDir, f).split(sep).join("/");
3187
4672
  h.update(rel);
3188
4673
  h.update("\0");
3189
4674
  const ext = rel.slice(rel.lastIndexOf("."));
3190
- const buf = readFileSync7(f);
4675
+ const buf = readFileSync13(f);
3191
4676
  if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
3192
4677
  else h.update(buf);
3193
4678
  h.update("\0");
3194
4679
  }
3195
4680
  return { hash: h.digest("hex").slice(0, 16), files: files.length };
3196
4681
  }
3197
- function buildManifest(skillsRoot, meta) {
3198
- const names = readdirSync3(skillsRoot).filter((n) => existsSync7(join(skillsRoot, n, "SKILL.md"))).sort();
4682
+ function buildManifest2(skillsRoot, meta) {
4683
+ const names = readdirSync3(skillsRoot).filter((n) => existsSync12(join(skillsRoot, n, "SKILL.md"))).sort();
3199
4684
  const skills = {};
3200
4685
  for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
3201
4686
  return { source: meta.source, skills };
@@ -3220,7 +4705,7 @@ function discoverSkillRoots(base, scope) {
3220
4705
  const candidates = [];
3221
4706
  const add = (hostBase, host) => {
3222
4707
  const dir = join(hostBase, host, "skills");
3223
- if (existsSync7(dir) && statSync2(dir).isDirectory())
4708
+ if (existsSync12(dir) && statSync3(dir).isDirectory())
3224
4709
  candidates.push({ dir, agent: agentLabel(host), scope });
3225
4710
  };
3226
4711
  for (const host of listSubdirs(base)) add(base, host);
@@ -3247,7 +4732,7 @@ function scopeForDir(dir, home, cwd) {
3247
4732
  }
3248
4733
  function locateInstall(skillNames, opts = {}) {
3249
4734
  if (opts.dir) {
3250
- return existsSync7(opts.dir) ? {
4735
+ return existsSync12(opts.dir) ? {
3251
4736
  dir: opts.dir,
3252
4737
  agent: agentFromDir(opts.dir),
3253
4738
  scope: scopeForDir(opts.dir, opts.home ?? homedir(), opts.cwd ?? process.cwd())
@@ -3258,7 +4743,7 @@ function locateInstall(skillNames, opts = {}) {
3258
4743
  ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project")
3259
4744
  ];
3260
4745
  for (const root of roots) {
3261
- if (skillNames.some((n) => existsSync7(join(root.dir, n, "SKILL.md")))) return root;
4746
+ if (skillNames.some((n) => existsSync12(join(root.dir, n, "SKILL.md")))) return root;
3262
4747
  }
3263
4748
  return null;
3264
4749
  }
@@ -3266,7 +4751,7 @@ function hashInstalled(root, skillNames) {
3266
4751
  const out = {};
3267
4752
  for (const name of skillNames) {
3268
4753
  const skillDir = join(root.dir, name);
3269
- if (existsSync7(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
4754
+ if (existsSync12(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
3270
4755
  }
3271
4756
  return out;
3272
4757
  }
@@ -3303,7 +4788,7 @@ function findRepoManifest(cwd = process.cwd()) {
3303
4788
  let dir = cwd;
3304
4789
  for (let i = 0; i < 16; i++) {
3305
4790
  const p = join(dir, MANIFEST_FILE);
3306
- if (existsSync7(p)) return p;
4791
+ if (existsSync12(p)) return p;
3307
4792
  const parent = join(dir, "..");
3308
4793
  if (parent === dir) break;
3309
4794
  dir = parent;
@@ -3343,9 +4828,9 @@ async function remoteHeadSha(repoSlug) {
3343
4828
  }
3344
4829
  function resolveLocalManifest(source) {
3345
4830
  const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
3346
- if (existsSync7(direct)) return JSON.parse(readFileSync7(direct, "utf8"));
4831
+ if (existsSync12(direct)) return JSON.parse(readFileSync13(direct, "utf8"));
3347
4832
  const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
3348
- if (existsSync7(skillsRoot)) return buildManifest(skillsRoot, { source: skillsRoot });
4833
+ if (existsSync12(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
3349
4834
  throw new Error(`No skills manifest found at: ${source}`);
3350
4835
  }
3351
4836
  async function fetchRemoteManifest(source) {
@@ -3368,7 +4853,7 @@ async function resolveLatestManifest(source, cwd = process.cwd(), opts = {}) {
3368
4853
  }
3369
4854
  if (!source && !opts.canonical) {
3370
4855
  const repoManifest = findRepoManifest(cwd);
3371
- if (repoManifest) return JSON.parse(readFileSync7(repoManifest, "utf8"));
4856
+ if (repoManifest) return JSON.parse(readFileSync13(repoManifest, "utf8"));
3372
4857
  }
3373
4858
  return fetchRemoteManifest(source);
3374
4859
  }
@@ -3419,9 +4904,9 @@ var init_skillsManifest = __esm({
3419
4904
  // src/commands/skills.ts
3420
4905
  var skills_exports = {};
3421
4906
  __export(skills_exports, {
3422
- command: () => command8
4907
+ command: () => command10
3423
4908
  });
3424
- import { defineCommand as defineCommand8 } from "citty";
4909
+ import { defineCommand as defineCommand10 } from "citty";
3425
4910
  import { execFileSync, spawn as spawn2 } from "child_process";
3426
4911
  function hasNpx() {
3427
4912
  try {
@@ -3513,7 +4998,7 @@ function renderCheck(result) {
3513
4998
  }
3514
4999
  console.log();
3515
5000
  }
3516
- var GLOBAL_INSTALL_ARGS_TAIL, SOURCE_URL, checkCommand, updateCommand, command8;
5001
+ var GLOBAL_INSTALL_ARGS_TAIL, SOURCE_URL, checkCommand, updateCommand, command10;
3517
5002
  var init_skills = __esm({
3518
5003
  "src/commands/skills.ts"() {
3519
5004
  "use strict";
@@ -3528,7 +5013,7 @@ var init_skills = __esm({
3528
5013
  "--yes"
3529
5014
  ];
3530
5015
  SOURCE_URL = "https://github.com/frogoe/engine";
3531
- checkCommand = defineCommand8({
5016
+ checkCommand = defineCommand10({
3532
5017
  meta: { name: "check", description: "Check whether installed skills are the latest version" },
3533
5018
  args: {
3534
5019
  json: { type: "boolean", description: "Output as JSON", default: false },
@@ -3560,7 +5045,7 @@ var init_skills = __esm({
3560
5045
  }
3561
5046
  }
3562
5047
  });
3563
- updateCommand = defineCommand8({
5048
+ updateCommand = defineCommand10({
3564
5049
  meta: {
3565
5050
  name: "update",
3566
5051
  description: "Update frogoe skills to the latest (core + installed). Pass names to also install them."
@@ -3607,7 +5092,7 @@ var init_skills = __esm({
3607
5092
  }
3608
5093
  }
3609
5094
  });
3610
- command8 = defineCommand8({
5095
+ command10 = defineCommand10({
3611
5096
  meta: {
3612
5097
  name: "skills",
3613
5098
  description: "Install, check, and update frogoe skills for AI coding tools"
@@ -3630,12 +5115,12 @@ var init_skills = __esm({
3630
5115
  });
3631
5116
 
3632
5117
  // src/cli.ts
3633
- import { defineCommand as defineCommand9, runMain } from "citty";
5118
+ import { defineCommand as defineCommand11, runMain } from "citty";
3634
5119
 
3635
5120
  // package.json
3636
5121
  var package_default = {
3637
5122
  name: "frogoe",
3638
- version: "0.3.1",
5123
+ version: "0.5.5",
3639
5124
  description: "froge CLI \u2014 the agent's hands: init, add, run, check, bundle",
3640
5125
  homepage: "https://github.com/frogoe/engine#readme",
3641
5126
  bugs: "https://github.com/frogoe/engine/issues",
@@ -3698,6 +5183,16 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
3698
5183
  console.log(VERSION);
3699
5184
  process.exit(0);
3700
5185
  }
5186
+ if (process.argv.includes("--source")) {
5187
+ console.log("# frogoe CLI source of truth");
5188
+ console.log("# repo: https://github.com/frogoe/engine");
5189
+ console.log("# cli: packages/cli/src/");
5190
+ console.log("# skills: skills/");
5191
+ console.log("# Never read the installed dist/ \u2014 it is bundled/minified.");
5192
+ console.log("# Clone the repo to read the readable source:");
5193
+ console.log("# git clone https://github.com/frogoe/engine");
5194
+ process.exit(0);
5195
+ }
3701
5196
  var HELP = `frogoe ${VERSION} \u2014 write a closure, ship a game
3702
5197
 
3703
5198
  Commands:
@@ -3706,17 +5201,90 @@ Commands:
3706
5201
  run [dir] serve with live reload + phone QR (--tunnel: any network)
3707
5202
  lint [dir] static contract lint \u2014 fast iteration (stable codes; --json)
3708
5203
  check [dir] full gate: lint + live Chrome sandbox (FPS, HUD outline)
5204
+ --fast: static only, no Chrome (quick iteration)
3709
5205
  report [dir] last playtest session: fps dips, errors, when
3710
5206
  bundle [dir] dissolve externals \u2192 one self-contained HTML
5207
+ embed [dir] wrap the bundle in a card (poster + manifest)
5208
+ vision [dir] see the game: objects, frames, art as ASCII maps
5209
+ --poster/--icon/--gameplay/--objects: one window only
5210
+ --full: all windows (default is poster + gameplay)
3711
5211
  skills [check|update] skill freshness \u2014 check or update via npx skills add
3712
5212
 
3713
5213
  Docs: skills/frogoe-core \u2014 the whole contract in five references.`;
3714
- var main = defineCommand9({
5214
+ var TEACHING_ERROR_PATTERNS = [
5215
+ /^frogoe /u,
5216
+ // "frogoe add: unknown block..."
5217
+ /^bundle\//u,
5218
+ // "bundle/art-missing — ..."
5219
+ /^art-/u,
5220
+ // "art-crash — ..."
5221
+ /— run /u,
5222
+ // "— run `frogoe check` first"
5223
+ /— see /u,
5224
+ // "— see frogoe-creative → references/art.md"
5225
+ /Available: /u,
5226
+ // "Available: score-card, fuel-gauge, ..."
5227
+ /fix: /u,
5228
+ // lint findings carry fix:
5229
+ /recipe: /u
5230
+ // lint findings carry recipe:
5231
+ ];
5232
+ var isTeachingError = (message2) => TEACHING_ERROR_PATTERNS.some((pattern) => pattern.test(message2));
5233
+ var ORIGINAL_CONSOLE_ERROR = console.error;
5234
+ console.error = (...args) => {
5235
+ const first = args[0];
5236
+ if (first instanceof Error && process.env.FROGOE_DEBUG !== "1") {
5237
+ const msg = first.message;
5238
+ if (isTeachingError(msg)) {
5239
+ ORIGINAL_CONSOLE_ERROR(`
5240
+ ${msg}
5241
+ `);
5242
+ ORIGINAL_CONSOLE_ERROR(
5243
+ ` (self-resolving: apply the fix above. This CLI is open-source \u2014
5244
+ readable code at packages/cli/src/, never read dist/.)
5245
+ `
5246
+ );
5247
+ return;
5248
+ }
5249
+ }
5250
+ ORIGINAL_CONSOLE_ERROR(...args);
5251
+ };
5252
+ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
5253
+ "--json",
5254
+ "--fast",
5255
+ "--force",
5256
+ "--out",
5257
+ "--tunnel",
5258
+ "--port",
5259
+ "--pretty",
5260
+ "--full",
5261
+ "--poster",
5262
+ "--icon",
5263
+ "--gameplay",
5264
+ "--objects",
5265
+ "--check",
5266
+ "--update",
5267
+ "--verbose"
5268
+ ]);
5269
+ var UNKNOWN_FLAG = /^--[a-z][a-z0-9-]*$/u;
5270
+ var unknownFlags = process.argv.filter(
5271
+ (arg) => UNKNOWN_FLAG.test(arg) && !KNOWN_FLAGS.has(arg) && arg !== "--version" && arg !== "-v" && arg !== "--help" && arg !== "-h"
5272
+ );
5273
+ if (unknownFlags.length > 0 && process.env.FROGOE_DEBUG !== "1") {
5274
+ ORIGINAL_CONSOLE_ERROR(`
5275
+ frogoe: unknown flag ${unknownFlags[0]} \u2014 did you mean --json?`);
5276
+ ORIGINAL_CONSOLE_ERROR(` (this fails loudly so a typo can't silently produce wrong output.)
5277
+ `);
5278
+ process.exit(1);
5279
+ }
5280
+ var main = defineCommand11({
3715
5281
  meta: { description: HELP },
3716
5282
  subCommands: {
3717
5283
  add: () => Promise.resolve().then(() => (init_add2(), add_exports)).then((m) => m.command),
3718
5284
  bundle: () => Promise.resolve().then(() => (init_bundle2(), bundle_exports)).then((m) => m.command),
3719
5285
  check: () => Promise.resolve().then(() => (init_check3(), check_exports)).then((m) => m.command),
5286
+ embed: () => Promise.resolve().then(() => (init_embed(), embed_exports)).then((m) => m.command),
5287
+ vision: () => Promise.resolve().then(() => (init_vision(), vision_exports)).then((m) => m.command),
3720
5288
  init: () => Promise.resolve().then(() => (init_init2(), init_exports)).then((m) => m.command),
3721
5289
  lint: () => Promise.resolve().then(() => (init_lint(), lint_exports)).then((m) => m.command),
3722
5290
  report: () => Promise.resolve().then(() => (init_report(), report_exports)).then((m) => m.command),