frogoe 0.3.2 → 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();
@@ -198,10 +220,25 @@ var init_add = __esm({
198
220
  const styleOpen = source.indexOf("<style>");
199
221
  const styleClose = source.indexOf("</style>");
200
222
  const css = styleOpen === -1 || styleClose === -1 || styleClose < styleOpen ? null : source.slice(styleOpen + "<style>".length, styleClose);
201
- return {
202
- css: css?.trim() ?? null,
203
- markup: (styleClose === -1 ? "" : source.slice(styleClose + "</style>".length)).trim()
204
- };
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 };
205
242
  };
206
243
  blockMarker = (name) => `<!-- frogoe:block:${name} -->`;
207
244
  injectIntoHtml = (html, name, css, markup, placement) => {
@@ -532,9 +569,9 @@ var init_bundle = __esm({
532
569
  });
533
570
  const seen = /* @__PURE__ */ new Map();
534
571
  const resolveFont = async (url) => {
535
- const cached = seen.get(url);
536
- if (cached) {
537
- return cached;
572
+ const cached2 = seen.get(url);
573
+ if (cached2) {
574
+ return cached2;
538
575
  }
539
576
  assertAllowedRemote(url, options.extraAllowedHosts);
540
577
  const res = options.fetchImpl ? await options.fetchImpl(url, { headers: { "user-agent": FONT_UA } }) : await fetch(url, { headers: { "user-agent": FONT_UA } });
@@ -670,63 +707,6 @@ ${css}
670
707
  }
671
708
  });
672
709
 
673
- // src/commands/bundle.ts
674
- var bundle_exports = {};
675
- __export(bundle_exports, {
676
- command: () => command2
677
- });
678
- import { defineCommand as defineCommand2 } from "citty";
679
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
680
- import path4 from "path";
681
- var command2;
682
- var init_bundle2 = __esm({
683
- "src/commands/bundle.ts"() {
684
- "use strict";
685
- init_bundle();
686
- command2 = defineCommand2({
687
- args: {
688
- dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
689
- json: { type: "boolean", description: "machine-readable report" },
690
- out: { type: "string", description: "output path (default: dist/index.html)" }
691
- },
692
- async run({ args }) {
693
- const dir = args.dir ? String(args.dir) : process.cwd();
694
- const report = await bundle({ dir });
695
- const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(dir, "dist", "index.html");
696
- mkdirSync3(path4.dirname(outPath), { recursive: true });
697
- writeFileSync3(outPath, report.artifact, "utf-8");
698
- for (const warning of report.warnings) {
699
- console.log(` \u26A0 ${warning}`);
700
- }
701
- if (args.json) {
702
- console.log(
703
- JSON.stringify(
704
- {
705
- artifact: outPath,
706
- assets: report.assets,
707
- bytes: report.bytes,
708
- sha256: report.sha256,
709
- warnings: report.warnings
710
- },
711
- null,
712
- 2
713
- )
714
- );
715
- } else {
716
- console.log(` frogoe bundle \u2192 ${outPath}`);
717
- console.log(
718
- ` ${report.bytes} bytes \xB7 ${report.assets.length} dissolved asset(s) \xB7 sha256 ${report.sha256.slice(0, 12)}`
719
- );
720
- for (const asset of report.assets) {
721
- console.log(` ${asset.kind.padEnd(5)} ${asset.source}`);
722
- }
723
- }
724
- },
725
- meta: { description: "dissolve externals into one self-contained HTML" }
726
- });
727
- }
728
- });
729
-
730
710
  // ../lint/src/brief.ts
731
711
  var KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
