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