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/README.md +19 -2
- package/dist/cli.js +2920 -1358
- package/dist/injected-runtime.js +247 -0
- package/dist/templates/_shared/AGENTS.md +18 -12
- package/dist/templates/_shared/CLAUDE.md +18 -12
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
536
|
-
if (
|
|
537
|
-
return
|
|
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
|
|
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
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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/
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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/
|
|
1174
|
-
var
|
|
1175
|
-
var
|
|
1176
|
-
"src/
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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/
|
|
1302
|
-
|
|
1303
|
-
var
|
|
1304
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
});
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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/
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
const
|
|
1566
|
-
if (
|
|
1567
|
-
|
|
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
|
-
|
|
1571
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1588
|
-
if (
|
|
1589
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
|
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/
|
|
1732
|
-
var
|
|
1733
|
-
var
|
|
1734
|
-
"src/
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
1814
|
-
return { findings, fps: Math.round(mean) };
|
|
1747
|
+
return spans;
|
|
1815
1748
|
};
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
const
|
|
1831
|
-
|
|
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
|
-
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
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
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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
|
|
1869
|
-
|
|
1870
|
-
let
|
|
1871
|
-
let
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
|
1938
|
-
const
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
await
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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/
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
|
|
2050
|
-
const
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const
|
|
2078
|
-
if (
|
|
2079
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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
|
-
|
|
2099
|
-
if (
|
|
2100
|
-
|
|
2101
|
-
|
|
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
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|
|
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/
|
|
2133
|
-
var
|
|
2134
|
-
var
|
|
2135
|
-
"src/
|
|
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
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
const
|
|
2256
|
-
|
|
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
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
2320
|
-
const
|
|
2321
|
-
|
|
2322
|
-
|
|
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
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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
|
-
|
|
2385
|
-
|
|
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
|
-
|
|
2388
|
-
|
|
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
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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
|
|
2416
|
-
const
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
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
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
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
|
|
2459
|
-
import
|
|
2460
|
-
var VIEWPORTS,
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
{ height:
|
|
2469
|
-
|
|
2470
|
-
|
|
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
|
|
3159
|
+
await sleep3(500);
|
|
2496
3160
|
}
|
|
2497
3161
|
};
|
|
2498
3162
|
collectLive = async (options) => {
|
|
2499
|
-
const 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 =
|
|
2510
|
-
|
|
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(
|
|
2531
|
-
screenshots.push(
|
|
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
|
|
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
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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: () =>
|
|
4118
|
+
command: () => command6
|
|
2641
4119
|
});
|
|
2642
|
-
import { defineCommand as
|
|
2643
|
-
var
|
|
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
|
-
|
|
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: () =>
|
|
4149
|
+
command: () => command7
|
|
2672
4150
|
});
|
|
2673
|
-
import { defineCommand as
|
|
2674
|
-
var
|
|
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
|
-
|
|
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: () =>
|
|
4184
|
+
command: () => command8
|
|
2707
4185
|
});
|
|
2708
|
-
import { readFileSync as
|
|
2709
|
-
import { defineCommand as
|
|
2710
|
-
var
|
|
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
|
-
|
|
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 =
|
|
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 = (
|
|
2766
|
-
const out = run(`--getappblocked "${
|
|
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
|
|
4302
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync8, chmodSync, writeFileSync as writeFileSync6 } from "fs";
|
|
2825
4303
|
import os3 from "os";
|
|
2826
|
-
import
|
|
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
|
|
2884
|
-
if (platform === "win32") return env.LOCALAPPDATA ??
|
|
2885
|
-
return
|
|
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 =
|
|
2937
|
-
const dir =
|
|
2938
|
-
const bin =
|
|
2939
|
-
const rootResolved =
|
|
2940
|
-
const binResolved =
|
|
2941
|
-
if (!binResolved.startsWith(rootResolved +
|
|
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 (
|
|
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
|
-
|
|
2956
|
-
|
|
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: () =>
|
|
4521
|
+
command: () => command9
|
|
3044
4522
|
});
|
|
3045
|
-
import { defineCommand as
|
|
3046
|
-
var NUDGE_MS, printQr, message,
|
|
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
|
-
|
|
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
|
|
3168
|
-
import { existsSync as
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
|
3204
|
-
const names = readdirSync3(skillsRoot).filter((n) =>
|
|
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 (
|
|
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
|
|
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) =>
|
|
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 (
|
|
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 (
|
|
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 (
|
|
4831
|
+
if (existsSync12(direct)) return JSON.parse(readFileSync13(direct, "utf8"));
|
|
3353
4832
|
const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
|
|
3354
|
-
if (
|
|
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(
|
|
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: () =>
|
|
4907
|
+
command: () => command10
|
|
3429
4908
|
});
|
|
3430
|
-
import { defineCommand as
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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),
|