732
712
  var init_brief = __esm({
@@ -786,6 +766,177 @@ var init_brief = __esm({
786
766
  }
787
767
  });
788
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
+
789
940
  // ../lint/src/contrast.ts
790
941
  var HEX, isHex, contrastRatio;
791
942
  var init_contrast = __esm({
@@ -812,7 +963,7 @@ var init_contrast = __esm({
812
963
  });
813
964
 
814
965
  // ../lint/src/check.ts
815
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "fs";
966
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5 } from "fs";
816
967
  import path5 from "path";
817
968
  var VERBS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
818
969
  var init_check = __esm({
@@ -820,10 +971,11 @@ var init_check = __esm({
820
971
  "use strict";
821
972
  init_brief();
822
973
  init_contrast();
974
+ init_art();
823
975
  VERBS = /* @__PURE__ */ new Set(["tap", "hold", "steer", "aim"]);
824
976
  read = (file) => {
825
977
  try {
826
- return readFileSync4(file, "utf-8");
978
+ return readFileSync5(file, "utf-8");
827
979
  } catch {
828
980
  return "";
829
981
  }
@@ -835,7 +987,7 @@ var init_check = __esm({
835
987
  };
836
988
  checkBrief = (dir, findings) => {
837
989
  const file = path5.join(dir, "BRIEF.md");
838
- if (!existsSync4(file)) {
990
+ if (!existsSync5(file)) {
839
991
  findings.push({
840
992
  code: "brief/missing",
841
993
  file: "BRIEF.md",
@@ -1068,7 +1220,7 @@ var init_check = __esm({
1068
1220
  }
1069
1221
  const blocksDir = path5.join(dir, "blocks");
1070
1222
  let markup = index;
1071
- if (existsSync4(blocksDir)) {
1223
+ if (existsSync5(blocksDir)) {
1072
1224
  for (const f of readdirSync(blocksDir)) {
1073
1225
  if (f.endsWith(".html")) {
1074
1226
  markup += read(path5.join(blocksDir, f));
@@ -1092,7 +1244,7 @@ var init_check = __esm({
1092
1244
  };
1093
1245
  checkPin = (dir, findings) => {
1094
1246
  const pinFile = path5.join(dir, "frogoe.json");
1095
- if (!existsSync4(pinFile)) {
1247
+ if (!existsSync5(pinFile)) {
1096
1248
  findings.push({
1097
1249
  code: "folder/contract-pin",
1098
1250
  file: "frogoe.json",
@@ -1129,6 +1281,7 @@ var init_check = __esm({
1129
1281
  };
1130
1282
  checkProject = (dir) => {
1131
1283
  const findings = [];
1284
+ checkArt(dir, findings);
1132
1285
  checkBrief(dir, findings);
1133
1286
  checkFolder(dir, findings);
1134
1287
  checkPin(dir, findings);
@@ -1146,1305 +1299,1831 @@ var init_check = __esm({
1146
1299
  var init_src = __esm({
1147
1300
  "../lint/src/index.ts"() {
1148
1301
  "use strict";
1302
+ init_art();
1149
1303
  init_brief();
1150
1304
  init_contrast();
1151
1305
  init_check();
1152
1306
  }
1153
1307
  });
1154
1308
 
1155
- // src/check.ts
1156
- var formatFindings;
1157
- var init_check2 = __esm({
1158
- "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"() {
1159
1316
  "use strict";
1160
- init_src();
1161
- init_src();
1162
- init_src();
1163
- formatFindings = (result) => result.findings.map((f) => {
1164
- const at = f.line !== void 0 ? `:${f.line}` : "";
1165
- const head = `${f.severity === "error" ? "\u2716" : "\u26A0"} ${f.code} ${f.file}${at}`;
1166
- return `${head}
1167
- ${f.message}
1168
- fix: ${f.fix}`;
1169
- }).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
+ };
1170
1349
  }
1171
1350
  });
1172
1351
 
1173
- // src/live/sampler.ts
1174
- 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;
1175
- var init_sampler = __esm({
1176
- "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"() {
1177
1356
  "use strict";
1178
- PROBE_SCRIPT = `(() => {
1179
- if (window.__frogoeProbe) return;
1180
- const probe = { audioContexts: [], audioEverRan: false, finish: [], fps: [] };
1181
- Object.defineProperty(window, "__frogoeProbe", { value: probe });
1182
- document.addEventListener("frogoe:finish", (e) => {
1183
- const d = e && e.detail;
1184
- const score = d && typeof d.score === "number" ? d.score : null;
1185
- probe.finish.push({ at: Math.round(performance.now()), score });
1186
- });
1187
- const wrap = (Native) => class extends Native {
1188
- constructor(...args) {
1189
- super(...args);
1190
- probe.audioContexts.push(this);
1191
- this.addEventListener("statechange", () => {
1192
- if (this.state === "running") probe.audioEverRan = true;
1193
- });
1194
- }
1195
- };
1196
- const AC = window.AudioContext;
1197
- if (AC && !AC.__frogoeHooked) {
1198
- Object.defineProperty(wrap(AC), "__frogoeHooked", { value: true });
1199
- window.AudioContext = wrap(AC);
1200
- }
1201
- if (window.webkitAudioContext) {
1202
- window.webkitAudioContext = wrap(window.webkitAudioContext);
1203
- }
1204
- let count = 0;
1205
- let secStart = performance.now();
1206
- const tick = () => {
1207
- count++;
1208
- const now = performance.now();
1209
- if (now - secStart >= 1000) {
1210
- probe.fps.push(count);
1211
- count = 0;
1212
- secStart = now;
1213
- }
1214
- requestAnimationFrame(tick);
1215
- };
1216
- requestAnimationFrame(tick);
1217
- })()`;
1218
- DOM_PROBE_SCRIPT = `(() => {
1219
- return {
1220
- canvasPresent: Boolean(document.querySelector("#c")),
1221
- hudPresent: Boolean(document.querySelector(".hud")),
1222
- state: window.__frogoe?.state ?? "(missing)",
1223
- };
1224
- })()`;
1225
- CANVAS_PAINTED_SCRIPT = `(() => {
1226
- const c = document.querySelector("#c");
1227
- if (!c) return false;
1228
- const g = c.getContext("2d");
1229
- if (!g) return false;
1230
- const px = Math.floor(c.width * 0.25), py = Math.floor(c.height * 0.25);
1231
- const pw = Math.floor(c.width * 0.5), ph = Math.floor(c.height * 0.5);
1232
- const s = g.getImageData(px, py, pw, ph).data;
1233
- for (let i = 3; i < s.length; i += 4) { if (s[i] !== 0) return true; }
1234
- return false;
1235
- })()`;
1236
- CANVAS_HASH_SCRIPT = `(() => {
1237
- const c = document.querySelector("#c");
1238
- if (!c) return null;
1239
- const g = c.getContext("2d");
1240
- if (!g) return null;
1241
- const cx = Math.floor(c.width * 0.25), cy = Math.floor(c.height * 0.25);
1242
- const cw = Math.floor(c.width * 0.5), ch = Math.floor(c.height * 0.5);
1243
- const d = g.getImageData(cx, cy, cw, ch).data;
1244
- let hash = 0;
1245
- for (let i = 0; i < d.length; i += 4) {
1246
- hash = (hash * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
1247
- }
1248
- return hash;
1249
- })()`;
1250
- GAME_STATE_SCRIPT = `window.__frogoe?.state ?? "(missing)"`;
1251
- FINISH_EVENTS_SCRIPT = `(() => {
1252
- const p = window.__frogoeProbe;
1253
- return p ? p.finish.slice() : [];
1254
- })()`;
1255
- FPS_MARK_SCRIPT = `window.__frogoeProbe ? window.__frogoeProbe.fps.length : 0`;
1256
- FPS_SINCE_SCRIPT = `(() => {
1257
- const p = window.__frogoeProbe;
1258
- const mark = __MARK__;
1259
- return p ? p.fps.slice(mark) : [];
1260
- })()`;
1261
- fpsSinceScript = (mark) => FPS_SINCE_SCRIPT.replaceAll("__MARK__", String(mark));
1262
- HUD_MEASURE_SCRIPT = `(() => {
1263
- const out = [];
1264
- for (const el of document.querySelectorAll(".hud *")) {
1265
- const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
1266
- if (!own) continue;
1267
- const text = el.textContent ?? "";
1268
- const s = getComputedStyle(el);
1269
- const hasOutline =
1270
- (s.webkitTextStroke && s.webkitTextStrokeWidth !== "0px") ||
1271
- (s.textShadow && s.textShadow !== "none");
1272
- const r = el.getBoundingClientRect();
1273
- out.push({ hasOutline, height: r.height, label: text.trim().slice(0, 24), width: r.width });
1274
- }
1275
- return out;
1276
- })()`;
1277
- RETRY_PRESENCE_SCRIPT = `(() => ({
1278
- gameover: Boolean(document.querySelector("[data-block-gameover]")),
1279
- retry: Boolean(document.querySelector("[data-block-retry]")),
1280
- }))()`;
1281
- INTERRUPT_AUDIO_SCRIPT = `(() => {
1282
- const p = window.__frogoeProbe;
1283
- if (!p) return false;
1284
- for (const c of p.audioContexts) {
1285
- try { void c.suspend(); } catch (e) {}
1286
- }
1287
- return true;
1288
- })()`;
1289
- AUDIO_STATES_SCRIPT = `(() => {
1290
- const p = window.__frogoeProbe;
1291
- if (!p) return { count: 0, everRan: false, running: 0 };
1292
- let running = 0;
1293
- for (const c of p.audioContexts) {
1294
- if (c.state === "running") running++;
1295
- }
1296
- return { count: p.audioContexts.length, everRan: p.audioEverRan === true, running };
1297
- })()`;
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
+ };
1298
1586
  }
1299
1587
  });
1300
1588
 
1301
- // src/live/driver.ts
1302
- var createPuppeteerDriver;
1303
- var init_driver = __esm({
1304
- "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"() {
1305
1594
  "use strict";
1306
- init_sampler();
1307
- createPuppeteerDriver = ({ page, size }) => {
1308
- const pageErrors = [];
1309
- const consoleErrors = [];
1310
- page.on("pageerror", (error) => {
1311
- pageErrors.push(String(error));
1312
- });
1313
- page.on("console", (message2) => {
1314
- if (message2.type() === "error") {
1315
- consoleErrors.push(message2.text());
1316
- }
1317
- });
1318
- page.evaluateOnNewDocument(PROBE_SCRIPT);
1319
- const read2 = async (script) => await page.evaluate(script);
1320
- return {
1321
- errors: () => pageErrors,
1322
- consoleErrors: () => consoleErrors,
1323
- async audioStates() {
1324
- return await read2(AUDIO_STATES_SCRIPT);
1325
- },
1326
- async interruptAudio() {
1327
- await read2(INTERRUPT_AUDIO_SCRIPT);
1328
- },
1329
- async domProbe() {
1330
- return await read2(DOM_PROBE_SCRIPT);
1331
- },
1332
- async canvasPainted() {
1333
- return await read2(CANVAS_PAINTED_SCRIPT);
1334
- },
1335
- async canvasHash() {
1336
- return await read2(CANVAS_HASH_SCRIPT);
1337
- },
1338
- async gameState() {
1339
- return await read2(GAME_STATE_SCRIPT);
1340
- },
1341
- async finishEvents() {
1342
- return await read2(FINISH_EVENTS_SCRIPT);
1343
- },
1344
- async fpsMark() {
1345
- return await read2(FPS_MARK_SCRIPT);
1346
- },
1347
- async fpsSince(mark) {
1348
- return await read2(fpsSinceScript(mark));
1349
- },
1350
- async hudMeasures() {
1351
- return await read2(HUD_MEASURE_SCRIPT);
1352
- },
1353
- async retryPresence() {
1354
- return await read2(RETRY_PRESENCE_SCRIPT);
1355
- },
1356
- async tap(x, y) {
1357
- await page.mouse.click(x, y);
1358
- },
1359
- async hold(x, y, ms) {
1360
- await page.mouse.move(x, y);
1361
- await page.mouse.down();
1362
- await new Promise((resolve2) => {
1363
- setTimeout(resolve2, ms);
1364
- });
1365
- await page.mouse.up();
1366
- },
1367
- async drag(x1, y1, x2, y2) {
1368
- await page.mouse.move(x1, y1);
1369
- await page.mouse.down();
1370
- await page.mouse.move(x2, y2, { steps: 6 });
1371
- await new Promise((resolve2) => {
1372
- setTimeout(resolve2, 80);
1373
- });
1374
- await page.mouse.up();
1375
- },
1376
- async clickRetryAwaitReload(timeoutMs) {
1377
- const interactable = `(() => {
1378
- const b = document.querySelector("[data-block-retry]");
1379
- if (!b) return false;
1380
- const s = getComputedStyle(b);
1381
- if (s.pointerEvents === "none" || s.visibility === "hidden" || s.display === "none") {
1382
- return false;
1383
- }
1384
- const r = b.getBoundingClientRect();
1385
- if (r.width < 4 || r.height < 4) return false;
1386
- const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
1387
- return Boolean(hit && (hit === b || b.contains(hit)));
1388
- })()`;
1389
- const grace = 3e3;
1390
- const started = Date.now();
1391
- for (; ; ) {
1392
- if (await page.evaluate(interactable) === true) {
1393
- break;
1394
- }
1395
- if (Date.now() - started > grace) {
1396
- return false;
1397
- }
1398
- await new Promise((resolve2) => {
1399
- setTimeout(resolve2, 100);
1400
- });
1401
- }
1402
- const navigated = page.waitForNavigation({ timeout: timeoutMs, waitUntil: "domcontentloaded" }).then(() => true).catch(() => false);
1403
- const button = await page.$("[data-block-retry]");
1404
- if (!button) {
1405
- return false;
1406
- }
1407
- await button.click();
1408
- return await navigated;
1409
- },
1410
- async setCpuThrottling(rate) {
1411
- await page.emulateCPUThrottling(rate === 1 ? null : rate);
1412
- },
1413
- async screenshot() {
1414
- return await page.screenshot({ encoding: "binary" });
1415
- },
1416
- viewport: () => size
1417
- };
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;
1418
1610
  };
1419
1611
  }
1420
1612
  });
1421
1613
 
1422
- // src/live/types.ts
1423
- var finding;
1424
- var init_types = __esm({
1425
- "src/live/types.ts"() {
1426
- "use strict";
1427
- finding = (shape) => shape;
1428
- }
1429
- });
1430
-
1431
- // src/live/decisions.ts
1432
- 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;
1433
- var init_decisions = __esm({
1434
- "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"() {
1435
1620
  "use strict";
1436
- init_types();
1437
- FPS_FLOOR = 30;
1438
- FPS_SUSTAINED_WINDOW = 3;
1439
- FROZEN_STREAK = 3;
1440
- outlineFinding = (measures) => {
1441
- const bare = measures.filter((m) => !m.hasOutline);
1442
- if (bare.length === 0 || !bare[0]) {
1443
- 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;
1444
1626
  }
1445
- return finding({
1446
- code: "live/hud-outline",
1447
- file: "index.html",
1448
- 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)`,
1449
- message: `${bare.length} HUD text element(s) have no outline`,
1450
- phase: "boot",
1451
- recipe: "frogoe-registry \u2192 block authoring (sticker depth pattern)",
1452
- severity: "error"
1453
- });
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;
1454
1632
  };
1455
- collapseFinding = (measures) => {
1456
- const collapsed = measures.filter((m) => m.width < 4 || m.height < 4);
1457
- if (collapsed.length === 0 || !collapsed[0]) {
1458
- return null;
1459
- }
1460
- const first = collapsed[0];
1461
- return finding({
1462
- code: "live/layout-collapse",
1463
- file: "index.html",
1464
- 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)`,
1465
- message: `${collapsed.length} HUD element(s) collapsed to zero size`,
1466
- phase: "boot",
1467
- severity: "error"
1468
- });
1469
- };
1470
- pageErrorFinding = (errors, viewport) => {
1471
- const first = errors[0];
1472
- if (first === void 0) {
1473
- return null;
1474
- }
1475
- return finding({
1476
- code: "live/page-error",
1477
- file: "game.js",
1478
- fix: `uncaught: ${first.slice(0, 140)}`,
1479
- message: `${errors.length} uncaught page error(s) [${viewport}]`,
1480
- phase: "boot",
1481
- severity: "error"
1482
- });
1483
- };
1484
- consoleErrorFinding = (entries, pageErrors, viewport) => {
1485
- const unique = entries.filter((entry) => !pageErrors.some((e) => e.includes(entry.slice(0, 60))));
1486
- const first = unique[0];
1487
- if (first === void 0) {
1488
- return null;
1489
- }
1490
- return finding({
1491
- code: "live/console-error",
1492
- file: "game.js",
1493
- fix: `console.error: ${first.slice(0, 140)} \u2014 recoverable failures should be handled, not logged`,
1494
- message: `${unique.length} console.error(s) [${viewport}]`,
1495
- phase: "boot",
1496
- severity: "warning"
1497
- });
1498
- };
1499
- canvasMissingFinding = (viewport) => finding({
1500
- code: "live/canvas-missing",
1501
- file: "index.html",
1502
- fix: 'the contract boots on <canvas id="c">',
1503
- message: `canvas missing [${viewport}]`,
1504
- phase: "boot",
1505
- severity: "error"
1506
- });
1507
- canvasUnpaintedFinding = (viewport, phase = "boot") => finding({
1508
- code: "live/canvas-unpainted",
1509
- file: "game.js",
1510
- fix: "loop.render never drew \u2014 fill loop.render = (ctx) => {...}",
1511
- message: `canvas stayed blank [${viewport}]`,
1512
- phase,
1513
- severity: "error"
1514
- });
1515
- contractMissingFinding = (viewport) => finding({
1516
- code: "live/contract-missing",
1517
- file: "index.html",
1518
- fix: 'import { defineGame } from "frogoe" \u2014 the runtime publishes window.__frogoe at boot',
1519
- message: `window.__frogoe absent [${viewport}]`,
1520
- phase: "boot",
1521
- severity: "error"
1522
- });
1523
- stateStuckFinding = (state, viewport, phase = "boot") => {
1524
- if (state !== "loading") {
1525
- return null;
1526
- }
1527
- return finding({
1528
- code: "live/state-stuck",
1529
- file: "game.js",
1530
- fix: 'state never left "loading" \u2014 defineGame() threw before start() or start() was never called',
1531
- message: `state stuck in "loading" [${viewport}]`,
1532
- phase,
1533
- severity: "error"
1534
- });
1633
+ isLoopback = (addr) => {
1634
+ if (!addr) return false;
1635
+ return addr === "::1" || addr.startsWith("127.") || addr.startsWith("::ffff:127.");
1535
1636
  };
1536
- earlyDeathFinding = (viewport, phase = "boot") => finding({
1537
- code: "live/early-death",
1538
- file: "game.js",
1539
- 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")',
1540
- message: `game ended before the first input [${viewport}]`,
1541
- phase,
1542
- severity: "error"
1543
- });
1544
- fpsFinding = (fps, viewport) => {
1545
- if (fps === void 0 || fps >= FPS_FLOOR) {
1546
- 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
+ }
1547
1648
  }
1548
- return finding({
1549
- code: "live/fps",
1550
- file: "game.js",
1551
- 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`,
1552
- message: `frame rate below the playability floor [${viewport}]`,
1553
- phase: "play",
1554
- recipe: "frogoe-creative \u2192 game-feel (motion rules)",
1555
- severity: "warning"
1556
- });
1649
+ return own;
1557
1650
  };
1558
- fpsSustainedFinding = (buckets, viewport) => {
1559
- if (buckets.length < FPS_SUSTAINED_WINDOW) {
1560
- 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
+ }
1561
1661
  }
1562
- let worst = Number.POSITIVE_INFINITY;
1563
- for (let i = 0; i + FPS_SUSTAINED_WINDOW <= buckets.length; i++) {
1564
- const window = buckets.slice(i, i + FPS_SUSTAINED_WINDOW);
1565
- const avg = window.reduce((a, b) => a + b, 0) / window.length;
1566
- if (avg < worst) {
1567
- 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
+ };
1568
1673
  }
1569
1674
  }
1570
- if (worst >= FPS_FLOOR) {
1571
- return null;
1675
+ const chosen = physical[0] ?? virtual[0];
1676
+ if (!chosen) {
1677
+ return { candidates: [], confidence: "none", virtual: false };
1572
1678
  }
1573
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1574
1679
  return {
1575
- finding: finding({
1576
- code: "live/fps-sustained",
1577
- file: "game.js",
1578
- 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`,
1579
- message: `sustained low frame rate [${viewport}]`,
1580
- phase: "play",
1581
- recipe: "frogoe-creative \u2192 game-feel (motion rules)",
1582
- severity: "error"
1583
- }),
1584
- 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)
1585
1684
  };
1586
1685
  };
1587
- frozenFrameFinding = (streak) => {
1588
- if (streak < FROZEN_STREAK) {
1589
- return null;
1590
- }
1591
- return finding({
1592
- code: "live/frozen-frame",
1593
- file: "game.js",
1594
- fix: `canvas held the same frame for ${streak} samples while playing \u2014 loop.render may have stopped or draws a static scene`,
1595
- message: "canvas froze during play",
1596
- phase: "play",
1597
- severity: "warning"
1598
- });
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);
1599
1690
  };
1600
- pausedFinding = () => finding({
1601
- code: "live/paused",
1602
- file: "game.js",
1603
- fix: 'state read "paused" during scripted play \u2014 pause() ran without a matching resume(); check document.hidden handling and blur listeners',
1604
- message: "game paused itself during play",
1605
- phase: "play",
1606
- severity: "warning"
1607
- });
1608
- stateCorruptFinding = (state) => finding({
1609
- code: "live/state-corrupt",
1610
- file: "game.js",
1611
- fix: `state read "${state}" \u2014 outside the contract's set (loading/playing/paused/over); game code is mutating window.__frogoe directly`,
1612
- message: `state left the contract's state machine ("${state}")`,
1613
- phase: "play",
1614
- severity: "error"
1615
- });
1616
- playabilityFinding = (result) => {
1617
- if (result === "pass") {
1618
- return null;
1619
- }
1620
- if (result === "no-input") {
1621
- return finding({
1622
- code: "live/no-input",
1623
- file: "game.js",
1624
- fix: 'the game never registered input.on("down", ...) \u2014 wire the core verb before shipping',
1625
- message: "game has no input handler",
1626
- phase: "play",
1627
- severity: "error"
1628
- });
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"
1629
1699
  }
1630
- return finding({
1631
- code: "live/not-playable",
1632
- file: "game.js",
1633
- fix: "scripted taps produced no canvas change \u2014 loop.update/loop.render may be wired but the game logic never runs",
1634
- message: "game did not respond to scripted input",
1635
- phase: "play",
1636
- severity: "error"
1637
- });
1638
- };
1639
- finishEventFinding = (stateOver, finishCount) => {
1640
- if (stateOver === finishCount > 0) {
1641
- 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
+ }
1642
1713
  }
1643
- return finding({
1644
- code: "live/finish-event-missing",
1645
- file: "game.js",
1646
- 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()',
1647
- message: "state/event mismatch at game over",
1648
- phase: "end",
1649
- severity: "error"
1650
- });
1651
- };
1652
- neverEndsFinding = (budgetMs) => finding({
1653
- code: "live/never-ends",
1654
- file: "game.js",
1655
- 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`,
1656
- message: "game never reached the over state",
1657
- phase: "end",
1658
- severity: "warning"
1659
- });
1660
- audioLockedFinding = (audio) => {
1661
- if (audio.count === 0 || audio.running > 0) {
1662
- return null;
1663
- }
1664
- return finding({
1665
- code: "live/audio-locked",
1666
- file: "game.js",
1667
- 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)',
1668
- message: "audio never recovers \u2014 every phone interruption plays this game silent",
1669
- phase: "play",
1670
- recipe: "frogoe-core \u2192 references/audio.md",
1671
- severity: "error"
1672
- });
1673
- };
1674
- noGameoverCardFinding = () => finding({
1675
- code: "live/no-gameover-card",
1676
- file: "index.html",
1677
- fix: "no [data-block-gameover] overlay when the game ended \u2014 install the game-over-card block so death has a screen",
1678
- message: "game over has no overlay",
1679
- phase: "end",
1680
- recipe: "frogoe-registry \u2192 game-over-card",
1681
- severity: "warning"
1682
- });
1683
- THROTTLE_RATE = 4;
1684
- THROTTLED_FPS_FLOOR = 15;
1685
- fpsThrottledFinding = (buckets, viewport) => {
1686
- if (buckets.length === 0) return null;
1687
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1688
- if (mean >= THROTTLED_FPS_FLOOR) return null;
1689
- return finding({
1690
- code: "live/fps-throttled",
1691
- file: "game.js",
1692
- 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`,
1693
- message: "cpu-bound collapse under phone-class throttle",
1694
- phase: "play",
1695
- severity: "warning"
1696
- });
1697
- };
1698
- noRetryFinding = () => finding({
1699
- code: "live/no-retry",
1700
- file: "index.html",
1701
- fix: "no [data-block-retry] button anywhere \u2014 the player is hard-stuck after death; the retry affordance is the loop's exit",
1702
- message: "no retry affordance after game over",
1703
- phase: "end",
1704
- recipe: "frogoe-registry \u2192 game-over-card",
1705
- severity: "error"
1706
- });
1707
- retryDeadFinding = () => finding({
1708
- code: "live/retry-dead",
1709
- file: "game.js",
1710
- fix: 'clicking retry produced no reload \u2014 wire it: retry.addEventListener("click", () => location.reload())',
1711
- message: "retry button did not reload the page",
1712
- phase: "retry",
1713
- severity: "error"
1714
- });
1715
- rebootFinding = (state) => {
1716
- if (state === "playing") {
1717
- return null;
1718
- }
1719
- return finding({
1720
- code: "live/state-stuck",
1721
- file: "game.js",
1722
- 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)`,
1723
- message: `retry boot stuck in "${state}"`,
1724
- phase: "retry",
1725
- severity: "error"
1726
- });
1714
+ return null;
1727
1715
  };
1716
+ resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
1728
1717
  }
1729
1718
  });
1730
1719
 
1731
- // src/live/phases.ts
1732
- 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;
1733
- var init_phases = __esm({
1734
- "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"() {
1735
1724
  "use strict";
1736
- init_decisions();
1737
- END_BUDGET_MS = 45e3;
1738
- RETRY_NAV_MS = 8e3;
1739
- PLAY_STEPS = 7;
1740
- PLAY_STEP_MS = 850;
1741
- HOLD_STEP_INDEX = 3;
1742
- DRAG_STEP_INDEX = 5;
1743
- DRAG_SPAN = 90;
1744
- HOLD_MS = 400;
1745
- POLL_MS = 400;
1746
- GRACE_MS = 150;
1747
- STABILITY_CYCLES = 2;
1748
- START_BURST_TAPS = 3;
1749
- DESKTOP_FPS_MS = 2e3;
1750
- sleep2 = (ms) => new Promise((resolve2) => {
1751
- setTimeout(resolve2, ms);
1752
- });
1753
- jitterX = (step) => step * 37 % 121 - 60;
1754
- jitterY = (step) => step * 53 % 181 - 90;
1755
- hasError = (findings) => findings.some((f) => f.severity === "error");
1756
- runBootChecks = async (driver, ctx) => {
1757
- const findings = [];
1758
- const name = ctx.viewport.name;
1759
- const pageErr = pageErrorFinding(driver.errors(), name);
1760
- if (pageErr) {
1761
- findings.push(pageErr);
1762
- }
1763
- const conErr = consoleErrorFinding(driver.consoleErrors(), driver.errors(), name);
1764
- if (conErr) {
1765
- findings.push(conErr);
1766
- }
1767
- const probe = await driver.domProbe();
1768
- if (!probe.canvasPresent) {
1769
- findings.push(canvasMissingFinding(name));
1770
- } else if (!await driver.canvasPainted()) {
1771
- findings.push(canvasUnpaintedFinding(name));
1772
- }
1773
- if (probe.state === "(missing)") {
1774
- findings.push(contractMissingFinding(name));
1775
- } else if (probe.state === "over") {
1776
- findings.push(earlyDeathFinding(name));
1777
- } else {
1778
- const stuck = stateStuckFinding(probe.state, name);
1779
- if (stuck) {
1780
- findings.push(stuck);
1781
- }
1782
- }
1783
- if (probe.hudPresent) {
1784
- const measures = await driver.hudMeasures();
1785
- const outline = outlineFinding(measures);
1786
- if (outline) {
1787
- findings.push(outline);
1788
- }
1789
- const collapse = collapseFinding(measures);
1790
- if (collapse) {
1791
- findings.push(collapse);
1792
- }
1793
- }
1794
- 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())}`;
1795
1730
  };
1796
- runDesktopPass = async (driver, ctx) => {
1797
- const doSleep = ctx.sleep ?? sleep2;
1798
- const findings = await runBootChecks(driver, ctx);
1799
- if (hasError(findings)) {
1800
- return { findings };
1801
- }
1802
- const mark = await driver.fpsMark();
1803
- await doSleep(DESKTOP_FPS_MS);
1804
- const buckets = await driver.fpsSince(mark);
1805
- if (buckets.length === 0) {
1806
- return { findings };
1807
- }
1808
- const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
1809
- const warn = fpsFinding(mean, ctx.viewport.name);
1810
- if (warn) {
1811
- 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
+ }
1812
1746
  }
1813
- await ctx.shot?.("live-desktop.png");
1814
- return { findings, fps: Math.round(mean) };
1747
+ return spans;
1815
1748
  };
1816
- overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
1817
- retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
1818
- waitForOver = async (driver, doSleep) => {
1819
- for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
1820
- await doSleep(POLL_MS);
1821
- if (await driver.gameState() === "over") {
1822
- 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
+ });
1823
1771
  }
1824
1772
  }
1825
- return false;
1826
- };
1827
- runStartBurst = async (driver, ctx) => {
1828
- const doSleep = ctx.sleep ?? sleep2;
1829
- for (let step = 0; step < START_BURST_TAPS; step++) {
1830
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
1831
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
1832
- await driver.tap(x, y);
1833
- 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 });
1834
1780
  }
1781
+ return out;
1835
1782
  };
1836
- verifyDeath = async (driver, ctx, findings, cycle) => {
1837
- const doSleep = ctx.sleep ?? sleep2;
1838
- let events = await driver.finishEvents();
1839
- if (events.length === 0) {
1840
- await doSleep(GRACE_MS);
1841
- events = await driver.finishEvents();
1842
- }
1843
- const mismatch = finishEventFinding(true, events.length);
1844
- if (mismatch) {
1845
- findings.push(mismatch);
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
+ }
1846
1798
  }
1847
- const presence = await driver.retryPresence();
1848
- if (!presence.gameover) {
1849
- findings.push(noGameoverCardFinding());
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;
1850
1813
  }
1851
- if (!presence.retry) {
1852
- 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;
1853
1868
  }
1854
- await ctx.shot?.(overShotName(cycle));
1855
- return presence.retry;
1856
1869
  };
1857
- runLifecycle = async (driver, ctx) => {
1858
- const doSleep = ctx.sleep ?? sleep2;
1859
- const settle = ctx.settleMs ?? 2e3;
1860
- const name = ctx.viewport.name;
1861
- const findings = [];
1862
- const boot = await runBootChecks(driver, ctx);
1863
- findings.push(...boot);
1864
- await ctx.shot?.("live-mobile.png");
1865
- if (hasError(boot)) {
1866
- 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?`);
1867
1911
  }
1868
- const mark = await driver.fpsMark();
1869
- const hashes = [];
1870
- let streak = 0;
1871
- let maxStreak = 0;
1872
- let sawOver = false;
1873
- let sawPaused = false;
1874
- let sawStuck = false;
1875
- let corrupt = null;
1876
- for (let step = 0; step < PLAY_STEPS; step++) {
1877
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
1878
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
1879
- if (step === HOLD_STEP_INDEX) {
1880
- await driver.hold(x, y, HOLD_MS);
1881
- } else if (step === DRAG_STEP_INDEX) {
1882
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
1883
- } else {
1884
- 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 {
1885
1927
  }
1886
- await doSleep(PLAY_STEP_MS);
1887
- const state = await driver.gameState();
1888
- if (state === "over") {
1889
- sawOver = true;
1890
- 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);
1891
1963
  }
1892
- if (state === "paused") {
1893
- sawPaused = true;
1894
- } else if (state === "loading") {
1895
- sawStuck = true;
1896
- } else if (state !== "playing" && state !== "(missing)") {
1897
- 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);
1898
1971
  }
1899
- const hash = await driver.canvasHash();
1900
- if (hash !== null) {
1901
- if (hashes.length > 0 && hash === hashes[hashes.length - 1] && state === "playing") {
1902
- streak += 1;
1903
- maxStreak = Math.max(maxStreak, streak);
1904
- } else {
1905
- streak = 0;
1906
- }
1907
- hashes.push(hash);
1972
+ if (existsSync6(file) && statSync(file).isDirectory()) {
1973
+ file = path9.join(file, "index.html");
1908
1974
  }
1909
- }
1910
- const buckets = await driver.fpsSince(mark);
1911
- const mean = buckets.length > 0 ? buckets.reduce((a, b) => a + b, 0) / buckets.length : void 0;
1912
- if (corrupt !== null) {
1913
- findings.push(stateCorruptFinding(corrupt));
1914
- }
1915
- if (sawStuck) {
1916
- const stuck = stateStuckFinding("loading", name, "play");
1917
- if (stuck) {
1918
- findings.push(stuck);
1975
+ if (!existsSync6(file)) {
1976
+ return c.text(`frogoe run: not found: ${raw}`, 404);
1919
1977
  }
1920
- }
1921
- if (sawPaused) {
1922
- findings.push(pausedFinding());
1923
- }
1924
- const frozen = frozenFrameFinding(maxStreak);
1925
- if (frozen) {
1926
- findings.push(frozen);
1927
- }
1928
- const sustained = fpsSustainedFinding(buckets, name);
1929
- if (sustained) {
1930
- findings.push(sustained.finding);
1931
- } else {
1932
- const warn = fpsFinding(mean, name);
1933
- if (warn) {
1934
- 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
+ });
1935
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");
1936
2002
  }
1937
- const responded = new Set(hashes).size > 1 || sawOver;
1938
- const playability = responded ? "pass" : "fail";
1939
- const play = playabilityFinding(playability);
1940
- if (play) {
1941
- findings.push(play);
1942
- }
1943
- const audioBefore = await driver.audioStates();
1944
- if (audioBefore.count > 0) {
1945
- await driver.interruptAudio();
1946
- await runStartBurst(driver, ctx);
1947
- await doSleep(600);
1948
- const audioFinding = audioLockedFinding(await driver.audioStates());
1949
- if (audioFinding) {
1950
- 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;
1951
2489
  }
1952
- }
1953
- let ends = false;
1954
- let retryReloads = 0;
1955
- if (!sawOver) {
1956
- sawOver = await waitForOver(driver, doSleep);
1957
- }
1958
- if (!sawOver) {
1959
- findings.push(neverEndsFinding(END_BUDGET_MS));
1960
- } else {
1961
- ends = true;
1962
- let canRetry = await verifyDeath(driver, ctx, findings, 0);
1963
- for (let cycle = 0; cycle < STABILITY_CYCLES && canRetry; cycle++) {
1964
- const reloaded = await driver.clickRetryAwaitReload(RETRY_NAV_MS);
1965
- if (!reloaded) {
1966
- findings.push(retryDeadFinding());
1967
- break;
1968
- }
1969
- retryReloads += 1;
1970
- await doSleep(settle);
1971
- const rebootState = await driver.gameState();
1972
- if (rebootState === "over") {
1973
- findings.push(earlyDeathFinding(name, "retry"));
1974
- } else {
1975
- const reboot = rebootFinding(rebootState);
1976
- if (reboot) {
1977
- findings.push(reboot);
1978
- }
1979
- }
1980
- if (!await driver.canvasPainted()) {
1981
- findings.push(canvasUnpaintedFinding(name, "retry"));
1982
- }
1983
- await ctx.shot?.(retryShotName(cycle));
1984
- if (cycle < STABILITY_CYCLES - 1) {
1985
- await runStartBurst(driver, ctx);
1986
- const overAgain = await waitForOver(driver, doSleep);
1987
- if (!overAgain) {
1988
- 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) {
1989
2499
  break;
1990
2500
  }
1991
- 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
+ });
1992
2507
  }
1993
- }
1994
- }
1995
- const throttleMark = await driver.fpsMark();
1996
- await driver.setCpuThrottling(THROTTLE_RATE);
1997
- await runStartBurst(driver, ctx);
1998
- for (let step = 0; step < PLAY_STEPS; step++) {
1999
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
2000
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
2001
- if (step === HOLD_STEP_INDEX) {
2002
- await driver.hold(x, y, HOLD_MS);
2003
- } else if (step === DRAG_STEP_INDEX) {
2004
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
2005
- } else {
2006
- await driver.tap(x, y);
2007
- }
2008
- await doSleep(PLAY_STEP_MS);
2009
- }
2010
- const throttledBuckets = await driver.fpsSince(throttleMark);
2011
- await driver.setCpuThrottling(1);
2012
- const throttled = fpsThrottledFinding(throttledBuckets, name);
2013
- if (throttled) {
2014
- findings.push(throttled);
2015
- }
2016
- return {
2017
- findings,
2018
- lifecycle: { ends, retryReloads },
2019
- mobileFps: mean !== void 0 ? Math.round(mean) : void 0,
2020
- 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
2021
2523
  };
2022
2524
  };
2023
2525
  }
2024
2526
  });
2025
2527
 
2026
- // src/net/ip.ts
2027
- import { execSync } from "child_process";
2028
- import os from "os";
2029
- var VIRTUAL, isPrivateV4, isLoopback, selfAddresses, pickLanIp, interfaceFromRouteOutput, ROUTE_PROBES, routedInterfaceName, resolveLan;
2030
- var init_ip = __esm({
2031
- "src/net/ip.ts"() {
2528
+ // src/live/types.ts
2529
+ var finding;
2530
+ var init_types = __esm({
2531
+ "src/live/types.ts"() {
2032
2532
  "use strict";
2033
- 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;
2034
- isPrivateV4 = (ip) => {
2035
- const octets = ip.split(".").map(Number);
2036
- if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
2037
- 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;
2038
2550
  }
2039
- const [a = -1, b = -1] = octets;
2040
- if (a === 10) return true;
2041
- if (a === 172 && b >= 16 && b <= 31) return true;
2042
- if (a === 192 && b === 168) return true;
2043
- 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
+ });
2044
2560
  };
2045
- isLoopback = (addr) => {
2046
- if (!addr) return false;
2047
- 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
+ });
2048
2575
  };
2049
- selfAddresses = (interfaces) => {
2050
- const own = /* @__PURE__ */ new Set();
2051
- for (const entries of Object.values(interfaces)) {
2052
- for (const entry of entries ?? []) {
2053
- if (entry.family === "IPv4") {
2054
- own.add(entry.address);
2055
- own.add(`::ffff:${entry.address}`);
2056
- } else if (entry.family === "IPv6") {
2057
- own.add(entry.address);
2058
- }
2059
- }
2576
+ pageErrorFinding = (errors, viewport) => {
2577
+ const first = errors[0];
2578
+ if (first === void 0) {
2579
+ return null;
2060
2580
  }
2061
- 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
+ });
2062
2663
  };
2063
- pickLanIp = (interfaces, routedName) => {
2064
- const physical = [];
2065
- const virtual = [];
2066
- for (const [name, entries] of Object.entries(interfaces)) {
2067
- for (const entry of entries ?? []) {
2068
- if (entry.family !== "IPv4" || entry.internal || !isPrivateV4(entry.address)) continue;
2069
- const item = { ip: entry.address, name };
2070
- if (VIRTUAL.test(name.toLowerCase())) virtual.push(item);
2071
- else physical.push(item);
2072
- }
2664
+ fpsSustainedFinding = (buckets, viewport) => {
2665
+ if (buckets.length < FPS_SUSTAINED_WINDOW) {
2666
+ return null;
2073
2667
  }
2074
- physical.sort((a, b) => a.name.localeCompare(b.name));
2075
- virtual.sort((a, b) => a.name.localeCompare(b.name));
2076
- if (routedName) {
2077
- const routed = physical.find((p) => p.name === routedName) ?? virtual.find((v) => v.name === routedName);
2078
- if (routed) {
2079
- return {
2080
- candidates: [routed.ip],
2081
- confidence: "routed",
2082
- ip: routed.ip,
2083
- virtual: !physical.includes(routed)
2084
- };
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;
2085
2674
  }
2086
2675
  }
2087
- const chosen = physical[0] ?? virtual[0];
2088
- if (!chosen) {
2089
- return { candidates: [], confidence: "none", virtual: false };
2676
+ if (worst >= FPS_FLOOR2) {
2677
+ return null;
2090
2678
  }
2679
+ const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
2091
2680
  return {
2092
- candidates: physical.length > 0 ? physical.map((p) => p.ip) : virtual.map((v) => v.ip),
2093
- confidence: "heuristic",
2094
- ip: chosen.ip,
2095
- 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
2096
2691
  };
2097
2692
  };
2098
- interfaceFromRouteOutput = (output, style) => {
2099
- if (style === "bsd") return /interface:\s*(\S+)/u.exec(output)?.[1];
2100
- if (style === "linux") return /dev\s+(\S+)/u.exec(output)?.[1];
2101
- 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
+ });
2102
2705
  };
2103
- ROUTE_PROBES = [
2104
- { cmd: "route -n get default", style: "bsd" },
2105
- // darwin/bsd; usage-errors elsewhere
2106
- { cmd: "ip route show default", style: "linux" },
2107
- // linux; absent on darwin/win
2108
- {
2109
- cmd: `powershell -NoProfile -Command "(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1).InterfaceAlias"`,
2110
- 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;
2111
2725
  }
2112
- ];
2113
- routedInterfaceName = () => {
2114
- for (const probe of ROUTE_PROBES) {
2115
- try {
2116
- const out = execSync(probe.cmd, {
2117
- encoding: "utf-8",
2118
- stdio: ["ignore", "pipe", "ignore"],
2119
- timeout: 3e3
2120
- });
2121
- const name = interfaceFromRouteOutput(out, probe.style);
2122
- if (name) return name;
2123
- } catch {
2124
- }
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
+ });
2125
2735
  }
2126
- 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
+ });
2127
2833
  };
2128
- resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
2129
2834
  }
2130
2835
  });
2131
2836
 
2132
- // src/telemetry/records.ts
2133
- var FPS_FLOOR2, formatClock, dipSpans, beaconToRecords, summarizeRecords;
2134
- var init_records = __esm({
2135
- "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"() {
2136
2841
  "use strict";
2137
- FPS_FLOOR2 = 30;
2138
- formatClock = (wall) => {
2139
- const d = new Date(wall);
2140
- const p = (n) => String(n).padStart(2, "0");
2141
- return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
2142
- };
2143
- dipSpans = (buckets) => {
2144
- const spans = [];
2145
- let start = -1;
2146
- let worst = 0;
2147
- for (let i = 0; i <= buckets.length; i++) {
2148
- const low = i < buckets.length && (buckets[i] ?? 0) > 0 && (buckets[i] ?? 0) < FPS_FLOOR2;
2149
- if (low && start === -1) {
2150
- start = i;
2151
- worst = buckets[i] ?? 0;
2152
- } else if (low) {
2153
- worst = Math.min(worst, buckets[i] ?? 0);
2154
- } else if (start !== -1) {
2155
- spans.push({ fps: worst, len: i - start, start });
2156
- start = -1;
2157
- }
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);
2158
2868
  }
2159
- return spans;
2160
- };
2161
- beaconToRecords = (payload, arrivalWall) => {
2162
- const out = [];
2163
- const arrivalUp = payload.up;
2164
- const wallAt = (up) => arrivalWall - Math.max(0, arrivalUp - up) * 1e3;
2165
- const buckets = payload.fps ?? [];
2166
- const spans = dipSpans(buckets);
2167
- const spanEnd = new Map(spans.map((s) => [s.start + s.len - 1, s]));
2168
- for (let i = 0; i < buckets.length; i++) {
2169
- const fps = buckets[i] ?? 0;
2170
- const up = Math.max(0, arrivalUp - (buckets.length - 1 - i));
2171
- const wall = wallAt(up);
2172
- const time = formatClock(wall);
2173
- out.push({
2174
- record: { fps, time, type: "fps", up, wall },
2175
- text: ""
2176
- });
2177
- const span = spanEnd.get(i);
2178
- if (span) {
2179
- out.push({
2180
- record: { fps: span.fps, time, type: "fps", up, wall },
2181
- text: `\u26A0 ${time} fps ${span.fps} \u2014 dip ${span.len}s`
2182
- });
2183
- }
2869
+ const conErr = consoleErrorFinding(driver.consoleErrors(), driver.errors(), name);
2870
+ if (conErr) {
2871
+ findings.push(conErr);
2184
2872
  }
2185
- for (const event of payload.events ?? []) {
2186
- const wall = wallAt(event.up);
2187
- const time = formatClock(wall);
2188
- const type = event.type === "error" || event.type === "rejection" || event.type === "hidden" || event.type === "visible" ? event.type : "error";
2189
- const msg = (event.msg ?? "").slice(0, 160);
2190
- const text = type === "hidden" ? `\xB7 ${time} phone hidden` : type === "visible" ? `\xB7 ${time} phone visible` : `\u2716 ${time} ${type === "rejection" ? "unhandled rejection" : "page error"}: ${msg}`;
2191
- 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));
2192
2878
  }
2193
- return out;
2194
- };
2195
- summarizeRecords = (records) => {
2196
- const fps = records.filter((r) => r.type === "fps" && typeof r.fps === "number");
2197
- const buckets = fps.map((r) => r.fps ?? 0).filter((n) => n > 0);
2198
- const spans = dipSpans(buckets);
2199
- const orderKey = (r) => typeof r.wall === "number" ? r.wall : r.up * 1e3;
2200
- let hiddenS = 0;
2201
- let pendingHidden;
2202
- for (const r of records) {
2203
- if (pendingHidden !== void 0) {
2204
- hiddenS += Math.max(0, orderKey(r) - pendingHidden) / 1e3;
2205
- pendingHidden = void 0;
2206
- }
2207
- if (r.type === "hidden") {
2208
- 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);
2209
2887
  }
2210
2888
  }
2211
- const worstSpan = spans.reduce(
2212
- (acc, s) => acc === void 0 || s.len > acc.len ? s : acc,
2213
- void 0
2214
- );
2215
- const worst = worstSpan ? {
2216
- fps: worstSpan.fps,
2217
- len: worstSpan.len,
2218
- time: fps[worstSpan.start + worstSpan.len - 1]?.time ?? "?"
2219
- } : void 0;
2220
- let pageLoads = records.length > 0 ? 1 : 0;
2221
- let prevUp = -1;
2222
- for (const r of records) {
2223
- if (prevUp !== -1 && r.up + 2 < prevUp) pageLoads += 1;
2224
- 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
+ }
2225
2899
  }
2226
- const first = records[0];
2227
- const last = records[records.length - 1];
2228
- const durationS = first && last && typeof first.wall === "number" && typeof last.wall === "number" ? (
2229
- // +1s: the first bucket already covers one second of play
2230
- Math.round((last.wall - first.wall + 1e3) / 1e3)
2231
- ) : Math.round(last?.up ?? 0);
2232
- return {
2233
- buckets: buckets.length,
2234
- dips: spans.length,
2235
- durationS,
2236
- errors: records.filter((r) => r.type === "error" || r.type === "rejection").length,
2237
- hiddenS: Math.round(hiddenS),
2238
- meanFps: buckets.length > 0 ? Math.round(buckets.reduce((a, b) => a + b, 0) / buckets.length) : void 0,
2239
- pageLoads,
2240
- worst
2241
- };
2900
+ return findings;
2242
2901
  };
2243
- }
2244
- });
2245
-
2246
- // src/telemetry/session.ts
2247
- import { appendFileSync, mkdirSync as mkdirSync4, readdirSync as readdirSync2 } from "fs";
2248
- import path6 from "path";
2249
- var pad, sessionStamp, createSessionStore, latestSessionFile;
2250
- var init_session = __esm({
2251
- "src/telemetry/session.ts"() {
2252
- "use strict";
2253
- pad = (n) => String(n).padStart(2, "0");
2254
- sessionStamp = (wall) => {
2255
- const d = new Date(wall);
2256
- 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) };
2257
2921
  };
2258
- createSessionStore = (gameDir, startedWall) => {
2259
- const dir = path6.join(gameDir, ".frogoe", "sessions");
2260
- let file;
2261
- return {
2262
- file: () => file,
2263
- write: (records) => {
2264
- if (records.length === 0) return;
2265
- if (!file) {
2266
- mkdirSync4(dir, { recursive: true });
2267
- file = path6.join(dir, `${sessionStamp(startedWall)}.jsonl`);
2268
- }
2269
- 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;
2270
2929
  }
2271
- };
2272
- };
2273
- latestSessionFile = (gameDir) => {
2274
- const dir = path6.join(gameDir, ".frogoe", "sessions");
2275
- try {
2276
- const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
2277
- return files.length > 0 ? path6.join(dir, files[files.length - 1] ?? "") : null;
2278
- } catch {
2279
- return null;
2280
2930
  }
2931
+ return false;
2281
2932
  };
2282
- }
2283
- });
2284
-
2285
- // src/run.ts
2286
- var run_exports = {};
2287
- __export(run_exports, {
2288
- startServer: () => startServer
2289
- });
2290
- import { existsSync as existsSync5, readFileSync as readFileSync5, statSync, watch } from "fs";
2291
- import os2 from "os";
2292
- import path7 from "path";
2293
- import { createAdaptorServer } from "@hono/node-server";
2294
- import { getConnInfo } from "@hono/node-server/conninfo";
2295
- import { Hono } from "hono";
2296
- var buildDevScript, MIME2, startServer;
2297
- var init_run = __esm({
2298
- "src/run.ts"() {
2299
- "use strict";
2300
- init_ip();
2301
- init_records();
2302
- init_session();
2303
- 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>`;
2304
- MIME2 = {
2305
- css: "text/css; charset=utf-8",
2306
- htm: "text/html; charset=utf-8",
2307
- html: "text/html; charset=utf-8",
2308
- ico: "image/x-icon",
2309
- jpeg: "image/jpeg",
2310
- js: "text/javascript; charset=utf-8",
2311
- json: "application/json; charset=utf-8",
2312
- mjs: "text/javascript; charset=utf-8",
2313
- png: "image/png",
2314
- svg: "image/svg+xml",
2315
- txt: "text/plain; charset=utf-8",
2316
- webp: "image/webp",
2317
- 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
+ }
2318
2941
  };
2319
- startServer = async (dir, requestedPort = 0, telemetry) => {
2320
- const root = path7.resolve(dir);
2321
- if (!existsSync5(path7.join(root, "index.html"))) {
2322
- 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();
2323
2948
  }
2324
- const clients = /* @__PURE__ */ new Set();
2325
- let version = 0;
2326
- let remoteSeen = false;
2327
- let lastRemote = "";
2328
- const own = selfAddresses(os2.networkInterfaces());
2329
- const session = telemetry ? createSessionStore(root, Date.now()) : void 0;
2330
- const app = new Hono();
2331
- app.use("*", async (c, next) => {
2332
- try {
2333
- const address2 = getConnInfo(c).remote.address;
2334
- if (address2 && !isLoopback(address2) && !own.has(address2)) {
2335
- remoteSeen = true;
2336
- lastRemote = address2;
2337
- }
2338
- } 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);
2339
2991
  }
2340
- await next();
2341
- });
2342
- app.get("/__frogoe/reload", (c) => {
2343
- const stream = new ReadableStream({
2344
- cancel() {
2345
- },
2346
- start(controller) {
2347
- clients.add(controller);
2348
- controller.enqueue(new TextEncoder().encode("retry: 3000\n\n"));
2349
- }
2350
- });
2351
- return c.body(stream, {
2352
- headers: {
2353
- "cache-control": "no-store",
2354
- connection: "keep-alive",
2355
- "content-type": "text/event-stream"
2356
- }
2357
- });
2358
- });
2359
- app.get(
2360
- "/__frogoe/version",
2361
- (c) => c.text(String(version), 200, { "cache-control": "no-store" })
2362
- );
2363
- app.post("/__frogoe/metrics", async (c) => {
2364
- try {
2365
- const payload = JSON.parse(await c.req.text());
2366
- if (!session) return c.body(null, 204);
2367
- const lines = beaconToRecords(payload, Date.now());
2368
- session.write(lines.map((l) => l.record));
2369
- for (const line of lines) {
2370
- if (line.text) telemetry?.onEvent?.(line.text);
2371
- }
2372
- return c.body(null, 204);
2373
- } catch {
2374
- 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;
2375
2997
  }
2376
- });
2377
- app.get("*", (c) => {
2378
- const raw = decodeURIComponent(new URL(c.req.url).pathname);
2379
- const safe = path7.normalize(raw).replaceAll("\\", "/");
2380
- let file = path7.join(root, safe === "/" ? "index.html" : safe);
2381
- if (!file.startsWith(root)) {
2382
- 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;
2383
3004
  }
2384
- if (existsSync5(file) && statSync(file).isDirectory()) {
2385
- 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);
2386
3014
  }
2387
- if (!existsSync5(file)) {
2388
- 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);
2389
3025
  }
2390
- const body = readFileSync5(file);
2391
- const ext = path7.extname(file).slice(1).toLowerCase();
2392
- const type = MIME2[ext] ?? "application/octet-stream";
2393
- if (ext === "html" || ext === "htm") {
2394
- const html = body.toString("utf-8");
2395
- const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
2396
- return c.body(injected, 200, {
2397
- "cache-control": "no-store",
2398
- "content-type": type
2399
- });
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);
2400
3041
  }
2401
- return c.body(body, 200, { "content-type": type });
2402
- });
2403
- const server = createAdaptorServer({ fetch: app.fetch });
2404
- await new Promise((resolve2, reject) => {
2405
- server.once("error", reject);
2406
- server.listen(requestedPort, "0.0.0.0", () => resolve2());
2407
- });
2408
- const address = server.address();
2409
- const port = typeof address === "object" && address ? address.port : 0;
2410
- if (!port) {
2411
- server.closeAllConnections?.();
2412
- server.close();
2413
- throw new Error("frogoe run: server failed to bind a port");
2414
3042
  }
2415
- const local = `http://localhost:${port}`;
2416
- const lanInfo = resolveLan();
2417
- const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
2418
- let timer;
2419
- const watcher = watch(root, { recursive: true }, (_event, file) => {
2420
- const first = file?.split(path7.sep)[0];
2421
- if (first === "snapshots" || first === ".frogoe") {
2422
- 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);
2423
3057
  }
2424
- clearTimeout(timer);
2425
- timer = setTimeout(() => {
2426
- version += 1;
2427
- const payload = new TextEncoder().encode("data: reload\n\n");
2428
- for (const client of clients) {
2429
- try {
2430
- client.enqueue(payload);
2431
- } catch {
2432
- 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);
2433
3084
  }
2434
3085
  }
2435
- }, 100);
2436
- });
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
+ }
2437
3122
  return {
2438
- port,
2439
- sawRemote: () => remoteSeen,
2440
- remote: () => remoteSeen ? lastRemote : void 0,
2441
- stop() {
2442
- clearTimeout(timer);
2443
- watcher.close();
2444
- server.closeAllConnections?.();
2445
- server.close();
2446
- },
2447
- urls: { lan, local }
3123
+ findings,
3124
+ lifecycle: { ends, retryReloads },
3125
+ mobileFps: mean !== void 0 ? Math.round(mean) : void 0,
3126
+ playability
2448
3127
  };
2449
3128
  };
2450
3129
  }
@@ -2455,34 +3134,19 @@ var live_exports = {};
2455
3134
  __export(live_exports, {
2456
3135
  collectLive: () => collectLive
2457
3136
  });
2458
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2459
- import path8 from "path";
2460
- 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;
2461
3140
  var init_live = __esm({
2462
3141
  "src/live/index.ts"() {
2463
- "use strict";
2464
- init_driver();
2465
- init_phases();
2466
- VIEWPORTS = [
2467
- { height: 844, name: "mobile", width: 390 },
2468
- { height: 800, name: "desktop", width: 1280 }
2469
- ];
2470
- ensureBrowser = async () => {
2471
- if (browserPath) {
2472
- return browserPath;
2473
- }
2474
- const { Browser, getInstalledBrowsers, install } = await import("@puppeteer/browsers");
2475
- const cacheDir = path8.resolve(process.cwd(), "node_modules/.frogoe-browser");
2476
- const installed = await getInstalledBrowsers({ cacheDir });
2477
- const existing = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
2478
- browserPath = existing?.executablePath ?? (await install({
2479
- browser: Browser.CHROMEHEADLESSSHELL,
2480
- buildId: "131.0.6778.204",
2481
- cacheDir,
2482
- unpack: true
2483
- })).executablePath;
2484
- return browserPath;
2485
- };
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
+ ];
2486
3150
  waitForServer = async (url) => {
2487
3151
  for (let attempt = 0; attempt < 10; attempt++) {
2488
3152
  try {
@@ -2492,11 +3156,11 @@ var init_live = __esm({
2492
3156
  }
2493
3157
  } catch {
2494
3158
  }
2495
- await sleep2(500);
3159
+ await sleep3(500);
2496
3160
  }
2497
3161
  };
2498
3162
  collectLive = async (options) => {
2499
- const dir = path8.resolve(options.dir);
3163
+ const dir = path12.resolve(options.dir);
2500
3164
  const settle = options.settleMs ?? 2e3;
2501
3165
  const findings = [];
2502
3166
  const screenshots = [];
@@ -2506,8 +3170,8 @@ var init_live = __esm({
2506
3170
  };
2507
3171
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
2508
3172
  const server = await startServer2(dir);
2509
- const snapshotDir = path8.join(dir, "snapshots");
2510
- mkdirSync5(snapshotDir, { recursive: true });
3173
+ const snapshotDir = path12.join(dir, "snapshots");
3174
+ mkdirSync6(snapshotDir, { recursive: true });
2511
3175
  const { default: puppeteer } = await import("puppeteer-core");
2512
3176
  const executablePath = await ensureBrowser();
2513
3177
  const browser = await puppeteer.launch({
@@ -2527,15 +3191,15 @@ var init_live = __esm({
2527
3191
  size: { height: viewport2.height, width: viewport2.width }
2528
3192
  });
2529
3193
  const shot = async (name) => {
2530
- writeFileSync4(path8.join(snapshotDir, name), await driver.screenshot());
2531
- screenshots.push(path8.join("snapshots", name));
3194
+ writeFileSync4(path12.join(snapshotDir, name), await driver.screenshot());
3195
+ screenshots.push(path12.join("snapshots", name));
2532
3196
  };
2533
3197
  if (!serverReady) {
2534
3198
  await waitForServer(server.urls.local);
2535
3199
  serverReady = true;
2536
3200
  }
2537
3201
  await page.goto(server.urls.local, { timeout: 15e3, waitUntil: "domcontentloaded" });
2538
- await sleep2(settle);
3202
+ await sleep3(settle);
2539
3203
  if (viewport2.name === "mobile") {
2540
3204
  const outcome = await runLifecycle(driver, {
2541
3205
  settleMs: settle,
@@ -2594,6 +3258,7 @@ var init_check3 = __esm({
2594
3258
  args: {
2595
3259
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2596
3260
  json: { type: "boolean", description: "machine-readable findings" },
3261
+ fast: { type: "boolean", description: "static only, no Chrome (quick iteration)" },
2597
3262
  live: {
2598
3263
  type: "boolean",
2599
3264
  description: "deprecated no-op \u2014 the live sandbox always runs now"
@@ -2605,16 +3270,22 @@ var init_check3 = __esm({
2605
3270
  }
2606
3271
  const dir = args.dir ? String(args.dir) : process.cwd();
2607
3272
  const result = checkProject(dir);
2608
- const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
2609
- console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
2610
- const live = await collectLive2({ dir });
2611
- result.findings = [...result.findings, ...live.findings].sort(
2612
- (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
2613
- );
2614
- result.errors = result.findings.filter((f) => f.severity === "error").length;
2615
- result.warnings = result.findings.filter((f) => f.severity === "warning").length;
2616
- if (live.screenshots.length > 0) {
2617
- 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
+ }
2618
3289
  }
2619
3290
  if (args.json) {
2620
3291
  console.log(JSON.stringify(result, null, 2));
@@ -2634,18 +3305,825 @@ var init_check3 = __esm({
2634
3305
  }
2635
3306
  });
2636
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
+
2637
4115
  // src/commands/init.ts
2638
4116
  var init_exports = {};
2639
4117
  __export(init_exports, {
2640
- command: () => command4
4118
+ command: () => command6
2641
4119
  });
2642
- import { defineCommand as defineCommand4 } from "citty";
2643
- var command4;
4120
+ import { defineCommand as defineCommand6 } from "citty";
4121
+ var command6;
2644
4122
  var init_init2 = __esm({
2645
4123
  "src/commands/init.ts"() {
2646
4124
  "use strict";
2647
4125
  init_init();
2648
- command4 = defineCommand4({
4126
+ command6 = defineCommand6({
2649
4127
  args: {
2650
4128
  force: { type: "boolean", description: "rematerialize over an existing game" },
2651
4129
  name: { type: "positional", description: "folder to create" }
@@ -2668,15 +4146,15 @@ var init_init2 = __esm({
2668
4146
  // src/commands/lint.ts
2669
4147
  var lint_exports = {};
2670
4148
  __export(lint_exports, {
2671
- command: () => command5
4149
+ command: () => command7
2672
4150
  });
2673
- import { defineCommand as defineCommand5 } from "citty";
2674
- var command5;
4151
+ import { defineCommand as defineCommand7 } from "citty";
4152
+ var command7;
2675
4153
  var init_lint = __esm({
2676
4154
  "src/commands/lint.ts"() {
2677
4155
  "use strict";
2678
4156
  init_check2();
2679
- command5 = defineCommand5({
4157
+ command7 = defineCommand7({
2680
4158
  args: {
2681
4159
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2682
4160
  json: { type: "boolean", description: "machine-readable findings" }
@@ -2703,17 +4181,17 @@ var init_lint = __esm({
2703
4181
  // src/commands/report.ts
2704
4182
  var report_exports = {};
2705
4183
  __export(report_exports, {
2706
- command: () => command6
4184
+ command: () => command8
2707
4185
  });
2708
- import { readFileSync as readFileSync6 } from "fs";
2709
- import { defineCommand as defineCommand6 } from "citty";
2710
- var command6;
4186
+ import { readFileSync as readFileSync12 } from "fs";
4187
+ import { defineCommand as defineCommand8 } from "citty";
4188
+ var command8;
2711
4189
  var init_report = __esm({
2712
4190
  "src/commands/report.ts"() {
2713
4191
  "use strict";
2714
4192
  init_records();
2715
4193
  init_session();
2716
- command6 = defineCommand6({
4194
+ command8 = defineCommand8({
2717
4195
  args: {
2718
4196
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
2719
4197
  },
@@ -2724,7 +4202,7 @@ var init_report = __esm({
2724
4202
  console.log(`frogoe report: no sessions in ${dir} \u2014 play a run under \`frogoe run\` first`);
2725
4203
  return;
2726
4204
  }
2727
- 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));
2728
4206
  const s = summarizeRecords(records);
2729
4207
  console.log(`
2730
4208
  frogoe report \u2014 ${file}`);
@@ -2762,8 +4240,8 @@ var init_firewall = __esm({
2762
4240
  return null;
2763
4241
  }
2764
4242
  };
2765
- binaryBlocked = (path10) => {
2766
- const out = run(`--getappblocked "${path10}"`);
4243
+ binaryBlocked = (path17) => {
4244
+ const out = run(`--getappblocked "${path17}"`);
2767
4245
  if (out === null) return null;
2768
4246
  if (/blocked/iu.test(out)) return true;
2769
4247
  if (/allowed/iu.test(out)) return false;
@@ -2821,9 +4299,9 @@ var init_plan = __esm({
2821
4299
 
2822
4300
  // src/net/tunnel.ts
2823
4301
  import { spawn, spawnSync } from "child_process";
2824
- 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";
2825
4303
  import os3 from "os";
2826
- import path9 from "path";
4304
+ import path16 from "path";
2827
4305
  import { gunzipSync } from "zlib";
2828
4306
  var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
2829
4307
  var init_tunnel = __esm({
@@ -2880,9 +4358,9 @@ var init_tunnel = __esm({
2880
4358
  };
2881
4359
  binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
2882
4360
  cacheBase = (platform, env, home) => {
2883
- if (platform === "darwin") return path9.join(home, "Library", "Caches");
2884
- if (platform === "win32") return env.LOCALAPPDATA ?? path9.join(home, "AppData", "Local");
2885
- 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");
2886
4364
  };
2887
4365
  resolveLatestTag = async () => {
2888
4366
  const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
@@ -2933,15 +4411,15 @@ var init_tunnel = __esm({
2933
4411
  );
2934
4412
  }
2935
4413
  const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
2936
- const root = path9.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
2937
- const dir = path9.join(root, tag);
2938
- const bin = path9.join(dir, binaryFileName(platform));
2939
- const rootResolved = path9.resolve(root);
2940
- const binResolved = path9.resolve(bin);
2941
- 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)) {
2942
4420
  throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
2943
4421
  }
2944
- if (existsSync6(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
4422
+ if (existsSync11(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
2945
4423
  onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
2946
4424
  const res = await fetch(
2947
4425
  `https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
@@ -2952,8 +4430,8 @@ var init_tunnel = __esm({
2952
4430
  });
2953
4431
  const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
2954
4432
  if (!binary) throw new Error("cloudflared archive did not contain the binary");
2955
- mkdirSync6(dir, { recursive: true });
2956
- writeFileSync5(bin, binary);
4433
+ mkdirSync8(dir, { recursive: true });
4434
+ writeFileSync6(bin, binary);
2957
4435
  if (platform !== "win32") chmodSync(bin, 493);
2958
4436
  if (!binaryEchoes(bin, tag)) {
2959
4437
  throw new Error(
@@ -3040,10 +4518,10 @@ var init_tunnel = __esm({
3040
4518
  // src/commands/run.ts
3041
4519
  var run_exports2 = {};
3042
4520
  __export(run_exports2, {
3043
- command: () => command7
4521
+ command: () => command9
3044
4522
  });
3045
- import { defineCommand as defineCommand7 } from "citty";
3046
- var NUDGE_MS, printQr, message, command7;
4523
+ import { defineCommand as defineCommand9 } from "citty";
4524
+ var NUDGE_MS, printQr, message, command9;
3047
4525
  var init_run2 = __esm({
3048
4526
  "src/commands/run.ts"() {
3049
4527
  "use strict";
@@ -3054,6 +4532,7 @@ var init_run2 = __esm({
3054
4532
  init_run();
3055
4533
  NUDGE_MS = 1e4;
3056
4534
  printQr = (url) => {
4535
+ if (!process.stdout.isTTY) return;
3057
4536
  void import("qrcode-terminal").then((mod) => {
3058
4537
  const qrcode = mod.default ?? mod;
3059
4538
  qrcode.generate(url, { small: true }, (qr) => console.log(qr));
@@ -3062,7 +4541,7 @@ var init_run2 = __esm({
3062
4541
  });
3063
4542
  };
3064
4543
  message = (error) => error instanceof Error ? error.message : String(error);
3065
- command7 = defineCommand7({
4544
+ command9 = defineCommand9({
3066
4545
  args: {
3067
4546
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
3068
4547
  port: { type: "string", description: "port (default: random free)" },
@@ -3164,8 +4643,8 @@ var init_run2 = __esm({
3164
4643
 
3165
4644
  // src/utils/skillsManifest.ts
3166
4645
  import { execFile } from "child_process";
3167
- import { createHash as createHash2 } from "crypto";
3168
- 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";
3169
4648
  import { homedir } from "os";
3170
4649
  import { isAbsolute, join, relative, resolve, sep } from "path";
3171
4650
  import { promisify } from "util";
@@ -3178,7 +4657,7 @@ function listFilesSorted(dir) {
3178
4657
  for (const name of readdirSync3(d)) {
3179
4658
  if (name === ".DS_Store") continue;
3180
4659
  const p = join(d, name);
3181
- if (statSync2(p).isDirectory()) walk(p);
4660
+ if (statSync3(p).isDirectory()) walk(p);
3182
4661
  else out.push(p);
3183
4662
  }
3184
4663
  };
@@ -3187,21 +4666,21 @@ function listFilesSorted(dir) {
3187
4666
  }
3188
4667
  function hashSkillBundle(skillDir) {
3189
4668
  const files = listFilesSorted(skillDir);
3190
- const h = createHash2("sha256");
4669
+ const h = createHash3("sha256");
3191
4670
  for (const f of files) {
3192
4671
  const rel = relative(skillDir, f).split(sep).join("/");
3193
4672
  h.update(rel);
3194
4673
  h.update("\0");
3195
4674
  const ext = rel.slice(rel.lastIndexOf("."));
3196
- const buf = readFileSync7(f);
4675
+ const buf = readFileSync13(f);
3197
4676
  if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
3198
4677
  else h.update(buf);
3199
4678
  h.update("\0");
3200
4679
  }
3201
4680
  return { hash: h.digest("hex").slice(0, 16), files: files.length };
3202
4681
  }
3203
- function buildManifest(skillsRoot, meta) {
3204
- 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();
3205
4684
  const skills = {};
3206
4685
  for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
3207
4686
  return { source: meta.source, skills };
@@ -3226,7 +4705,7 @@ function discoverSkillRoots(base, scope) {
3226
4705
  const candidates = [];
3227
4706
  const add = (hostBase, host) => {
3228
4707
  const dir = join(hostBase, host, "skills");
3229
- if (existsSync7(dir) && statSync2(dir).isDirectory())
4708
+ if (existsSync12(dir) && statSync3(dir).isDirectory())
3230
4709
  candidates.push({ dir, agent: agentLabel(host), scope });
3231
4710
  };
3232
4711
  for (const host of listSubdirs(base)) add(base, host);
@@ -3253,7 +4732,7 @@ function scopeForDir(dir, home, cwd) {
3253
4732
  }
3254
4733
  function locateInstall(skillNames, opts = {}) {
3255
4734
  if (opts.dir) {
3256
- return existsSync7(opts.dir) ? {
4735
+ return existsSync12(opts.dir) ? {
3257
4736
  dir: opts.dir,
3258
4737
  agent: agentFromDir(opts.dir),
3259
4738
  scope: scopeForDir(opts.dir, opts.home ?? homedir(), opts.cwd ?? process.cwd())
@@ -3264,7 +4743,7 @@ function locateInstall(skillNames, opts = {}) {
3264
4743
  ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project")
3265
4744
  ];
3266
4745
  for (const root of roots) {
3267
- 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;
3268
4747
  }
3269
4748
  return null;
3270
4749
  }
@@ -3272,7 +4751,7 @@ function hashInstalled(root, skillNames) {
3272
4751
  const out = {};
3273
4752
  for (const name of skillNames) {
3274
4753
  const skillDir = join(root.dir, name);
3275
- if (existsSync7(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
4754
+ if (existsSync12(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
3276
4755
  }
3277
4756
  return out;
3278
4757
  }
@@ -3309,7 +4788,7 @@ function findRepoManifest(cwd = process.cwd()) {
3309
4788
  let dir = cwd;
3310
4789
  for (let i = 0; i < 16; i++) {
3311
4790
  const p = join(dir, MANIFEST_FILE);
3312
- if (existsSync7(p)) return p;
4791
+ if (existsSync12(p)) return p;
3313
4792
  const parent = join(dir, "..");
3314
4793
  if (parent === dir) break;
3315
4794
  dir = parent;
@@ -3349,9 +4828,9 @@ async function remoteHeadSha(repoSlug) {
3349
4828
  }
3350
4829
  function resolveLocalManifest(source) {
3351
4830
  const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
3352
- if (existsSync7(direct)) return JSON.parse(readFileSync7(direct, "utf8"));
4831
+ if (existsSync12(direct)) return JSON.parse(readFileSync13(direct, "utf8"));
3353
4832
  const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
3354
- if (existsSync7(skillsRoot)) return buildManifest(skillsRoot, { source: skillsRoot });
4833
+ if (existsSync12(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
3355
4834
  throw new Error(`No skills manifest found at: ${source}`);
3356
4835
  }
3357
4836
  async function fetchRemoteManifest(source) {
@@ -3374,7 +4853,7 @@ async function resolveLatestManifest(source, cwd = process.cwd(), opts = {}) {
3374
4853
  }
3375
4854
  if (!source && !opts.canonical) {
3376
4855
  const repoManifest = findRepoManifest(cwd);
3377
- if (repoManifest) return JSON.parse(readFileSync7(repoManifest, "utf8"));
4856
+ if (repoManifest) return JSON.parse(readFileSync13(repoManifest, "utf8"));
3378
4857
  }
3379
4858
  return fetchRemoteManifest(source);
3380
4859
  }
@@ -3425,9 +4904,9 @@ var init_skillsManifest = __esm({
3425
4904
  // src/commands/skills.ts
3426
4905
  var skills_exports = {};
3427
4906
  __export(skills_exports, {
3428
- command: () => command8
4907
+ command: () => command10
3429
4908
  });
3430
- import { defineCommand as defineCommand8 } from "citty";
4909
+ import { defineCommand as defineCommand10 } from "citty";
3431
4910
  import { execFileSync, spawn as spawn2 } from "child_process";
3432
4911
  function hasNpx() {
3433
4912
  try {
@@ -3519,7 +4998,7 @@ function renderCheck(result) {
3519
4998
  }
3520
4999
  console.log();
3521
5000
  }
3522
- var GLOBAL_INSTALL_ARGS_TAIL, SOURCE_URL, checkCommand, updateCommand, command8;
5001
+ var GLOBAL_INSTALL_ARGS_TAIL, SOURCE_URL, checkCommand, updateCommand, command10;
3523
5002
  var init_skills = __esm({
3524
5003
  "src/commands/skills.ts"() {
3525
5004
  "use strict";
@@ -3534,7 +5013,7 @@ var init_skills = __esm({
3534
5013
  "--yes"
3535
5014
  ];
3536
5015
  SOURCE_URL = "https://github.com/frogoe/engine";
3537
- checkCommand = defineCommand8({
5016
+ checkCommand = defineCommand10({
3538
5017
  meta: { name: "check", description: "Check whether installed skills are the latest version" },
3539
5018
  args: {
3540
5019
  json: { type: "boolean", description: "Output as JSON", default: false },
@@ -3566,7 +5045,7 @@ var init_skills = __esm({
3566
5045
  }
3567
5046
  }
3568
5047
  });
3569
- updateCommand = defineCommand8({
5048
+ updateCommand = defineCommand10({
3570
5049
  meta: {
3571
5050
  name: "update",
3572
5051
  description: "Update frogoe skills to the latest (core + installed). Pass names to also install them."
@@ -3613,7 +5092,7 @@ var init_skills = __esm({
3613
5092
  }
3614
5093
  }
3615
5094
  });
3616
- command8 = defineCommand8({
5095
+ command10 = defineCommand10({
3617
5096
  meta: {
3618
5097
  name: "skills",
3619
5098
  description: "Install, check, and update frogoe skills for AI coding tools"
@@ -3636,12 +5115,12 @@ var init_skills = __esm({
3636
5115
  });
3637
5116
 
3638
5117
  // src/cli.ts
3639
- import { defineCommand as defineCommand9, runMain } from "citty";
5118
+ import { defineCommand as defineCommand11, runMain } from "citty";
3640
5119
 
3641
5120
  // package.json
3642
5121
  var package_default = {
3643
5122
  name: "frogoe",
3644
- version: "0.3.2",
5123
+ version: "0.5.5",
3645
5124
  description: "froge CLI \u2014 the agent's hands: init, add, run, check, bundle",
3646
5125
  homepage: "https://github.com/frogoe/engine#readme",
3647
5126
  bugs: "https://github.com/frogoe/engine/issues",
@@ -3704,6 +5183,16 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
3704
5183
  console.log(VERSION);
3705
5184
  process.exit(0);
3706
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
+ }
3707
5196
  var HELP = `frogoe ${VERSION} \u2014 write a closure, ship a game
3708
5197
 
3709
5198
  Commands:
@@ -3712,17 +5201,90 @@ Commands:
3712
5201
  run [dir] serve with live reload + phone QR (--tunnel: any network)
3713
5202
  lint [dir] static contract lint \u2014 fast iteration (stable codes; --json)
3714
5203
  check [dir] full gate: lint + live Chrome sandbox (FPS, HUD outline)
5204
+ --fast: static only, no Chrome (quick iteration)
3715
5205
  report [dir] last playtest session: fps dips, errors, when
3716
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)
3717
5211
  skills [check|update] skill freshness \u2014 check or update via npx skills add
3718
5212
 
3719
5213
  Docs: skills/frogoe-core \u2014 the whole contract in five references.`;
3720
- 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({
3721
5281
  meta: { description: HELP },
3722
5282
  subCommands: {
3723
5283
  add: () => Promise.resolve().then(() => (init_add2(), add_exports)).then((m) => m.command),
3724
5284
  bundle: () => Promise.resolve().then(() => (init_bundle2(), bundle_exports)).then((m) => m.command),
3725
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),
3726
5288
  init: () => Promise.resolve().then(() => (init_init2(), init_exports)).then((m) => m.command),
3727
5289
  lint: () => Promise.resolve().then(() => (init_lint(), lint_exports)).then((m) => m.command),
3728
5290
  report: () => Promise.resolve().then(() => (init_report(), report_exports)).then((m) => m.command),