frogoe 0.1.0
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/bin/frogoe.mjs +11 -0
- package/dist/cli.js +3124 -0
- package/dist/contract/contract.js +236 -0
- package/dist/registry/blocks/coin-counter/coin-counter.html +101 -0
- package/dist/registry/blocks/coin-counter/registry-item.json +27 -0
- package/dist/registry/blocks/combo-counter/combo-counter.html +106 -0
- package/dist/registry/blocks/combo-counter/registry-item.json +27 -0
- package/dist/registry/blocks/fuel-gauge/fuel-gauge.html +130 -0
- package/dist/registry/blocks/fuel-gauge/registry-item.json +29 -0
- package/dist/registry/blocks/game-over-card/game-over-card.html +185 -0
- package/dist/registry/blocks/game-over-card/registry-item.json +30 -0
- package/dist/registry/blocks/hearts-row/hearts-row.html +92 -0
- package/dist/registry/blocks/hearts-row/registry-item.json +27 -0
- package/dist/registry/blocks/mini-board/mini-board.html +125 -0
- package/dist/registry/blocks/mini-board/registry-item.json +28 -0
- package/dist/registry/blocks/objective-chip/objective-chip.html +101 -0
- package/dist/registry/blocks/objective-chip/registry-item.json +29 -0
- package/dist/registry/blocks/ready-gate/ready-gate.html +124 -0
- package/dist/registry/blocks/ready-gate/registry-item.json +28 -0
- package/dist/registry/blocks/ready-hint/ready-hint.html +104 -0
- package/dist/registry/blocks/ready-hint/registry-item.json +29 -0
- package/dist/registry/blocks/score-card/registry-item.json +27 -0
- package/dist/registry/blocks/score-card/score-card.html +92 -0
- package/dist/registry/blocks/timer-ring/registry-item.json +28 -0
- package/dist/registry/blocks/timer-ring/timer-ring.html +123 -0
- package/dist/registry/registry.json +51 -0
- package/dist/registry/schema/registry-item.schema.json +36 -0
- package/dist/registry/schema/registry.schema.json +22 -0
- package/dist/runtimeVersion.js +10 -0
- package/dist/templates/_shared/AGENTS.md +105 -0
- package/dist/templates/_shared/CLAUDE.md +105 -0
- package/package.json +42 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/templates.ts
|
|
13
|
+
var CONTRACT_VERSION, contractHeader, briefTemplate, frogoeJsonTemplate, indexTemplate, gameTemplate, gitignoreTemplate;
|
|
14
|
+
var init_templates = __esm({
|
|
15
|
+
"src/templates.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
CONTRACT_VERSION = "0.1.0";
|
|
18
|
+
contractHeader = (version) => [
|
|
19
|
+
`// frogoe contract v${version} (materialized by frogoe init \u2014 do not edit)`,
|
|
20
|
+
"// frogoe check verifies this marker against frogoe.json; upgrade with: frogoe init --force",
|
|
21
|
+
""
|
|
22
|
+
].join("\n");
|
|
23
|
+
briefTemplate = `---
|
|
24
|
+
title: My Game
|
|
25
|
+
verb: tap # tap | hold | steer | aim \u2014 ONE word, the core action
|
|
26
|
+
mood: TODO \u2014 one phrase: "dawn uplift", "arcade cabinet at midnight"...
|
|
27
|
+
palette:
|
|
28
|
+
bg: "#101418" # TODO
|
|
29
|
+
fg: "#fffdf7" # TODO
|
|
30
|
+
accent: "#ffd166" # TODO
|
|
31
|
+
fonts: # optional display voice; omit for a quiet system stack
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
TODO \u2014 two lines: what the player does, and what ends the run.
|
|
35
|
+
`;
|
|
36
|
+
frogoeJsonTemplate = (version) => `${JSON.stringify({ contract: version }, null, 2)}
|
|
37
|
+
`;
|
|
38
|
+
indexTemplate = `<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
43
|
+
<title>My Game</title>
|
|
44
|
+
<style>
|
|
45
|
+
html, body { margin: 0; height: 100%; overflow: hidden; touch-action: none;
|
|
46
|
+
background: var(--game-bg, #101418);
|
|
47
|
+
/* phones are the primary device: no long-press selection, no callout */
|
|
48
|
+
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
49
|
+
#c { display: block; width: 100vw; height: 100vh; }
|
|
50
|
+
|
|
51
|
+
/* HUD layer + safe-area-aware placement (frogoe-core \u2192 hud-bindings) */
|
|
52
|
+
.hud { position: absolute; inset: 0; pointer-events: none; }
|
|
53
|
+
.hud > * { position: absolute; }
|
|
54
|
+
.hud [data-pos="top-left"] { top: calc(env(safe-area-inset-top) + 12px); left: calc(env(safe-area-inset-left) + 12px); }
|
|
55
|
+
.hud [data-pos="top-center"] { top: calc(env(safe-area-inset-top) + 12px); left: 50%; translate: -50% 0; }
|
|
56
|
+
.hud [data-pos="top-right"] { top: calc(env(safe-area-inset-top) + 12px); right: calc(env(safe-area-inset-right) + 12px); }
|
|
57
|
+
.hud [data-pos="bottom-left"] { bottom: calc(env(safe-area-inset-bottom) + 12px); left: calc(env(safe-area-inset-left) + 12px); }
|
|
58
|
+
.hud [data-pos="bottom-center"] { bottom: calc(env(safe-area-inset-bottom) + 12px); left: 50%; translate: -50% 0; }
|
|
59
|
+
|
|
60
|
+
/* theme every block from the BRIEF palette \u2014 one parent rule */
|
|
61
|
+
.hud {
|
|
62
|
+
--block-accent: #ffd166;
|
|
63
|
+
--block-fg: #fffdf7;
|
|
64
|
+
--block-outline: #0a0e18;
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
67
|
+
<script type="importmap">
|
|
68
|
+
{
|
|
69
|
+
"imports": {
|
|
70
|
+
"frogoe": "./.frogoe/contract.js"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<canvas id="c"></canvas>
|
|
77
|
+
<div class="hud">
|
|
78
|
+
<!-- registry blocks land here (frogoe add score-card) -->
|
|
79
|
+
</div>
|
|
80
|
+
<script type="module" src="game.js"></script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
83
|
+
`;
|
|
84
|
+
gameTemplate = `/** My Game \u2014 the whole simulation in one closure. Replace everything
|
|
85
|
+
* below with your game; the four nouns are the entire platform. */
|
|
86
|
+
import { defineGame } from "frogoe";
|
|
87
|
+
|
|
88
|
+
defineGame(({ stage, input, loop, finish }) => {
|
|
89
|
+
let t = 0;
|
|
90
|
+
let x = stage.play.center;
|
|
91
|
+
|
|
92
|
+
input.on("down", () => {
|
|
93
|
+
finish(0); // stub: replace with your real run end
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
loop.update = (dt) => {
|
|
97
|
+
t += dt;
|
|
98
|
+
x = stage.play.center + Math.sin(t * 2) * (stage.play.width * 0.3);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
loop.render = (ctx) => {
|
|
102
|
+
ctx.clearRect(0, 0, stage.width, stage.height);
|
|
103
|
+
ctx.fillStyle = "#fffdf7";
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
ctx.arc(x, stage.height / 2, 14 + Math.sin(t * 3) * 3, 0, 7);
|
|
106
|
+
ctx.fill();
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
`;
|
|
110
|
+
gitignoreTemplate = `.frogoe/
|
|
111
|
+
dist/
|
|
112
|
+
snapshots/
|
|
113
|
+
node_modules/
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// src/init.ts
|
|
119
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
120
|
+
import path from "path";
|
|
121
|
+
import { fileURLToPath } from "url";
|
|
122
|
+
var here, CONTRACT_SOURCE, scaffold, registryRoot;
|
|
123
|
+
var init_init = __esm({
|
|
124
|
+
"src/init.ts"() {
|
|
125
|
+
"use strict";
|
|
126
|
+
init_templates();
|
|
127
|
+
here = path.dirname(fileURLToPath(import.meta.url));
|
|
128
|
+
CONTRACT_SOURCE = existsSync(path.join(here, "../../contract/src/contract.js")) ? path.join(here, "../../contract/src/contract.js") : path.join(here, "../contract/contract.js");
|
|
129
|
+
scaffold = (name, options) => {
|
|
130
|
+
const root = options?.dir ?? process.cwd();
|
|
131
|
+
const target = path.resolve(root, name);
|
|
132
|
+
if (existsSync(target)) {
|
|
133
|
+
const occupied = existsSync(path.join(target, "frogoe.json"));
|
|
134
|
+
if (occupied && !options?.force) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`frogoe init: ${target} already has a frogoe game (frogoe.json present). Use --force to rematerialize .frogoe/ and scaffolds.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
mkdirSync(path.join(target, ".frogoe"), { recursive: true });
|
|
141
|
+
mkdirSync(path.join(target, "blocks"), { recursive: true });
|
|
142
|
+
const contract = contractHeader(CONTRACT_VERSION) + readFileSync(CONTRACT_SOURCE, "utf-8");
|
|
143
|
+
const sharedDir = path.join(here, "templates/_shared");
|
|
144
|
+
const agentDocs = existsSync(path.join(sharedDir, "CLAUDE.md")) ? readFileSync(path.join(sharedDir, "CLAUDE.md"), "utf-8") : "";
|
|
145
|
+
const agentsDocs = existsSync(path.join(sharedDir, "AGENTS.md")) ? readFileSync(path.join(sharedDir, "AGENTS.md"), "utf-8") : "";
|
|
146
|
+
const files = [
|
|
147
|
+
[".gitignore", gitignoreTemplate, true],
|
|
148
|
+
...agentDocs ? [["CLAUDE.md", agentDocs, true]] : [],
|
|
149
|
+
...agentsDocs ? [["AGENTS.md", agentsDocs, true]] : [],
|
|
150
|
+
["BRIEF.md", briefTemplate, true],
|
|
151
|
+
["frogoe.json", frogoeJsonTemplate(CONTRACT_VERSION), true],
|
|
152
|
+
["index.html", indexTemplate, false],
|
|
153
|
+
["game.js", gameTemplate, false],
|
|
154
|
+
[".frogoe/contract.js", contract, true],
|
|
155
|
+
["blocks/.gitkeep", "", true]
|
|
156
|
+
];
|
|
157
|
+
for (const [rel, body, overwrite] of files) {
|
|
158
|
+
const dest = path.join(target, rel);
|
|
159
|
+
if (!overwrite && existsSync(dest)) continue;
|
|
160
|
+
writeFileSync(dest, body, "utf-8");
|
|
161
|
+
}
|
|
162
|
+
return { dir: target, files: files.map(([rel]) => rel) };
|
|
163
|
+
};
|
|
164
|
+
registryRoot = () => {
|
|
165
|
+
const candidates = [
|
|
166
|
+
path.resolve(here, "../../../registry"),
|
|
167
|
+
// repo source mode
|
|
168
|
+
path.resolve(here, "../registry")
|
|
169
|
+
// dist mode (build:copy)
|
|
170
|
+
];
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
if (existsSync(path.join(candidate, "registry.json"))) {
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw new Error(
|
|
177
|
+
"frogoe: registry not found (expected ../registry or packaged copy). Run from the repo or install @frogoe/cli fully."
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// src/add.ts
|
|
184
|
+
import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
185
|
+
import path2 from "path";
|
|
186
|
+
var STYLE_PATTERN, MARKUP_PATTERN, parseBlock, blockMarker, injectIntoHtml, addBlock;
|
|
187
|
+
var init_add = __esm({
|
|
188
|
+
"src/add.ts"() {
|
|
189
|
+
"use strict";
|
|
190
|
+
init_init();
|
|
191
|
+
STYLE_PATTERN = /<style>([\s\S]*?)<\/style>/u;
|
|
192
|
+
MARKUP_PATTERN = /<\/style>\s*([\s\S]*)$/u;
|
|
193
|
+
parseBlock = (source) => {
|
|
194
|
+
const styleMatch = STYLE_PATTERN.exec(source);
|
|
195
|
+
const markupMatch = MARKUP_PATTERN.exec(source);
|
|
196
|
+
return {
|
|
197
|
+
css: styleMatch?.[1]?.trim() ?? null,
|
|
198
|
+
markup: (markupMatch?.[1] ?? "").trim()
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
blockMarker = (name) => `<!-- frogoe:block:${name} -->`;
|
|
202
|
+
injectIntoHtml = (html, name, css, markup, placement) => {
|
|
203
|
+
if (!css && !markup) return null;
|
|
204
|
+
const marker = blockMarker(name);
|
|
205
|
+
let out = html;
|
|
206
|
+
const firstIdx = out.indexOf(marker);
|
|
207
|
+
if (firstIdx !== -1) {
|
|
208
|
+
const lastIdx = out.lastIndexOf(marker);
|
|
209
|
+
if (lastIdx > firstIdx) {
|
|
210
|
+
out = out.slice(0, firstIdx).trimEnd() + "\n " + out.slice(lastIdx + marker.length);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (css) {
|
|
214
|
+
const styleClose = out.lastIndexOf("</style>");
|
|
215
|
+
if (styleClose === -1) return null;
|
|
216
|
+
const cssBlock = `
|
|
217
|
+
/* ===== ${name} (frogoe add) ===== */
|
|
218
|
+
${css}
|
|
219
|
+
`;
|
|
220
|
+
out = out.slice(0, styleClose) + cssBlock + out.slice(styleClose);
|
|
221
|
+
}
|
|
222
|
+
if (markup) {
|
|
223
|
+
const pos = placement === "overlay-fullscreen" ? "" : ' data-pos="top-left"';
|
|
224
|
+
const wrapped = `
|
|
225
|
+
${marker}
|
|
226
|
+
<div${pos}>
|
|
227
|
+
${markup.split("\n").map((l) => ` ${l}`).join("\n")}
|
|
228
|
+
</div>
|
|
229
|
+
${marker}`;
|
|
230
|
+
const hudOpen = out.indexOf('class="hud"');
|
|
231
|
+
if (hudOpen === -1) return null;
|
|
232
|
+
const afterHudOpen = out.indexOf(">", hudOpen);
|
|
233
|
+
if (afterHudOpen === -1) return null;
|
|
234
|
+
let depth = 1;
|
|
235
|
+
let searchFrom = afterHudOpen + 1;
|
|
236
|
+
let hudEnd = -1;
|
|
237
|
+
while (searchFrom < out.length) {
|
|
238
|
+
const open = out.indexOf("<div", searchFrom);
|
|
239
|
+
const close = out.indexOf("</div>", searchFrom);
|
|
240
|
+
if (close === -1) break;
|
|
241
|
+
if (open !== -1 && open < close) {
|
|
242
|
+
depth++;
|
|
243
|
+
searchFrom = open + 4;
|
|
244
|
+
} else {
|
|
245
|
+
depth--;
|
|
246
|
+
if (depth === 0) {
|
|
247
|
+
hudEnd = close;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
searchFrom = close + 6;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (hudEnd === -1) return null;
|
|
254
|
+
out = out.slice(0, hudEnd) + wrapped + out.slice(hudEnd);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
};
|
|
258
|
+
addBlock = (name, options) => {
|
|
259
|
+
const root = options?.dir ?? process.cwd();
|
|
260
|
+
const registry = registryRoot();
|
|
261
|
+
const blockDir = path2.join(registry, "blocks", name);
|
|
262
|
+
if (!existsSync2(blockDir)) {
|
|
263
|
+
const available = readFileSync2(path2.join(registry, "registry.json"), "utf-8");
|
|
264
|
+
const known = JSON.parse(available).items.map((i) => i.name).join(", ");
|
|
265
|
+
throw new Error(`frogoe add: unknown block "${name}". Available: ${known}`);
|
|
266
|
+
}
|
|
267
|
+
const item = JSON.parse(
|
|
268
|
+
readFileSync2(path2.join(blockDir, "registry-item.json"), "utf-8")
|
|
269
|
+
);
|
|
270
|
+
const markupFiles = item.files.filter((f) => f.type === "frogoe:markup").map((f) => path2.join(blockDir, f.path));
|
|
271
|
+
if (markupFiles.length === 0) {
|
|
272
|
+
throw new Error(`frogoe add: block "${name}" has no markup file in its manifest.`);
|
|
273
|
+
}
|
|
274
|
+
const source = markupFiles.map((f) => readFileSync2(f, "utf-8")).join("\n");
|
|
275
|
+
for (const binding of item.bindings ?? []) {
|
|
276
|
+
if (!source.includes(binding)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`frogoe add: block "${name}" is broken \u2014 manifest binding ${binding} missing from its markup. Report it; nothing was copied.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
mkdirSync2(path2.join(root, "blocks"), { recursive: true });
|
|
283
|
+
cpSync(markupFiles[0] ?? "", path2.join(root, "blocks", `${name}.html`));
|
|
284
|
+
let injected = false;
|
|
285
|
+
const placement = item.placement ?? "overlay";
|
|
286
|
+
if (!options?.noInject) {
|
|
287
|
+
const indexPath = path2.join(root, "index.html");
|
|
288
|
+
if (existsSync2(indexPath)) {
|
|
289
|
+
const html = readFileSync2(indexPath, "utf-8");
|
|
290
|
+
const { css, markup } = parseBlock(source);
|
|
291
|
+
const modified = injectIntoHtml(html, name, css, markup, placement);
|
|
292
|
+
if (modified) {
|
|
293
|
+
writeFileSync2(indexPath, modified, "utf-8");
|
|
294
|
+
injected = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
bindings: item.bindings ?? [],
|
|
300
|
+
block: name,
|
|
301
|
+
injected,
|
|
302
|
+
placement
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// src/commands/add.ts
|
|
309
|
+
var add_exports = {};
|
|
310
|
+
__export(add_exports, {
|
|
311
|
+
command: () => command
|
|
312
|
+
});
|
|
313
|
+
import { defineCommand } from "citty";
|
|
314
|
+
var command;
|
|
315
|
+
var init_add2 = __esm({
|
|
316
|
+
"src/commands/add.ts"() {
|
|
317
|
+
"use strict";
|
|
318
|
+
init_add();
|
|
319
|
+
command = defineCommand({
|
|
320
|
+
args: {
|
|
321
|
+
block: { type: "positional", description: "registry block name (e.g. score-card)" }
|
|
322
|
+
},
|
|
323
|
+
async run({ args }) {
|
|
324
|
+
if (!args.block) {
|
|
325
|
+
throw new Error("frogoe add <block> \u2014 e.g. frogoe add fuel-gauge");
|
|
326
|
+
}
|
|
327
|
+
const result = addBlock(String(args.block));
|
|
328
|
+
console.log(`frogoe add \u2014 ${result.block}`);
|
|
329
|
+
if (result.injected) {
|
|
330
|
+
console.log(` \u2713 styles + markup injected into index.html (idempotent \u2014 re-add replaces)`);
|
|
331
|
+
console.log(` \u2713 source copied to blocks/${result.block}.html (for custom edits)`);
|
|
332
|
+
} else {
|
|
333
|
+
console.log(` \u2713 source copied to blocks/${result.block}.html`);
|
|
334
|
+
console.log(` \u26A0 could not inject into index.html \u2014 paste markup + styles manually`);
|
|
335
|
+
}
|
|
336
|
+
console.log(` bindings: ${result.bindings.join(", ") || "(none)"}`);
|
|
337
|
+
console.log(` drive it from game.js: document.querySelector('[data-block-...]')`);
|
|
338
|
+
},
|
|
339
|
+
meta: { description: "copy a registry HUD block into blocks/" }
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// src/fetch-policy.ts
|
|
345
|
+
var DEFAULT_FETCH_POLICY, FetchUnavailableError, FetchNotServedError, sleep, fetchWithPolicy;
|
|
346
|
+
var init_fetch_policy = __esm({
|
|
347
|
+
"src/fetch-policy.ts"() {
|
|
348
|
+
"use strict";
|
|
349
|
+
DEFAULT_FETCH_POLICY = {
|
|
350
|
+
attemptTimeoutMs: 8e3,
|
|
351
|
+
baseDelayMs: 250,
|
|
352
|
+
maxAttempts: 2,
|
|
353
|
+
maxElapsedMs: 2e4
|
|
354
|
+
};
|
|
355
|
+
FetchUnavailableError = class extends Error {
|
|
356
|
+
constructor(url, cause) {
|
|
357
|
+
super(`frogoe bundle: fetch unavailable: ${url}`);
|
|
358
|
+
this.url = url;
|
|
359
|
+
this.cause = cause;
|
|
360
|
+
this.name = "FetchUnavailableError";
|
|
361
|
+
}
|
|
362
|
+
url;
|
|
363
|
+
};
|
|
364
|
+
FetchNotServedError = class extends Error {
|
|
365
|
+
constructor(url, status) {
|
|
366
|
+
super(`frogoe bundle: not served (${status}): ${url}`);
|
|
367
|
+
this.url = url;
|
|
368
|
+
this.status = status;
|
|
369
|
+
this.name = "FetchNotServedError";
|
|
370
|
+
}
|
|
371
|
+
url;
|
|
372
|
+
status;
|
|
373
|
+
};
|
|
374
|
+
sleep = (ms) => new Promise((resolve) => {
|
|
375
|
+
setTimeout(resolve, ms);
|
|
376
|
+
});
|
|
377
|
+
fetchWithPolicy = async (url, options) => {
|
|
378
|
+
const policy = { ...DEFAULT_FETCH_POLICY, ...options?.policy };
|
|
379
|
+
const doFetch = options?.fetchImpl ?? fetch;
|
|
380
|
+
const started = Date.now();
|
|
381
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
|
382
|
+
if (Date.now() - started > policy.maxElapsedMs) {
|
|
383
|
+
throw new FetchUnavailableError(url, new Error("wall-clock budget exceeded"));
|
|
384
|
+
}
|
|
385
|
+
const controller = new AbortController();
|
|
386
|
+
const timer = setTimeout(() => {
|
|
387
|
+
controller.abort();
|
|
388
|
+
}, policy.attemptTimeoutMs);
|
|
389
|
+
try {
|
|
390
|
+
const res = await doFetch(url, {
|
|
391
|
+
headers: options?.headers,
|
|
392
|
+
redirect: "follow",
|
|
393
|
+
signal: controller.signal
|
|
394
|
+
});
|
|
395
|
+
if (res.status >= 500) {
|
|
396
|
+
throw new FetchUnavailableError(url, new Error(`upstream ${res.status}`));
|
|
397
|
+
}
|
|
398
|
+
if (res.status >= 400) {
|
|
399
|
+
throw new FetchNotServedError(url, res.status);
|
|
400
|
+
}
|
|
401
|
+
return await res.text();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const retryable = error instanceof FetchUnavailableError || !(error instanceof FetchNotServedError);
|
|
404
|
+
if (!retryable || attempt === policy.maxAttempts) {
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
await sleep(Math.random() * policy.baseDelayMs * 2 ** (attempt - 1));
|
|
408
|
+
} finally {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
throw new FetchUnavailableError(url, new Error("unreachable"));
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// src/bundle.ts
|
|
418
|
+
import { createHash } from "crypto";
|
|
419
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
420
|
+
import path3 from "path";
|
|
421
|
+
var ALLOWED_HOSTS, PIN_EXEMPT_HOSTS, FONT_UA, MIME, sha256, dataUri, assertAllowedRemote, cdnPlugin, importMapPlugin, inlineFontCss, localDataUri, LEAK_PATTERN, bundle;
|
|
422
|
+
var init_bundle = __esm({
|
|
423
|
+
"src/bundle.ts"() {
|
|
424
|
+
"use strict";
|
|
425
|
+
init_fetch_policy();
|
|
426
|
+
ALLOWED_HOSTS = /* @__PURE__ */ new Set([
|
|
427
|
+
"cdn.jsdelivr.net",
|
|
428
|
+
"esm.sh",
|
|
429
|
+
"fonts.googleapis.com",
|
|
430
|
+
"fonts.gstatic.com",
|
|
431
|
+
"unpkg.com"
|
|
432
|
+
]);
|
|
433
|
+
PIN_EXEMPT_HOSTS = /* @__PURE__ */ new Set([
|
|
434
|
+
"fonts.googleapis.com",
|
|
435
|
+
"fonts.gstatic.com",
|
|
436
|
+
"fonts.googleapis.test",
|
|
437
|
+
"fonts.gstatic.test"
|
|
438
|
+
]);
|
|
439
|
+
FONT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
|
|
440
|
+
MIME = {
|
|
441
|
+
gif: "image/gif",
|
|
442
|
+
jpeg: "image/jpeg",
|
|
443
|
+
jpg: "image/jpeg",
|
|
444
|
+
mp3: "audio/mpeg",
|
|
445
|
+
ogg: "audio/ogg",
|
|
446
|
+
png: "image/png",
|
|
447
|
+
svg: "image/svg+xml",
|
|
448
|
+
wav: "audio/wav",
|
|
449
|
+
webp: "image/webp",
|
|
450
|
+
woff2: "font/woff2"
|
|
451
|
+
};
|
|
452
|
+
sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
453
|
+
dataUri = (mime, body) => {
|
|
454
|
+
const base64 = Buffer.from(body, "binary").toString("base64");
|
|
455
|
+
return `data:${mime};base64,${base64}`;
|
|
456
|
+
};
|
|
457
|
+
assertAllowedRemote = (url, extraHosts) => {
|
|
458
|
+
const host = new URL(url).host;
|
|
459
|
+
if (!ALLOWED_HOSTS.has(host) && !(extraHosts ?? []).includes(host)) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`bundle/blocked-origin: ${host} is not on the allowlist (${[...ALLOWED_HOSTS].join(", ")}).`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (!PIN_EXEMPT_HOSTS.has(host) && !/@v?\d+(\.\d+)+[^/]*\//u.test(url) && !/@\d+\.\d+\.\d+[^/]*$/u.test(url)) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`bundle/unpinned: ${url} \u2014 exact versions only (@latest and bare tags are forbidden; the pin is hashed).`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
cdnPlugin = (options, assets) => ({
|
|
471
|
+
name: "frogoe-cdn",
|
|
472
|
+
setup(build) {
|
|
473
|
+
build.onResolve({ filter: /^https?:\/\// }, (args) => ({
|
|
474
|
+
namespace: "frogoe-cdn",
|
|
475
|
+
path: args.path
|
|
476
|
+
}));
|
|
477
|
+
build.onResolve({ filter: /.*/, namespace: "frogoe-cdn" }, (args) => ({
|
|
478
|
+
namespace: "frogoe-cdn",
|
|
479
|
+
path: new URL(args.path, args.importer).href
|
|
480
|
+
}));
|
|
481
|
+
build.onLoad({ filter: /.*/, namespace: "frogoe-cdn" }, async (args) => {
|
|
482
|
+
assertAllowedRemote(args.path, options.extraAllowedHosts);
|
|
483
|
+
const body = await fetchWithPolicy(args.path, {
|
|
484
|
+
fetchImpl: options.fetchImpl
|
|
485
|
+
});
|
|
486
|
+
assets.push({
|
|
487
|
+
bytes: body.length,
|
|
488
|
+
kind: "js",
|
|
489
|
+
sha256: sha256(body),
|
|
490
|
+
source: args.path
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
contents: body,
|
|
494
|
+
loader: args.path.endsWith(".css") ? "css" : "js"
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
importMapPlugin = (dir, imports) => ({
|
|
500
|
+
name: "frogoe-importmap",
|
|
501
|
+
setup(build) {
|
|
502
|
+
const rules = new Map(Object.entries(imports));
|
|
503
|
+
if (!rules.has("frogoe")) {
|
|
504
|
+
rules.set("frogoe", "./.frogoe/contract.js");
|
|
505
|
+
}
|
|
506
|
+
for (const [name, target] of rules) {
|
|
507
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
508
|
+
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => {
|
|
509
|
+
if (/^https?:\/\//.test(target)) {
|
|
510
|
+
return { namespace: "frogoe-cdn", path: target };
|
|
511
|
+
}
|
|
512
|
+
return { path: path3.resolve(dir, target) };
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
inlineFontCss = async (cssUrl, options, assets) => {
|
|
518
|
+
const css = await fetchWithPolicy(cssUrl, {
|
|
519
|
+
fetchImpl: options.fetchImpl,
|
|
520
|
+
headers: { "user-agent": FONT_UA }
|
|
521
|
+
});
|
|
522
|
+
assets.push({
|
|
523
|
+
bytes: css.length,
|
|
524
|
+
kind: "css",
|
|
525
|
+
sha256: sha256(css),
|
|
526
|
+
source: cssUrl
|
|
527
|
+
});
|
|
528
|
+
const seen = /* @__PURE__ */ new Map();
|
|
529
|
+
const resolveFont = async (url) => {
|
|
530
|
+
const cached = seen.get(url);
|
|
531
|
+
if (cached) {
|
|
532
|
+
return cached;
|
|
533
|
+
}
|
|
534
|
+
assertAllowedRemote(url, options.extraAllowedHosts);
|
|
535
|
+
const res = options.fetchImpl ? await options.fetchImpl(url, { headers: { "user-agent": FONT_UA } }) : await fetch(url, { headers: { "user-agent": FONT_UA } });
|
|
536
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
537
|
+
const uri = `data:font/woff2;base64,${buffer.toString("base64")}`;
|
|
538
|
+
seen.set(url, uri);
|
|
539
|
+
assets.push({
|
|
540
|
+
bytes: buffer.length,
|
|
541
|
+
kind: "font",
|
|
542
|
+
sha256: sha256(buffer.toString("binary")),
|
|
543
|
+
source: url
|
|
544
|
+
});
|
|
545
|
+
return uri;
|
|
546
|
+
};
|
|
547
|
+
let out = css;
|
|
548
|
+
const urls = [...css.matchAll(/url\((https:\/\/[^)]+)\)/gu)].map((m) => m[1] ?? "");
|
|
549
|
+
for (const url of urls) {
|
|
550
|
+
if (url) {
|
|
551
|
+
out = out.replaceAll(url, await resolveFont(url));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
};
|
|
556
|
+
localDataUri = (dir, href) => {
|
|
557
|
+
const file = path3.resolve(dir, href.replace(/^\.\//u, ""));
|
|
558
|
+
if (!existsSync3(file)) {
|
|
559
|
+
throw new Error(`bundle/missing-asset: ${href} not found in the game folder`);
|
|
560
|
+
}
|
|
561
|
+
const ext = path3.extname(file).slice(1).toLowerCase();
|
|
562
|
+
const mime = MIME[ext];
|
|
563
|
+
if (!mime) {
|
|
564
|
+
throw new Error(`bundle/unsupported-asset: .${ext} (${href})`);
|
|
565
|
+
}
|
|
566
|
+
return dataUri(mime, readFileSync3(file, "binary"));
|
|
567
|
+
};
|
|
568
|
+
LEAK_PATTERN = /(?:src|href)\s*=\s*"(https?:\/\/[^"]+)"/gi;
|
|
569
|
+
bundle = async (options) => {
|
|
570
|
+
const dir = path3.resolve(options.dir);
|
|
571
|
+
const indexPath = path3.join(dir, "index.html");
|
|
572
|
+
if (!existsSync3(indexPath)) {
|
|
573
|
+
throw new Error("frogoe bundle: no index.html \u2014 run from a game folder");
|
|
574
|
+
}
|
|
575
|
+
const html = readFileSync3(indexPath, "utf-8");
|
|
576
|
+
const assets = [];
|
|
577
|
+
const warnings = [];
|
|
578
|
+
let out = html;
|
|
579
|
+
const importMapMatch = /<script[^>]*type="importmap"[^>]*>([\s\S]*?)<\/script>/iu.exec(html);
|
|
580
|
+
const entryMatch = /<script[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/iu.exec(html);
|
|
581
|
+
let imports = {};
|
|
582
|
+
if (importMapMatch?.[1]) {
|
|
583
|
+
try {
|
|
584
|
+
imports = JSON.parse(importMapMatch[1]).imports ?? {};
|
|
585
|
+
} catch {
|
|
586
|
+
throw new Error("frogoe bundle: import map in index.html is not valid JSON");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
let bundled = "";
|
|
590
|
+
if (entryMatch?.[1]) {
|
|
591
|
+
const { build } = await import("esbuild");
|
|
592
|
+
const result = await build({
|
|
593
|
+
bundle: true,
|
|
594
|
+
entryPoints: [path3.resolve(dir, entryMatch[1])],
|
|
595
|
+
format: "esm",
|
|
596
|
+
logLevel: "silent",
|
|
597
|
+
plugins: [importMapPlugin(dir, imports), cdnPlugin(options, assets)],
|
|
598
|
+
target: "es2022",
|
|
599
|
+
write: false
|
|
600
|
+
});
|
|
601
|
+
bundled = result.outputFiles[0]?.text ?? "";
|
|
602
|
+
out = out.replace(entryMatch[0], `<script type="module">
|
|
603
|
+
${bundled}
|
|
604
|
+
</script>`);
|
|
605
|
+
}
|
|
606
|
+
if (importMapMatch) {
|
|
607
|
+
out = out.replace(importMapMatch[0], "");
|
|
608
|
+
}
|
|
609
|
+
for (const match of html.matchAll(/<link[^>]*href="([^"]+)"[^>]*>/giu)) {
|
|
610
|
+
const href = match[1] ?? "";
|
|
611
|
+
if (!/rel\s*=\s*"stylesheet"/iu.test(match[0])) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
let replacement;
|
|
615
|
+
if (href.startsWith("https://")) {
|
|
616
|
+
assertAllowedRemote(href, options.extraAllowedHosts);
|
|
617
|
+
const css = await inlineFontCss(href, options, assets);
|
|
618
|
+
replacement = `<style>
|
|
619
|
+
${css}
|
|
620
|
+
</style>`;
|
|
621
|
+
} else {
|
|
622
|
+
const css = readFileSync3(path3.resolve(dir, href), "utf-8");
|
|
623
|
+
replacement = `<style>
|
|
624
|
+
${css}
|
|
625
|
+
</style>`;
|
|
626
|
+
}
|
|
627
|
+
out = out.replace(match[0], replacement);
|
|
628
|
+
}
|
|
629
|
+
for (const match of out.matchAll(/(src|href)\s*=\s*"(\.{0,2}\/[^":]+)"/gu)) {
|
|
630
|
+
const href = match[2] ?? "";
|
|
631
|
+
if (href.startsWith("data:") || href.startsWith("#")) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (href.endsWith(".js") && entryMatch?.[1] === href) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (existsSync3(path3.resolve(dir, href))) {
|
|
638
|
+
out = out.replaceAll(href, localDataUri(dir, href));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const leaked = [...out.matchAll(LEAK_PATTERN)].map((m) => m[1] ?? "");
|
|
642
|
+
if (leaked.length > 0) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
`bundle/leaked-remote: artifact still references ${[...new Set(leaked)].join(", ")} \u2014 nothing ships half-dissolved.`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
const artifactHash = sha256(out);
|
|
648
|
+
const banner = `<!-- frogoe bundle | contract 0.1.0 | assets: ${assets.length} | sha256:${artifactHash} -->
|
|
649
|
+
`;
|
|
650
|
+
const artifact = banner + out;
|
|
651
|
+
const bytes = Buffer.byteLength(artifact, "utf-8");
|
|
652
|
+
if (bytes > 3e6) {
|
|
653
|
+
warnings.push(
|
|
654
|
+
`bundle/size: ${Math.round(bytes / 1e5) / 10} MB \u2014 feed caching stays sane, but check whether every asset earns its bytes`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
artifact,
|
|
659
|
+
assets,
|
|
660
|
+
bytes,
|
|
661
|
+
sha256: sha256(artifact),
|
|
662
|
+
warnings
|
|
663
|
+
};
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// src/commands/bundle.ts
|
|
669
|
+
var bundle_exports = {};
|
|
670
|
+
__export(bundle_exports, {
|
|
671
|
+
command: () => command2
|
|
672
|
+
});
|
|
673
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
674
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
675
|
+
import path4 from "path";
|
|
676
|
+
var command2;
|
|
677
|
+
var init_bundle2 = __esm({
|
|
678
|
+
"src/commands/bundle.ts"() {
|
|
679
|
+
"use strict";
|
|
680
|
+
init_bundle();
|
|
681
|
+
command2 = defineCommand2({
|
|
682
|
+
args: {
|
|
683
|
+
dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
|
|
684
|
+
json: { type: "boolean", description: "machine-readable report" },
|
|
685
|
+
out: { type: "string", description: "output path (default: dist/index.html)" }
|
|
686
|
+
},
|
|
687
|
+
async run({ args }) {
|
|
688
|
+
const dir = args.dir ? String(args.dir) : process.cwd();
|
|
689
|
+
const report = await bundle({ dir });
|
|
690
|
+
const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(dir, "dist", "index.html");
|
|
691
|
+
mkdirSync3(path4.dirname(outPath), { recursive: true });
|
|
692
|
+
writeFileSync3(outPath, report.artifact, "utf-8");
|
|
693
|
+
for (const warning of report.warnings) {
|
|
694
|
+
console.log(` \u26A0 ${warning}`);
|
|
695
|
+
}
|
|
696
|
+
if (args.json) {
|
|
697
|
+
console.log(
|
|
698
|
+
JSON.stringify(
|
|
699
|
+
{
|
|
700
|
+
artifact: outPath,
|
|
701
|
+
assets: report.assets,
|
|
702
|
+
bytes: report.bytes,
|
|
703
|
+
sha256: report.sha256,
|
|
704
|
+
warnings: report.warnings
|
|
705
|
+
},
|
|
706
|
+
null,
|
|
707
|
+
2
|
|
708
|
+
)
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
console.log(` frogoe bundle \u2192 ${outPath}`);
|
|
712
|
+
console.log(
|
|
713
|
+
` ${report.bytes} bytes \xB7 ${report.assets.length} dissolved asset(s) \xB7 sha256 ${report.sha256.slice(0, 12)}`
|
|
714
|
+
);
|
|
715
|
+
for (const asset of report.assets) {
|
|
716
|
+
console.log(` ${asset.kind.padEnd(5)} ${asset.source}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
meta: { description: "dissolve externals into one self-contained HTML" }
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ../lint/src/brief.ts
|
|
726
|
+
var stripComment, parseBrief;
|
|
727
|
+
var init_brief = __esm({
|
|
728
|
+
"../lint/src/brief.ts"() {
|
|
729
|
+
"use strict";
|
|
730
|
+
stripComment = (value) => value.replace(/\s+#.*$/u, "").trim().replace(/^["']|["']$/gu, "");
|
|
731
|
+
parseBrief = (source) => {
|
|
732
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source);
|
|
733
|
+
if (!match) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const brief = {};
|
|
737
|
+
let section = "";
|
|
738
|
+
for (const rawLine of match[1]?.split("\n") ?? []) {
|
|
739
|
+
if (rawLine.trim() === "") {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const nested = /^\s{2,}([a-z-]+):\s*(.*)$/u.exec(rawLine);
|
|
743
|
+
const top = /^([a-z-]+):\s*(.*)$/u.exec(rawLine);
|
|
744
|
+
if (nested && section === "palette") {
|
|
745
|
+
const key = nested[1];
|
|
746
|
+
brief[key] = stripComment(nested[2] ?? "");
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (top) {
|
|
750
|
+
section = top[1] ?? "";
|
|
751
|
+
if (section === "palette") {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const key = section;
|
|
755
|
+
brief[key] = stripComment(top[2] ?? "");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return brief;
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// ../lint/src/contrast.ts
|
|
764
|
+
var HEX, isHex, contrastRatio;
|
|
765
|
+
var init_contrast = __esm({
|
|
766
|
+
"../lint/src/contrast.ts"() {
|
|
767
|
+
"use strict";
|
|
768
|
+
HEX = /^#[0-9a-fA-F]{3,8}$/u;
|
|
769
|
+
isHex = (value) => HEX.test(value);
|
|
770
|
+
contrastRatio = (a, b) => {
|
|
771
|
+
const lum = (hex) => {
|
|
772
|
+
const full = hex.length === 4 ? `#${hex.slice(1).replace(/(.)/gu, "$1$1")}` : hex;
|
|
773
|
+
const n = Number.parseInt(full.slice(1), 16);
|
|
774
|
+
const rgb = [16, 8, 0].map((shift) => (n >> shift & 255) / 255);
|
|
775
|
+
const lin = rgb.map((c) => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
776
|
+
return 0.2126 * (lin[0] ?? 0) + 0.7152 * (lin[1] ?? 0) + 0.0722 * (lin[2] ?? 0);
|
|
777
|
+
};
|
|
778
|
+
try {
|
|
779
|
+
const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
780
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
781
|
+
} catch {
|
|
782
|
+
return 21;
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// ../lint/src/check.ts
|
|
789
|
+
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
790
|
+
import path5 from "path";
|
|
791
|
+
var VERBS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
|
|
792
|
+
var init_check = __esm({
|
|
793
|
+
"../lint/src/check.ts"() {
|
|
794
|
+
"use strict";
|
|
795
|
+
init_brief();
|
|
796
|
+
init_contrast();
|
|
797
|
+
VERBS = /* @__PURE__ */ new Set(["tap", "hold", "steer", "aim"]);
|
|
798
|
+
read = (file) => {
|
|
799
|
+
try {
|
|
800
|
+
return readFileSync4(file, "utf-8");
|
|
801
|
+
} catch {
|
|
802
|
+
return "";
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
linesOf = (source) => source.split("\n");
|
|
806
|
+
findLine = (source, pattern) => {
|
|
807
|
+
const idx = linesOf(source).findIndex((line) => pattern.test(line));
|
|
808
|
+
return idx === -1 ? void 0 : idx + 1;
|
|
809
|
+
};
|
|
810
|
+
checkBrief = (dir, findings) => {
|
|
811
|
+
const file = path5.join(dir, "BRIEF.md");
|
|
812
|
+
if (!existsSync4(file)) {
|
|
813
|
+
findings.push({
|
|
814
|
+
code: "brief/missing",
|
|
815
|
+
file: "BRIEF.md",
|
|
816
|
+
fix: "frogoe init writes a stub BRIEF.md \u2014 or copy the schema from frogoe-core \u2192 brief-format",
|
|
817
|
+
message: "feed games declare intent before code",
|
|
818
|
+
severity: "error"
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const source = read(file);
|
|
823
|
+
const todoLine = findLine(source, /TODO/u);
|
|
824
|
+
if (todoLine !== void 0) {
|
|
825
|
+
findings.push({
|
|
826
|
+
code: "brief/todo",
|
|
827
|
+
file: "BRIEF.md",
|
|
828
|
+
fix: "fill every TODO \u2014 the brief is the gate's measuring stick",
|
|
829
|
+
line: todoLine,
|
|
830
|
+
message: "BRIEF.md still has TODO markers",
|
|
831
|
+
severity: "error"
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
const brief = parseBrief(source);
|
|
835
|
+
if (!brief) {
|
|
836
|
+
findings.push({
|
|
837
|
+
code: "brief/frontmatter",
|
|
838
|
+
file: "BRIEF.md",
|
|
839
|
+
fix: "start the file with a --- frontmatter block (frogoe-core \u2192 brief-format)",
|
|
840
|
+
message: "no frontmatter block found",
|
|
841
|
+
severity: "error"
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const problems = [];
|
|
846
|
+
if (!brief.title || brief.title.length < 2 || brief.title.length > 40) {
|
|
847
|
+
problems.push("title (2\u201340 chars)");
|
|
848
|
+
}
|
|
849
|
+
if (!brief.verb || !VERBS.has(brief.verb)) {
|
|
850
|
+
problems.push("verb \u2208 tap|hold|steer|aim");
|
|
851
|
+
}
|
|
852
|
+
if (!brief.mood) {
|
|
853
|
+
problems.push("mood (one phrase)");
|
|
854
|
+
}
|
|
855
|
+
for (const key of ["bg", "fg", "accent"]) {
|
|
856
|
+
if (!brief[key] || !isHex(brief[key] ?? "")) {
|
|
857
|
+
problems.push(`palette.${key} (hex)`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (problems.length > 0) {
|
|
861
|
+
findings.push({
|
|
862
|
+
code: "brief/frontmatter",
|
|
863
|
+
file: "BRIEF.md",
|
|
864
|
+
fix: `missing/invalid: ${problems.join(", ")} \u2014 frogoe-core \u2192 brief-format`,
|
|
865
|
+
message: "frontmatter is incomplete",
|
|
866
|
+
severity: "error"
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
const outline = typeof brief.outline === "string" ? brief.outline : brief.bg ?? "";
|
|
870
|
+
if (brief.fg && isHex(brief.fg) && isHex(outline) && outline !== brief.bg) {
|
|
871
|
+
const ratio = contrastRatio(brief.fg, outline);
|
|
872
|
+
if (ratio < 3) {
|
|
873
|
+
findings.push({
|
|
874
|
+
code: "brief/contrast",
|
|
875
|
+
file: "BRIEF.md",
|
|
876
|
+
fix: `fg/outline contrast is ${ratio.toFixed(2)}:1 \u2014 HUD floor is 3:1; the outline is the readability partner in games (not bg); brighten fg or darken outline`,
|
|
877
|
+
message: "declared palette fails the HUD readability floor",
|
|
878
|
+
severity: "error"
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
} else if (brief.bg && brief.fg && isHex(brief.bg) && isHex(brief.fg)) {
|
|
882
|
+
const ratio = contrastRatio(brief.fg, brief.bg);
|
|
883
|
+
if (ratio < 3) {
|
|
884
|
+
findings.push({
|
|
885
|
+
code: "brief/contrast",
|
|
886
|
+
file: "BRIEF.md",
|
|
887
|
+
fix: `declared fg/bg contrast is ${ratio.toFixed(2)}:1 \u2014 HUD floor is 3:1; games should declare palette.outline (the readability partner) in BRIEF.md`,
|
|
888
|
+
message: "declared palette fails the HUD readability floor",
|
|
889
|
+
severity: "error"
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
checkFolder = (dir, findings) => {
|
|
895
|
+
const index = read(path5.join(dir, "index.html"));
|
|
896
|
+
if (!index) {
|
|
897
|
+
findings.push({
|
|
898
|
+
code: "folder/index-missing",
|
|
899
|
+
file: "index.html",
|
|
900
|
+
fix: "the entry shell: canvas + import map + hud layer (frogoe init writes one)",
|
|
901
|
+
message: "no index.html",
|
|
902
|
+
severity: "error"
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (!/<canvas[^>]+id="c"/iu.test(index)) {
|
|
907
|
+
findings.push({
|
|
908
|
+
code: "folder/canvas",
|
|
909
|
+
file: "index.html",
|
|
910
|
+
fix: 'add <canvas id="c"></canvas> \u2014 the contract boots on that exact element',
|
|
911
|
+
message: 'missing <canvas id="c">',
|
|
912
|
+
severity: "error"
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
if (!/viewport-fit=cover/u.test(index)) {
|
|
916
|
+
findings.push({
|
|
917
|
+
code: "folder/viewport-fit",
|
|
918
|
+
file: "index.html",
|
|
919
|
+
fix: "viewport meta must include viewport-fit=cover or safe-area insets read as zero",
|
|
920
|
+
message: "viewport-fit=cover missing",
|
|
921
|
+
severity: "error"
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
if (!/(?:-webkit-)?user-select:\s*none/u.test(index)) {
|
|
925
|
+
findings.push({
|
|
926
|
+
code: "folder/touch-select",
|
|
927
|
+
file: "index.html",
|
|
928
|
+
fix: "phones are the primary device \u2014 add `-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;` on html/body or a long-press summons the text-selection UI mid-game on iOS and Android alike (frogoe-core \u2192 references/audio.md)",
|
|
929
|
+
message: "long-press can summon the phone selection UI",
|
|
930
|
+
severity: "error"
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
const mapMatch = /<script[^>]*type="importmap"[^>]*>([\s\S]*?)<\/script>/u.exec(index);
|
|
934
|
+
const mapping = mapMatch?.[1];
|
|
935
|
+
if (!mapping) {
|
|
936
|
+
findings.push({
|
|
937
|
+
code: "folder/importmap",
|
|
938
|
+
file: "index.html",
|
|
939
|
+
fix: 'add <script type="importmap"> with {"imports":{"frogoe":"./.frogoe/contract.js"}}',
|
|
940
|
+
message: "no import map",
|
|
941
|
+
severity: "error"
|
|
942
|
+
});
|
|
943
|
+
} else {
|
|
944
|
+
try {
|
|
945
|
+
const parsed = JSON.parse(mapping);
|
|
946
|
+
if (parsed.imports?.frogoe !== "./.frogoe/contract.js") {
|
|
947
|
+
findings.push({
|
|
948
|
+
code: "folder/importmap",
|
|
949
|
+
file: "index.html",
|
|
950
|
+
fix: 'imports.frogoe must be exactly "./.frogoe/contract.js" \u2014 the pinned contract',
|
|
951
|
+
message: `frogoe maps to ${parsed.imports?.frogoe ?? "(absent)"}`,
|
|
952
|
+
severity: "error"
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
} catch {
|
|
956
|
+
findings.push({
|
|
957
|
+
code: "folder/importmap",
|
|
958
|
+
file: "index.html",
|
|
959
|
+
fix: "import map is not valid JSON",
|
|
960
|
+
message: "import map fails to parse",
|
|
961
|
+
severity: "error"
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const game = read(path5.join(dir, "game.js"));
|
|
966
|
+
if (!game) {
|
|
967
|
+
findings.push({
|
|
968
|
+
code: "folder/game-missing",
|
|
969
|
+
file: "game.js",
|
|
970
|
+
fix: "the whole simulation: defineGame(({stage,input,loop,finish}) => {...})",
|
|
971
|
+
message: "no game.js",
|
|
972
|
+
severity: "error"
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (!/loop\.update\s*=/u.test(game)) {
|
|
977
|
+
findings.push({
|
|
978
|
+
code: "game/loop-update",
|
|
979
|
+
file: "game.js",
|
|
980
|
+
fix: "fill loop.update = (dt) => {...} inside the closure",
|
|
981
|
+
message: "loop.update is never assigned",
|
|
982
|
+
severity: "warning"
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
if (!/loop\.render\s*=/u.test(game)) {
|
|
986
|
+
findings.push({
|
|
987
|
+
code: "game/loop-render",
|
|
988
|
+
file: "game.js",
|
|
989
|
+
fix: "fill loop.render = (ctx) => {...} inside the closure",
|
|
990
|
+
message: "loop.render is never assigned",
|
|
991
|
+
severity: "warning"
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const gameLine = (pattern) => findLine(game, pattern);
|
|
995
|
+
const dragLine = gameLine(/\+=\s*(?:p|pt|pointer)\.(?:dx|dy)\b/u);
|
|
996
|
+
if (dragLine !== void 0) {
|
|
997
|
+
findings.push({
|
|
998
|
+
code: "input/incremental-drag",
|
|
999
|
+
file: "game.js",
|
|
1000
|
+
fix: "pointer.dx is anchor-relative \u2014 assign absolutely (x = grabX + p.dx) or track your own lastX",
|
|
1001
|
+
line: dragLine,
|
|
1002
|
+
message: "re-adding anchor-relative dx per event rockets the actor into a wall",
|
|
1003
|
+
recipe: "frogoe-core \u2192 contract (drag semantics)",
|
|
1004
|
+
severity: "error"
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
const absLine = gameLine(/[=]\s*(?:pointer|p|touch)\.x\b(?!.*(?:dx|anchor|base|last))/u);
|
|
1008
|
+
if (absLine !== void 0) {
|
|
1009
|
+
findings.push({
|
|
1010
|
+
code: "input/absolute-steering",
|
|
1011
|
+
file: "game.js",
|
|
1012
|
+
fix: "steer from relative deltas (p.dx) or your own anchor \u2014 absolute x thumb-ghosts",
|
|
1013
|
+
line: absLine,
|
|
1014
|
+
message: "steering appears to follow absolute pointer x",
|
|
1015
|
+
recipe: "frogoe-core \u2192 contract (drag semantics)",
|
|
1016
|
+
severity: "warning"
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const spawnLine = gameLine(/Math\.random\(\)\s*\*\s*(?:W\b|innerWidth|window\.innerWidth)/u);
|
|
1020
|
+
if (spawnLine !== void 0) {
|
|
1021
|
+
findings.push({
|
|
1022
|
+
code: "layout/innerwidth-spawn",
|
|
1023
|
+
file: "game.js",
|
|
1024
|
+
fix: "spawn inside stage.play (capped centered column) for identical challenge on every screen",
|
|
1025
|
+
line: spawnLine,
|
|
1026
|
+
message: "spawn uses raw innerWidth",
|
|
1027
|
+
recipe: "frogoe-core \u2192 contract",
|
|
1028
|
+
severity: "warning"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
const suspendedLine = gameLine(/\.state\s*(?:===|==)\s*["']suspended["']/u);
|
|
1032
|
+
if (suspendedLine !== void 0 && /AudioContext/u.test(game)) {
|
|
1033
|
+
findings.push({
|
|
1034
|
+
code: "audio/suspended-only",
|
|
1035
|
+
file: "game.js",
|
|
1036
|
+
fix: 'resume when state !== "running" \u2014 iOS reports a non-standard "interrupted" state (lock, call, tab switch) that === "suspended" silently misses (frogoe-core \u2192 references/audio.md)',
|
|
1037
|
+
line: suspendedLine,
|
|
1038
|
+
message: "audio resume gates on suspended only \u2014 interrupted contexts stay silent",
|
|
1039
|
+
recipe: "frogoe-core \u2192 references/audio.md",
|
|
1040
|
+
severity: "warning"
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const blocksDir = path5.join(dir, "blocks");
|
|
1044
|
+
let markup = index;
|
|
1045
|
+
if (existsSync4(blocksDir)) {
|
|
1046
|
+
for (const f of readdirSync(blocksDir)) {
|
|
1047
|
+
if (f.endsWith(".html")) {
|
|
1048
|
+
markup += read(path5.join(blocksDir, f));
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const wanted = new Set(
|
|
1053
|
+
[...game.matchAll(/\[(data-block-[a-z0-9-]+)\]/gu)].map((m) => m[1] ?? "")
|
|
1054
|
+
);
|
|
1055
|
+
for (const binding of wanted) {
|
|
1056
|
+
if (binding && !markup.includes(binding)) {
|
|
1057
|
+
findings.push({
|
|
1058
|
+
code: "blocks/binding-orphan",
|
|
1059
|
+
file: "game.js",
|
|
1060
|
+
fix: `no element carries ${binding} \u2014 paste the block markup (frogoe add) or drop the selector`,
|
|
1061
|
+
message: `selector ${binding} targets nothing`,
|
|
1062
|
+
severity: "warning"
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
checkPin = (dir, findings) => {
|
|
1068
|
+
const pinFile = path5.join(dir, "frogoe.json");
|
|
1069
|
+
if (!existsSync4(pinFile)) {
|
|
1070
|
+
findings.push({
|
|
1071
|
+
code: "folder/contract-pin",
|
|
1072
|
+
file: "frogoe.json",
|
|
1073
|
+
fix: '{"contract": "0.1.0"} \u2014 the single version source of truth',
|
|
1074
|
+
message: "no frogoe.json pin",
|
|
1075
|
+
severity: "error"
|
|
1076
|
+
});
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
let pin = "";
|
|
1080
|
+
try {
|
|
1081
|
+
pin = String(JSON.parse(read(pinFile)).contract ?? "");
|
|
1082
|
+
} catch {
|
|
1083
|
+
}
|
|
1084
|
+
const contract = read(path5.join(dir, ".frogoe/contract.js"));
|
|
1085
|
+
const marker = /frogoe contract v([\d.]+)/u.exec(contract.slice(0, 400));
|
|
1086
|
+
if (!contract) {
|
|
1087
|
+
findings.push({
|
|
1088
|
+
code: "folder/contract-pin",
|
|
1089
|
+
file: ".frogoe/contract.js",
|
|
1090
|
+
fix: "run frogoe init --force (rematerializes .frogoe/ from the pin)",
|
|
1091
|
+
message: "contract not materialized",
|
|
1092
|
+
severity: "error"
|
|
1093
|
+
});
|
|
1094
|
+
} else if (!marker || marker[1] !== pin) {
|
|
1095
|
+
findings.push({
|
|
1096
|
+
code: "folder/contract-pin",
|
|
1097
|
+
file: ".frogoe/contract.js",
|
|
1098
|
+
fix: `frogoe.json pins ${pin || "(none)"} but .frogoe carries ${marker?.[1] ?? "no marker"} \u2014 run frogoe init --force`,
|
|
1099
|
+
message: "contract version drift",
|
|
1100
|
+
severity: "error"
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
checkProject = (dir) => {
|
|
1105
|
+
const findings = [];
|
|
1106
|
+
checkBrief(dir, findings);
|
|
1107
|
+
checkFolder(dir, findings);
|
|
1108
|
+
checkPin(dir, findings);
|
|
1109
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
1110
|
+
return {
|
|
1111
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
1112
|
+
findings,
|
|
1113
|
+
warnings: findings.filter((f) => f.severity === "warning").length
|
|
1114
|
+
};
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// ../lint/src/index.ts
|
|
1120
|
+
var init_src = __esm({
|
|
1121
|
+
"../lint/src/index.ts"() {
|
|
1122
|
+
"use strict";
|
|
1123
|
+
init_brief();
|
|
1124
|
+
init_contrast();
|
|
1125
|
+
init_check();
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// src/check.ts
|
|
1130
|
+
var formatFindings;
|
|
1131
|
+
var init_check2 = __esm({
|
|
1132
|
+
"src/check.ts"() {
|
|
1133
|
+
"use strict";
|
|
1134
|
+
init_src();
|
|
1135
|
+
init_src();
|
|
1136
|
+
init_src();
|
|
1137
|
+
formatFindings = (result) => result.findings.map((f) => {
|
|
1138
|
+
const at = f.line !== void 0 ? `:${f.line}` : "";
|
|
1139
|
+
const head = `${f.severity === "error" ? "\u2716" : "\u26A0"} ${f.code} ${f.file}${at}`;
|
|
1140
|
+
return `${head}
|
|
1141
|
+
${f.message}
|
|
1142
|
+
fix: ${f.fix}`;
|
|
1143
|
+
}).join("\n") || "clean \u2014 0 findings";
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// src/live/sampler.ts
|
|
1148
|
+
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;
|
|
1149
|
+
var init_sampler = __esm({
|
|
1150
|
+
"src/live/sampler.ts"() {
|
|
1151
|
+
"use strict";
|
|
1152
|
+
PROBE_SCRIPT = `(() => {
|
|
1153
|
+
if (window.__frogoeProbe) return;
|
|
1154
|
+
const probe = { audioContexts: [], audioEverRan: false, finish: [], fps: [] };
|
|
1155
|
+
Object.defineProperty(window, "__frogoeProbe", { value: probe });
|
|
1156
|
+
document.addEventListener("frogoe:finish", (e) => {
|
|
1157
|
+
const d = e && e.detail;
|
|
1158
|
+
const score = d && typeof d.score === "number" ? d.score : null;
|
|
1159
|
+
probe.finish.push({ at: Math.round(performance.now()), score });
|
|
1160
|
+
});
|
|
1161
|
+
const wrap = (Native) => class extends Native {
|
|
1162
|
+
constructor(...args) {
|
|
1163
|
+
super(...args);
|
|
1164
|
+
probe.audioContexts.push(this);
|
|
1165
|
+
this.addEventListener("statechange", () => {
|
|
1166
|
+
if (this.state === "running") probe.audioEverRan = true;
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
const AC = window.AudioContext;
|
|
1171
|
+
if (AC && !AC.__frogoeHooked) {
|
|
1172
|
+
Object.defineProperty(wrap(AC), "__frogoeHooked", { value: true });
|
|
1173
|
+
window.AudioContext = wrap(AC);
|
|
1174
|
+
}
|
|
1175
|
+
if (window.webkitAudioContext) {
|
|
1176
|
+
window.webkitAudioContext = wrap(window.webkitAudioContext);
|
|
1177
|
+
}
|
|
1178
|
+
let count = 0;
|
|
1179
|
+
let secStart = performance.now();
|
|
1180
|
+
const tick = () => {
|
|
1181
|
+
count++;
|
|
1182
|
+
const now = performance.now();
|
|
1183
|
+
if (now - secStart >= 1000) {
|
|
1184
|
+
probe.fps.push(count);
|
|
1185
|
+
count = 0;
|
|
1186
|
+
secStart = now;
|
|
1187
|
+
}
|
|
1188
|
+
requestAnimationFrame(tick);
|
|
1189
|
+
};
|
|
1190
|
+
requestAnimationFrame(tick);
|
|
1191
|
+
})()`;
|
|
1192
|
+
DOM_PROBE_SCRIPT = `(() => {
|
|
1193
|
+
return {
|
|
1194
|
+
canvasPresent: Boolean(document.querySelector("#c")),
|
|
1195
|
+
hudPresent: Boolean(document.querySelector(".hud")),
|
|
1196
|
+
state: window.__frogoe?.state ?? "(missing)",
|
|
1197
|
+
};
|
|
1198
|
+
})()`;
|
|
1199
|
+
CANVAS_PAINTED_SCRIPT = `(() => {
|
|
1200
|
+
const c = document.querySelector("#c");
|
|
1201
|
+
if (!c) return false;
|
|
1202
|
+
const g = c.getContext("2d");
|
|
1203
|
+
if (!g) return false;
|
|
1204
|
+
const px = Math.floor(c.width * 0.25), py = Math.floor(c.height * 0.25);
|
|
1205
|
+
const pw = Math.floor(c.width * 0.5), ph = Math.floor(c.height * 0.5);
|
|
1206
|
+
const s = g.getImageData(px, py, pw, ph).data;
|
|
1207
|
+
for (let i = 3; i < s.length; i += 4) { if (s[i] !== 0) return true; }
|
|
1208
|
+
return false;
|
|
1209
|
+
})()`;
|
|
1210
|
+
CANVAS_HASH_SCRIPT = `(() => {
|
|
1211
|
+
const c = document.querySelector("#c");
|
|
1212
|
+
if (!c) return null;
|
|
1213
|
+
const g = c.getContext("2d");
|
|
1214
|
+
if (!g) return null;
|
|
1215
|
+
const cx = Math.floor(c.width * 0.25), cy = Math.floor(c.height * 0.25);
|
|
1216
|
+
const cw = Math.floor(c.width * 0.5), ch = Math.floor(c.height * 0.5);
|
|
1217
|
+
const d = g.getImageData(cx, cy, cw, ch).data;
|
|
1218
|
+
let hash = 0;
|
|
1219
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
1220
|
+
hash = (hash * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
|
|
1221
|
+
}
|
|
1222
|
+
return hash;
|
|
1223
|
+
})()`;
|
|
1224
|
+
GAME_STATE_SCRIPT = `window.__frogoe?.state ?? "(missing)"`;
|
|
1225
|
+
FINISH_EVENTS_SCRIPT = `(() => {
|
|
1226
|
+
const p = window.__frogoeProbe;
|
|
1227
|
+
return p ? p.finish.slice() : [];
|
|
1228
|
+
})()`;
|
|
1229
|
+
FPS_MARK_SCRIPT = `window.__frogoeProbe ? window.__frogoeProbe.fps.length : 0`;
|
|
1230
|
+
FPS_SINCE_SCRIPT = `(() => {
|
|
1231
|
+
const p = window.__frogoeProbe;
|
|
1232
|
+
const mark = __MARK__;
|
|
1233
|
+
return p ? p.fps.slice(mark) : [];
|
|
1234
|
+
})()`;
|
|
1235
|
+
fpsSinceScript = (mark) => FPS_SINCE_SCRIPT.replaceAll("__MARK__", String(mark));
|
|
1236
|
+
HUD_MEASURE_SCRIPT = `(() => {
|
|
1237
|
+
const out = [];
|
|
1238
|
+
for (const el of document.querySelectorAll(".hud *")) {
|
|
1239
|
+
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
|
|
1240
|
+
if (!own) continue;
|
|
1241
|
+
const text = el.textContent ?? "";
|
|
1242
|
+
const s = getComputedStyle(el);
|
|
1243
|
+
const hasOutline =
|
|
1244
|
+
(s.webkitTextStroke && s.webkitTextStrokeWidth !== "0px") ||
|
|
1245
|
+
(s.textShadow && s.textShadow !== "none");
|
|
1246
|
+
const r = el.getBoundingClientRect();
|
|
1247
|
+
out.push({ hasOutline, height: r.height, label: text.trim().slice(0, 24), width: r.width });
|
|
1248
|
+
}
|
|
1249
|
+
return out;
|
|
1250
|
+
})()`;
|
|
1251
|
+
RETRY_PRESENCE_SCRIPT = `(() => ({
|
|
1252
|
+
gameover: Boolean(document.querySelector("[data-block-gameover]")),
|
|
1253
|
+
retry: Boolean(document.querySelector("[data-block-retry]")),
|
|
1254
|
+
}))()`;
|
|
1255
|
+
INTERRUPT_AUDIO_SCRIPT = `(() => {
|
|
1256
|
+
const p = window.__frogoeProbe;
|
|
1257
|
+
if (!p) return false;
|
|
1258
|
+
for (const c of p.audioContexts) {
|
|
1259
|
+
try { void c.suspend(); } catch (e) {}
|
|
1260
|
+
}
|
|
1261
|
+
return true;
|
|
1262
|
+
})()`;
|
|
1263
|
+
AUDIO_STATES_SCRIPT = `(() => {
|
|
1264
|
+
const p = window.__frogoeProbe;
|
|
1265
|
+
if (!p) return { count: 0, everRan: false, running: 0 };
|
|
1266
|
+
let running = 0;
|
|
1267
|
+
for (const c of p.audioContexts) {
|
|
1268
|
+
if (c.state === "running") running++;
|
|
1269
|
+
}
|
|
1270
|
+
return { count: p.audioContexts.length, everRan: p.audioEverRan === true, running };
|
|
1271
|
+
})()`;
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// src/live/driver.ts
|
|
1276
|
+
var createPuppeteerDriver;
|
|
1277
|
+
var init_driver = __esm({
|
|
1278
|
+
"src/live/driver.ts"() {
|
|
1279
|
+
"use strict";
|
|
1280
|
+
init_sampler();
|
|
1281
|
+
createPuppeteerDriver = ({ page, size }) => {
|
|
1282
|
+
const pageErrors = [];
|
|
1283
|
+
const consoleErrors = [];
|
|
1284
|
+
page.on("pageerror", (error) => {
|
|
1285
|
+
pageErrors.push(String(error));
|
|
1286
|
+
});
|
|
1287
|
+
page.on("console", (message2) => {
|
|
1288
|
+
if (message2.type() === "error") {
|
|
1289
|
+
consoleErrors.push(message2.text());
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
page.evaluateOnNewDocument(PROBE_SCRIPT);
|
|
1293
|
+
const read2 = async (script) => await page.evaluate(script);
|
|
1294
|
+
return {
|
|
1295
|
+
errors: () => pageErrors,
|
|
1296
|
+
consoleErrors: () => consoleErrors,
|
|
1297
|
+
async audioStates() {
|
|
1298
|
+
return await read2(AUDIO_STATES_SCRIPT);
|
|
1299
|
+
},
|
|
1300
|
+
async interruptAudio() {
|
|
1301
|
+
await read2(INTERRUPT_AUDIO_SCRIPT);
|
|
1302
|
+
},
|
|
1303
|
+
async domProbe() {
|
|
1304
|
+
return await read2(DOM_PROBE_SCRIPT);
|
|
1305
|
+
},
|
|
1306
|
+
async canvasPainted() {
|
|
1307
|
+
return await read2(CANVAS_PAINTED_SCRIPT);
|
|
1308
|
+
},
|
|
1309
|
+
async canvasHash() {
|
|
1310
|
+
return await read2(CANVAS_HASH_SCRIPT);
|
|
1311
|
+
},
|
|
1312
|
+
async gameState() {
|
|
1313
|
+
return await read2(GAME_STATE_SCRIPT);
|
|
1314
|
+
},
|
|
1315
|
+
async finishEvents() {
|
|
1316
|
+
return await read2(FINISH_EVENTS_SCRIPT);
|
|
1317
|
+
},
|
|
1318
|
+
async fpsMark() {
|
|
1319
|
+
return await read2(FPS_MARK_SCRIPT);
|
|
1320
|
+
},
|
|
1321
|
+
async fpsSince(mark) {
|
|
1322
|
+
return await read2(fpsSinceScript(mark));
|
|
1323
|
+
},
|
|
1324
|
+
async hudMeasures() {
|
|
1325
|
+
return await read2(HUD_MEASURE_SCRIPT);
|
|
1326
|
+
},
|
|
1327
|
+
async retryPresence() {
|
|
1328
|
+
return await read2(RETRY_PRESENCE_SCRIPT);
|
|
1329
|
+
},
|
|
1330
|
+
async tap(x, y) {
|
|
1331
|
+
await page.mouse.click(x, y);
|
|
1332
|
+
},
|
|
1333
|
+
async hold(x, y, ms) {
|
|
1334
|
+
await page.mouse.move(x, y);
|
|
1335
|
+
await page.mouse.down();
|
|
1336
|
+
await new Promise((resolve) => {
|
|
1337
|
+
setTimeout(resolve, ms);
|
|
1338
|
+
});
|
|
1339
|
+
await page.mouse.up();
|
|
1340
|
+
},
|
|
1341
|
+
async drag(x1, y1, x2, y2) {
|
|
1342
|
+
await page.mouse.move(x1, y1);
|
|
1343
|
+
await page.mouse.down();
|
|
1344
|
+
await page.mouse.move(x2, y2, { steps: 6 });
|
|
1345
|
+
await new Promise((resolve) => {
|
|
1346
|
+
setTimeout(resolve, 80);
|
|
1347
|
+
});
|
|
1348
|
+
await page.mouse.up();
|
|
1349
|
+
},
|
|
1350
|
+
async clickRetryAwaitReload(timeoutMs) {
|
|
1351
|
+
const interactable = `(() => {
|
|
1352
|
+
const b = document.querySelector("[data-block-retry]");
|
|
1353
|
+
if (!b) return false;
|
|
1354
|
+
const s = getComputedStyle(b);
|
|
1355
|
+
if (s.pointerEvents === "none" || s.visibility === "hidden" || s.display === "none") {
|
|
1356
|
+
return false;
|
|
1357
|
+
}
|
|
1358
|
+
const r = b.getBoundingClientRect();
|
|
1359
|
+
if (r.width < 4 || r.height < 4) return false;
|
|
1360
|
+
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
1361
|
+
return Boolean(hit && (hit === b || b.contains(hit)));
|
|
1362
|
+
})()`;
|
|
1363
|
+
const grace = 3e3;
|
|
1364
|
+
const started = Date.now();
|
|
1365
|
+
for (; ; ) {
|
|
1366
|
+
if (await page.evaluate(interactable) === true) {
|
|
1367
|
+
break;
|
|
1368
|
+
}
|
|
1369
|
+
if (Date.now() - started > grace) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
await new Promise((resolve) => {
|
|
1373
|
+
setTimeout(resolve, 100);
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
const navigated = page.waitForNavigation({ timeout: timeoutMs, waitUntil: "domcontentloaded" }).then(() => true).catch(() => false);
|
|
1377
|
+
const button = await page.$("[data-block-retry]");
|
|
1378
|
+
if (!button) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
await button.click();
|
|
1382
|
+
return await navigated;
|
|
1383
|
+
},
|
|
1384
|
+
async setCpuThrottling(rate) {
|
|
1385
|
+
await page.emulateCPUThrottling(rate === 1 ? null : rate);
|
|
1386
|
+
},
|
|
1387
|
+
async screenshot() {
|
|
1388
|
+
return await page.screenshot({ encoding: "binary" });
|
|
1389
|
+
},
|
|
1390
|
+
viewport: () => size
|
|
1391
|
+
};
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// src/live/types.ts
|
|
1397
|
+
var finding;
|
|
1398
|
+
var init_types = __esm({
|
|
1399
|
+
"src/live/types.ts"() {
|
|
1400
|
+
"use strict";
|
|
1401
|
+
finding = (shape) => shape;
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// src/live/decisions.ts
|
|
1406
|
+
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;
|
|
1407
|
+
var init_decisions = __esm({
|
|
1408
|
+
"src/live/decisions.ts"() {
|
|
1409
|
+
"use strict";
|
|
1410
|
+
init_types();
|
|
1411
|
+
FPS_FLOOR = 30;
|
|
1412
|
+
FPS_SUSTAINED_WINDOW = 3;
|
|
1413
|
+
FROZEN_STREAK = 3;
|
|
1414
|
+
outlineFinding = (measures) => {
|
|
1415
|
+
const bare = measures.filter((m) => !m.hasOutline);
|
|
1416
|
+
if (bare.length === 0 || !bare[0]) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
return finding({
|
|
1420
|
+
code: "live/hud-outline",
|
|
1421
|
+
file: "index.html",
|
|
1422
|
+
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)`,
|
|
1423
|
+
message: `${bare.length} HUD text element(s) have no outline`,
|
|
1424
|
+
phase: "boot",
|
|
1425
|
+
recipe: "frogoe-registry \u2192 block authoring (sticker depth pattern)",
|
|
1426
|
+
severity: "error"
|
|
1427
|
+
});
|
|
1428
|
+
};
|
|
1429
|
+
collapseFinding = (measures) => {
|
|
1430
|
+
const collapsed = measures.filter((m) => m.width < 4 || m.height < 4);
|
|
1431
|
+
if (collapsed.length === 0 || !collapsed[0]) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
const first = collapsed[0];
|
|
1435
|
+
return finding({
|
|
1436
|
+
code: "live/layout-collapse",
|
|
1437
|
+
file: "index.html",
|
|
1438
|
+
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)`,
|
|
1439
|
+
message: `${collapsed.length} HUD element(s) collapsed to zero size`,
|
|
1440
|
+
phase: "boot",
|
|
1441
|
+
severity: "error"
|
|
1442
|
+
});
|
|
1443
|
+
};
|
|
1444
|
+
pageErrorFinding = (errors, viewport) => {
|
|
1445
|
+
const first = errors[0];
|
|
1446
|
+
if (first === void 0) {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
return finding({
|
|
1450
|
+
code: "live/page-error",
|
|
1451
|
+
file: "game.js",
|
|
1452
|
+
fix: `uncaught: ${first.slice(0, 140)}`,
|
|
1453
|
+
message: `${errors.length} uncaught page error(s) [${viewport}]`,
|
|
1454
|
+
phase: "boot",
|
|
1455
|
+
severity: "error"
|
|
1456
|
+
});
|
|
1457
|
+
};
|
|
1458
|
+
consoleErrorFinding = (entries, pageErrors, viewport) => {
|
|
1459
|
+
const unique = entries.filter((entry) => !pageErrors.some((e) => e.includes(entry.slice(0, 60))));
|
|
1460
|
+
const first = unique[0];
|
|
1461
|
+
if (first === void 0) {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
return finding({
|
|
1465
|
+
code: "live/console-error",
|
|
1466
|
+
file: "game.js",
|
|
1467
|
+
fix: `console.error: ${first.slice(0, 140)} \u2014 recoverable failures should be handled, not logged`,
|
|
1468
|
+
message: `${unique.length} console.error(s) [${viewport}]`,
|
|
1469
|
+
phase: "boot",
|
|
1470
|
+
severity: "warning"
|
|
1471
|
+
});
|
|
1472
|
+
};
|
|
1473
|
+
canvasMissingFinding = (viewport) => finding({
|
|
1474
|
+
code: "live/canvas-missing",
|
|
1475
|
+
file: "index.html",
|
|
1476
|
+
fix: 'the contract boots on <canvas id="c">',
|
|
1477
|
+
message: `canvas missing [${viewport}]`,
|
|
1478
|
+
phase: "boot",
|
|
1479
|
+
severity: "error"
|
|
1480
|
+
});
|
|
1481
|
+
canvasUnpaintedFinding = (viewport, phase = "boot") => finding({
|
|
1482
|
+
code: "live/canvas-unpainted",
|
|
1483
|
+
file: "game.js",
|
|
1484
|
+
fix: "loop.render never drew \u2014 fill loop.render = (ctx) => {...}",
|
|
1485
|
+
message: `canvas stayed blank [${viewport}]`,
|
|
1486
|
+
phase,
|
|
1487
|
+
severity: "error"
|
|
1488
|
+
});
|
|
1489
|
+
contractMissingFinding = (viewport) => finding({
|
|
1490
|
+
code: "live/contract-missing",
|
|
1491
|
+
file: "index.html",
|
|
1492
|
+
fix: 'import { defineGame } from "frogoe" \u2014 the runtime publishes window.__frogoe at boot',
|
|
1493
|
+
message: `window.__frogoe absent [${viewport}]`,
|
|
1494
|
+
phase: "boot",
|
|
1495
|
+
severity: "error"
|
|
1496
|
+
});
|
|
1497
|
+
stateStuckFinding = (state, viewport, phase = "boot") => {
|
|
1498
|
+
if (state !== "loading") {
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
return finding({
|
|
1502
|
+
code: "live/state-stuck",
|
|
1503
|
+
file: "game.js",
|
|
1504
|
+
fix: 'state never left "loading" \u2014 defineGame() threw before start() or start() was never called',
|
|
1505
|
+
message: `state stuck in "loading" [${viewport}]`,
|
|
1506
|
+
phase,
|
|
1507
|
+
severity: "error"
|
|
1508
|
+
});
|
|
1509
|
+
};
|
|
1510
|
+
earlyDeathFinding = (viewport, phase = "boot") => finding({
|
|
1511
|
+
code: "live/early-death",
|
|
1512
|
+
file: "game.js",
|
|
1513
|
+
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")',
|
|
1514
|
+
message: `game ended before the first input [${viewport}]`,
|
|
1515
|
+
phase,
|
|
1516
|
+
severity: "error"
|
|
1517
|
+
});
|
|
1518
|
+
fpsFinding = (fps, viewport) => {
|
|
1519
|
+
if (fps === void 0 || fps >= FPS_FLOOR) {
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
return finding({
|
|
1523
|
+
code: "live/fps",
|
|
1524
|
+
file: "game.js",
|
|
1525
|
+
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`,
|
|
1526
|
+
message: `frame rate below the playability floor [${viewport}]`,
|
|
1527
|
+
phase: "play",
|
|
1528
|
+
recipe: "frogoe-creative \u2192 game-feel (motion rules)",
|
|
1529
|
+
severity: "warning"
|
|
1530
|
+
});
|
|
1531
|
+
};
|
|
1532
|
+
fpsSustainedFinding = (buckets, viewport) => {
|
|
1533
|
+
if (buckets.length < FPS_SUSTAINED_WINDOW) {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
let worst = Number.POSITIVE_INFINITY;
|
|
1537
|
+
for (let i = 0; i + FPS_SUSTAINED_WINDOW <= buckets.length; i++) {
|
|
1538
|
+
const window = buckets.slice(i, i + FPS_SUSTAINED_WINDOW);
|
|
1539
|
+
const avg = window.reduce((a, b) => a + b, 0) / window.length;
|
|
1540
|
+
if (avg < worst) {
|
|
1541
|
+
worst = avg;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (worst >= FPS_FLOOR) {
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
|
|
1548
|
+
return {
|
|
1549
|
+
finding: finding({
|
|
1550
|
+
code: "live/fps-sustained",
|
|
1551
|
+
file: "game.js",
|
|
1552
|
+
fix: `${worst.toFixed(0)} fps held for ${FPS_SUSTAINED_WINDOW}s+ on ${viewport} (floor ${FPS_FLOOR}) \u2014 the game is consistently slow, not jittering: cut per-frame allocations, shrink offscreen canvases, reduce draw calls`,
|
|
1553
|
+
message: `sustained low frame rate [${viewport}]`,
|
|
1554
|
+
phase: "play",
|
|
1555
|
+
recipe: "frogoe-creative \u2192 game-feel (motion rules)",
|
|
1556
|
+
severity: "error"
|
|
1557
|
+
}),
|
|
1558
|
+
mean
|
|
1559
|
+
};
|
|
1560
|
+
};
|
|
1561
|
+
frozenFrameFinding = (streak) => {
|
|
1562
|
+
if (streak < FROZEN_STREAK) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
return finding({
|
|
1566
|
+
code: "live/frozen-frame",
|
|
1567
|
+
file: "game.js",
|
|
1568
|
+
fix: `canvas held the same frame for ${streak} samples while playing \u2014 loop.render may have stopped or draws a static scene`,
|
|
1569
|
+
message: "canvas froze during play",
|
|
1570
|
+
phase: "play",
|
|
1571
|
+
severity: "warning"
|
|
1572
|
+
});
|
|
1573
|
+
};
|
|
1574
|
+
pausedFinding = () => finding({
|
|
1575
|
+
code: "live/paused",
|
|
1576
|
+
file: "game.js",
|
|
1577
|
+
fix: 'state read "paused" during scripted play \u2014 pause() ran without a matching resume(); check document.hidden handling and blur listeners',
|
|
1578
|
+
message: "game paused itself during play",
|
|
1579
|
+
phase: "play",
|
|
1580
|
+
severity: "warning"
|
|
1581
|
+
});
|
|
1582
|
+
stateCorruptFinding = (state) => finding({
|
|
1583
|
+
code: "live/state-corrupt",
|
|
1584
|
+
file: "game.js",
|
|
1585
|
+
fix: `state read "${state}" \u2014 outside the contract's set (loading/playing/paused/over); game code is mutating window.__frogoe directly`,
|
|
1586
|
+
message: `state left the contract's state machine ("${state}")`,
|
|
1587
|
+
phase: "play",
|
|
1588
|
+
severity: "error"
|
|
1589
|
+
});
|
|
1590
|
+
playabilityFinding = (result) => {
|
|
1591
|
+
if (result === "pass") {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
if (result === "no-input") {
|
|
1595
|
+
return finding({
|
|
1596
|
+
code: "live/no-input",
|
|
1597
|
+
file: "game.js",
|
|
1598
|
+
fix: 'the game never registered input.on("down", ...) \u2014 wire the core verb before shipping',
|
|
1599
|
+
message: "game has no input handler",
|
|
1600
|
+
phase: "play",
|
|
1601
|
+
severity: "error"
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
return finding({
|
|
1605
|
+
code: "live/not-playable",
|
|
1606
|
+
file: "game.js",
|
|
1607
|
+
fix: "scripted taps produced no canvas change \u2014 loop.update/loop.render may be wired but the game logic never runs",
|
|
1608
|
+
message: "game did not respond to scripted input",
|
|
1609
|
+
phase: "play",
|
|
1610
|
+
severity: "error"
|
|
1611
|
+
});
|
|
1612
|
+
};
|
|
1613
|
+
finishEventFinding = (stateOver, finishCount) => {
|
|
1614
|
+
if (stateOver === finishCount > 0) {
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
return finding({
|
|
1618
|
+
code: "live/finish-event-missing",
|
|
1619
|
+
file: "game.js",
|
|
1620
|
+
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()',
|
|
1621
|
+
message: "state/event mismatch at game over",
|
|
1622
|
+
phase: "end",
|
|
1623
|
+
severity: "error"
|
|
1624
|
+
});
|
|
1625
|
+
};
|
|
1626
|
+
neverEndsFinding = (budgetMs) => finding({
|
|
1627
|
+
code: "live/never-ends",
|
|
1628
|
+
file: "game.js",
|
|
1629
|
+
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`,
|
|
1630
|
+
message: "game never reached the over state",
|
|
1631
|
+
phase: "end",
|
|
1632
|
+
severity: "warning"
|
|
1633
|
+
});
|
|
1634
|
+
audioLockedFinding = (audio) => {
|
|
1635
|
+
if (audio.count === 0 || audio.running > 0) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
return finding({
|
|
1639
|
+
code: "live/audio-locked",
|
|
1640
|
+
file: "game.js",
|
|
1641
|
+
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)',
|
|
1642
|
+
message: "audio never recovers \u2014 every phone interruption plays this game silent",
|
|
1643
|
+
phase: "play",
|
|
1644
|
+
recipe: "frogoe-core \u2192 references/audio.md",
|
|
1645
|
+
severity: "error"
|
|
1646
|
+
});
|
|
1647
|
+
};
|
|
1648
|
+
noGameoverCardFinding = () => finding({
|
|
1649
|
+
code: "live/no-gameover-card",
|
|
1650
|
+
file: "index.html",
|
|
1651
|
+
fix: "no [data-block-gameover] overlay when the game ended \u2014 install the game-over-card block so death has a screen",
|
|
1652
|
+
message: "game over has no overlay",
|
|
1653
|
+
phase: "end",
|
|
1654
|
+
recipe: "frogoe-registry \u2192 game-over-card",
|
|
1655
|
+
severity: "warning"
|
|
1656
|
+
});
|
|
1657
|
+
THROTTLE_RATE = 4;
|
|
1658
|
+
THROTTLED_FPS_FLOOR = 15;
|
|
1659
|
+
fpsThrottledFinding = (buckets, viewport) => {
|
|
1660
|
+
if (buckets.length === 0) return null;
|
|
1661
|
+
const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
|
|
1662
|
+
if (mean >= THROTTLED_FPS_FLOOR) return null;
|
|
1663
|
+
return finding({
|
|
1664
|
+
code: "live/fps-throttled",
|
|
1665
|
+
file: "game.js",
|
|
1666
|
+
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`,
|
|
1667
|
+
message: "cpu-bound collapse under phone-class throttle",
|
|
1668
|
+
phase: "play",
|
|
1669
|
+
severity: "warning"
|
|
1670
|
+
});
|
|
1671
|
+
};
|
|
1672
|
+
noRetryFinding = () => finding({
|
|
1673
|
+
code: "live/no-retry",
|
|
1674
|
+
file: "index.html",
|
|
1675
|
+
fix: "no [data-block-retry] button anywhere \u2014 the player is hard-stuck after death; the retry affordance is the loop's exit",
|
|
1676
|
+
message: "no retry affordance after game over",
|
|
1677
|
+
phase: "end",
|
|
1678
|
+
recipe: "frogoe-registry \u2192 game-over-card",
|
|
1679
|
+
severity: "error"
|
|
1680
|
+
});
|
|
1681
|
+
retryDeadFinding = () => finding({
|
|
1682
|
+
code: "live/retry-dead",
|
|
1683
|
+
file: "game.js",
|
|
1684
|
+
fix: 'clicking retry produced no reload \u2014 wire it: retry.addEventListener("click", () => location.reload())',
|
|
1685
|
+
message: "retry button did not reload the page",
|
|
1686
|
+
phase: "retry",
|
|
1687
|
+
severity: "error"
|
|
1688
|
+
});
|
|
1689
|
+
rebootFinding = (state) => {
|
|
1690
|
+
if (state === "playing") {
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
return finding({
|
|
1694
|
+
code: "live/state-stuck",
|
|
1695
|
+
file: "game.js",
|
|
1696
|
+
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)`,
|
|
1697
|
+
message: `retry boot stuck in "${state}"`,
|
|
1698
|
+
phase: "retry",
|
|
1699
|
+
severity: "error"
|
|
1700
|
+
});
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
// src/live/phases.ts
|
|
1706
|
+
var END_BUDGET_MS, RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep2, jitterX, jitterY, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
|
|
1707
|
+
var init_phases = __esm({
|
|
1708
|
+
"src/live/phases.ts"() {
|
|
1709
|
+
"use strict";
|
|
1710
|
+
init_decisions();
|
|
1711
|
+
END_BUDGET_MS = 45e3;
|
|
1712
|
+
RETRY_NAV_MS = 8e3;
|
|
1713
|
+
PLAY_STEPS = 7;
|
|
1714
|
+
PLAY_STEP_MS = 850;
|
|
1715
|
+
HOLD_STEP_INDEX = 3;
|
|
1716
|
+
DRAG_STEP_INDEX = 5;
|
|
1717
|
+
DRAG_SPAN = 90;
|
|
1718
|
+
HOLD_MS = 400;
|
|
1719
|
+
POLL_MS = 400;
|
|
1720
|
+
GRACE_MS = 150;
|
|
1721
|
+
STABILITY_CYCLES = 2;
|
|
1722
|
+
START_BURST_TAPS = 3;
|
|
1723
|
+
DESKTOP_FPS_MS = 2e3;
|
|
1724
|
+
sleep2 = (ms) => new Promise((resolve) => {
|
|
1725
|
+
setTimeout(resolve, ms);
|
|
1726
|
+
});
|
|
1727
|
+
jitterX = (step) => step * 37 % 121 - 60;
|
|
1728
|
+
jitterY = (step) => step * 53 % 181 - 90;
|
|
1729
|
+
hasError = (findings) => findings.some((f) => f.severity === "error");
|
|
1730
|
+
runBootChecks = async (driver, ctx) => {
|
|
1731
|
+
const findings = [];
|
|
1732
|
+
const name = ctx.viewport.name;
|
|
1733
|
+
const pageErr = pageErrorFinding(driver.errors(), name);
|
|
1734
|
+
if (pageErr) {
|
|
1735
|
+
findings.push(pageErr);
|
|
1736
|
+
}
|
|
1737
|
+
const conErr = consoleErrorFinding(driver.consoleErrors(), driver.errors(), name);
|
|
1738
|
+
if (conErr) {
|
|
1739
|
+
findings.push(conErr);
|
|
1740
|
+
}
|
|
1741
|
+
const probe = await driver.domProbe();
|
|
1742
|
+
if (!probe.canvasPresent) {
|
|
1743
|
+
findings.push(canvasMissingFinding(name));
|
|
1744
|
+
} else if (!await driver.canvasPainted()) {
|
|
1745
|
+
findings.push(canvasUnpaintedFinding(name));
|
|
1746
|
+
}
|
|
1747
|
+
if (probe.state === "(missing)") {
|
|
1748
|
+
findings.push(contractMissingFinding(name));
|
|
1749
|
+
} else if (probe.state === "over") {
|
|
1750
|
+
findings.push(earlyDeathFinding(name));
|
|
1751
|
+
} else {
|
|
1752
|
+
const stuck = stateStuckFinding(probe.state, name);
|
|
1753
|
+
if (stuck) {
|
|
1754
|
+
findings.push(stuck);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
if (probe.hudPresent) {
|
|
1758
|
+
const measures = await driver.hudMeasures();
|
|
1759
|
+
const outline = outlineFinding(measures);
|
|
1760
|
+
if (outline) {
|
|
1761
|
+
findings.push(outline);
|
|
1762
|
+
}
|
|
1763
|
+
const collapse = collapseFinding(measures);
|
|
1764
|
+
if (collapse) {
|
|
1765
|
+
findings.push(collapse);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return findings;
|
|
1769
|
+
};
|
|
1770
|
+
runDesktopPass = async (driver, ctx) => {
|
|
1771
|
+
const doSleep = ctx.sleep ?? sleep2;
|
|
1772
|
+
const findings = await runBootChecks(driver, ctx);
|
|
1773
|
+
if (hasError(findings)) {
|
|
1774
|
+
return { findings };
|
|
1775
|
+
}
|
|
1776
|
+
const mark = await driver.fpsMark();
|
|
1777
|
+
await doSleep(DESKTOP_FPS_MS);
|
|
1778
|
+
const buckets = await driver.fpsSince(mark);
|
|
1779
|
+
if (buckets.length === 0) {
|
|
1780
|
+
return { findings };
|
|
1781
|
+
}
|
|
1782
|
+
const mean = buckets.reduce((a, b) => a + b, 0) / buckets.length;
|
|
1783
|
+
const warn = fpsFinding(mean, ctx.viewport.name);
|
|
1784
|
+
if (warn) {
|
|
1785
|
+
findings.push(warn);
|
|
1786
|
+
}
|
|
1787
|
+
await ctx.shot?.("live-desktop.png");
|
|
1788
|
+
return { findings, fps: Math.round(mean) };
|
|
1789
|
+
};
|
|
1790
|
+
overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
|
|
1791
|
+
retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
|
|
1792
|
+
waitForOver = async (driver, doSleep) => {
|
|
1793
|
+
for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
|
|
1794
|
+
await doSleep(POLL_MS);
|
|
1795
|
+
if (await driver.gameState() === "over") {
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return false;
|
|
1800
|
+
};
|
|
1801
|
+
runStartBurst = async (driver, ctx) => {
|
|
1802
|
+
const doSleep = ctx.sleep ?? sleep2;
|
|
1803
|
+
for (let step = 0; step < START_BURST_TAPS; step++) {
|
|
1804
|
+
const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
|
|
1805
|
+
const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
|
|
1806
|
+
await driver.tap(x, y);
|
|
1807
|
+
await doSleep(PLAY_STEP_MS);
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
verifyDeath = async (driver, ctx, findings, cycle) => {
|
|
1811
|
+
const doSleep = ctx.sleep ?? sleep2;
|
|
1812
|
+
let events = await driver.finishEvents();
|
|
1813
|
+
if (events.length === 0) {
|
|
1814
|
+
await doSleep(GRACE_MS);
|
|
1815
|
+
events = await driver.finishEvents();
|
|
1816
|
+
}
|
|
1817
|
+
const mismatch = finishEventFinding(true, events.length);
|
|
1818
|
+
if (mismatch) {
|
|
1819
|
+
findings.push(mismatch);
|
|
1820
|
+
}
|
|
1821
|
+
const presence = await driver.retryPresence();
|
|
1822
|
+
if (!presence.gameover) {
|
|
1823
|
+
findings.push(noGameoverCardFinding());
|
|
1824
|
+
}
|
|
1825
|
+
if (!presence.retry) {
|
|
1826
|
+
findings.push(noRetryFinding());
|
|
1827
|
+
}
|
|
1828
|
+
await ctx.shot?.(overShotName(cycle));
|
|
1829
|
+
return presence.retry;
|
|
1830
|
+
};
|
|
1831
|
+
runLifecycle = async (driver, ctx) => {
|
|
1832
|
+
const doSleep = ctx.sleep ?? sleep2;
|
|
1833
|
+
const settle = ctx.settleMs ?? 2e3;
|
|
1834
|
+
const name = ctx.viewport.name;
|
|
1835
|
+
const findings = [];
|
|
1836
|
+
const boot = await runBootChecks(driver, ctx);
|
|
1837
|
+
findings.push(...boot);
|
|
1838
|
+
await ctx.shot?.("live-mobile.png");
|
|
1839
|
+
if (hasError(boot)) {
|
|
1840
|
+
return { findings, lifecycle: { ends: false, retryReloads: 0 }, playability: "no-input" };
|
|
1841
|
+
}
|
|
1842
|
+
const mark = await driver.fpsMark();
|
|
1843
|
+
const hashes = [];
|
|
1844
|
+
let streak = 0;
|
|
1845
|
+
let maxStreak = 0;
|
|
1846
|
+
let sawOver = false;
|
|
1847
|
+
let sawPaused = false;
|
|
1848
|
+
let sawStuck = false;
|
|
1849
|
+
let corrupt = null;
|
|
1850
|
+
for (let step = 0; step < PLAY_STEPS; step++) {
|
|
1851
|
+
const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
|
|
1852
|
+
const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
|
|
1853
|
+
if (step === HOLD_STEP_INDEX) {
|
|
1854
|
+
await driver.hold(x, y, HOLD_MS);
|
|
1855
|
+
} else if (step === DRAG_STEP_INDEX) {
|
|
1856
|
+
await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
|
|
1857
|
+
} else {
|
|
1858
|
+
await driver.tap(x, y);
|
|
1859
|
+
}
|
|
1860
|
+
await doSleep(PLAY_STEP_MS);
|
|
1861
|
+
const state = await driver.gameState();
|
|
1862
|
+
if (state === "over") {
|
|
1863
|
+
sawOver = true;
|
|
1864
|
+
break;
|
|
1865
|
+
}
|
|
1866
|
+
if (state === "paused") {
|
|
1867
|
+
sawPaused = true;
|
|
1868
|
+
} else if (state === "loading") {
|
|
1869
|
+
sawStuck = true;
|
|
1870
|
+
} else if (state !== "playing" && state !== "(missing)") {
|
|
1871
|
+
corrupt ??= state;
|
|
1872
|
+
}
|
|
1873
|
+
const hash = await driver.canvasHash();
|
|
1874
|
+
if (hash !== null) {
|
|
1875
|
+
if (hashes.length > 0 && hash === hashes[hashes.length - 1] && state === "playing") {
|
|
1876
|
+
streak += 1;
|
|
1877
|
+
maxStreak = Math.max(maxStreak, streak);
|
|
1878
|
+
} else {
|
|
1879
|
+
streak = 0;
|
|
1880
|
+
}
|
|
1881
|
+
hashes.push(hash);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const buckets = await driver.fpsSince(mark);
|
|
1885
|
+
const mean = buckets.length > 0 ? buckets.reduce((a, b) => a + b, 0) / buckets.length : void 0;
|
|
1886
|
+
if (corrupt !== null) {
|
|
1887
|
+
findings.push(stateCorruptFinding(corrupt));
|
|
1888
|
+
}
|
|
1889
|
+
if (sawStuck) {
|
|
1890
|
+
const stuck = stateStuckFinding("loading", name, "play");
|
|
1891
|
+
if (stuck) {
|
|
1892
|
+
findings.push(stuck);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (sawPaused) {
|
|
1896
|
+
findings.push(pausedFinding());
|
|
1897
|
+
}
|
|
1898
|
+
const frozen = frozenFrameFinding(maxStreak);
|
|
1899
|
+
if (frozen) {
|
|
1900
|
+
findings.push(frozen);
|
|
1901
|
+
}
|
|
1902
|
+
const sustained = fpsSustainedFinding(buckets, name);
|
|
1903
|
+
if (sustained) {
|
|
1904
|
+
findings.push(sustained.finding);
|
|
1905
|
+
} else {
|
|
1906
|
+
const warn = fpsFinding(mean, name);
|
|
1907
|
+
if (warn) {
|
|
1908
|
+
findings.push(warn);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const responded = new Set(hashes).size > 1 || sawOver;
|
|
1912
|
+
const playability = responded ? "pass" : "fail";
|
|
1913
|
+
const play = playabilityFinding(playability);
|
|
1914
|
+
if (play) {
|
|
1915
|
+
findings.push(play);
|
|
1916
|
+
}
|
|
1917
|
+
const audioBefore = await driver.audioStates();
|
|
1918
|
+
if (audioBefore.count > 0) {
|
|
1919
|
+
await driver.interruptAudio();
|
|
1920
|
+
await runStartBurst(driver, ctx);
|
|
1921
|
+
await doSleep(600);
|
|
1922
|
+
const audioFinding = audioLockedFinding(await driver.audioStates());
|
|
1923
|
+
if (audioFinding) {
|
|
1924
|
+
findings.push(audioFinding);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
let ends = false;
|
|
1928
|
+
let retryReloads = 0;
|
|
1929
|
+
if (!sawOver) {
|
|
1930
|
+
sawOver = await waitForOver(driver, doSleep);
|
|
1931
|
+
}
|
|
1932
|
+
if (!sawOver) {
|
|
1933
|
+
findings.push(neverEndsFinding(END_BUDGET_MS));
|
|
1934
|
+
} else {
|
|
1935
|
+
ends = true;
|
|
1936
|
+
let canRetry = await verifyDeath(driver, ctx, findings, 0);
|
|
1937
|
+
for (let cycle = 0; cycle < STABILITY_CYCLES && canRetry; cycle++) {
|
|
1938
|
+
const reloaded = await driver.clickRetryAwaitReload(RETRY_NAV_MS);
|
|
1939
|
+
if (!reloaded) {
|
|
1940
|
+
findings.push(retryDeadFinding());
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
retryReloads += 1;
|
|
1944
|
+
await doSleep(settle);
|
|
1945
|
+
const rebootState = await driver.gameState();
|
|
1946
|
+
if (rebootState === "over") {
|
|
1947
|
+
findings.push(earlyDeathFinding(name, "retry"));
|
|
1948
|
+
} else {
|
|
1949
|
+
const reboot = rebootFinding(rebootState);
|
|
1950
|
+
if (reboot) {
|
|
1951
|
+
findings.push(reboot);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
if (!await driver.canvasPainted()) {
|
|
1955
|
+
findings.push(canvasUnpaintedFinding(name, "retry"));
|
|
1956
|
+
}
|
|
1957
|
+
await ctx.shot?.(retryShotName(cycle));
|
|
1958
|
+
if (cycle < STABILITY_CYCLES - 1) {
|
|
1959
|
+
await runStartBurst(driver, ctx);
|
|
1960
|
+
const overAgain = await waitForOver(driver, doSleep);
|
|
1961
|
+
if (!overAgain) {
|
|
1962
|
+
findings.push(neverEndsFinding(END_BUDGET_MS));
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
canRetry = await verifyDeath(driver, ctx, findings, cycle + 1);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
const throttleMark = await driver.fpsMark();
|
|
1970
|
+
await driver.setCpuThrottling(THROTTLE_RATE);
|
|
1971
|
+
await runStartBurst(driver, ctx);
|
|
1972
|
+
for (let step = 0; step < PLAY_STEPS; step++) {
|
|
1973
|
+
const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
|
|
1974
|
+
const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
|
|
1975
|
+
if (step === HOLD_STEP_INDEX) {
|
|
1976
|
+
await driver.hold(x, y, HOLD_MS);
|
|
1977
|
+
} else if (step === DRAG_STEP_INDEX) {
|
|
1978
|
+
await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
|
|
1979
|
+
} else {
|
|
1980
|
+
await driver.tap(x, y);
|
|
1981
|
+
}
|
|
1982
|
+
await doSleep(PLAY_STEP_MS);
|
|
1983
|
+
}
|
|
1984
|
+
const throttledBuckets = await driver.fpsSince(throttleMark);
|
|
1985
|
+
await driver.setCpuThrottling(1);
|
|
1986
|
+
const throttled = fpsThrottledFinding(throttledBuckets, name);
|
|
1987
|
+
if (throttled) {
|
|
1988
|
+
findings.push(throttled);
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
findings,
|
|
1992
|
+
lifecycle: { ends, retryReloads },
|
|
1993
|
+
mobileFps: mean !== void 0 ? Math.round(mean) : void 0,
|
|
1994
|
+
playability
|
|
1995
|
+
};
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
// src/net/ip.ts
|
|
2001
|
+
import { execSync } from "child_process";
|
|
2002
|
+
import os from "os";
|
|
2003
|
+
var VIRTUAL, isPrivateV4, isLoopback, selfAddresses, pickLanIp, interfaceFromRouteOutput, ROUTE_PROBES, routedInterfaceName, resolveLan;
|
|
2004
|
+
var init_ip = __esm({
|
|
2005
|
+
"src/net/ip.ts"() {
|
|
2006
|
+
"use strict";
|
|
2007
|
+
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;
|
|
2008
|
+
isPrivateV4 = (ip) => {
|
|
2009
|
+
const octets = ip.split(".").map(Number);
|
|
2010
|
+
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
2011
|
+
return false;
|
|
2012
|
+
}
|
|
2013
|
+
const [a = -1, b = -1] = octets;
|
|
2014
|
+
if (a === 10) return true;
|
|
2015
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
2016
|
+
if (a === 192 && b === 168) return true;
|
|
2017
|
+
return false;
|
|
2018
|
+
};
|
|
2019
|
+
isLoopback = (addr) => {
|
|
2020
|
+
if (!addr) return false;
|
|
2021
|
+
return addr === "::1" || addr.startsWith("127.") || addr.startsWith("::ffff:127.");
|
|
2022
|
+
};
|
|
2023
|
+
selfAddresses = (interfaces) => {
|
|
2024
|
+
const own = /* @__PURE__ */ new Set();
|
|
2025
|
+
for (const entries of Object.values(interfaces)) {
|
|
2026
|
+
for (const entry of entries ?? []) {
|
|
2027
|
+
if (entry.family === "IPv4") {
|
|
2028
|
+
own.add(entry.address);
|
|
2029
|
+
own.add(`::ffff:${entry.address}`);
|
|
2030
|
+
} else if (entry.family === "IPv6") {
|
|
2031
|
+
own.add(entry.address);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return own;
|
|
2036
|
+
};
|
|
2037
|
+
pickLanIp = (interfaces, routedName) => {
|
|
2038
|
+
const physical = [];
|
|
2039
|
+
const virtual = [];
|
|
2040
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
2041
|
+
for (const entry of entries ?? []) {
|
|
2042
|
+
if (entry.family !== "IPv4" || entry.internal || !isPrivateV4(entry.address)) continue;
|
|
2043
|
+
const item = { ip: entry.address, name };
|
|
2044
|
+
if (VIRTUAL.test(name.toLowerCase())) virtual.push(item);
|
|
2045
|
+
else physical.push(item);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
physical.sort((a, b) => a.name.localeCompare(b.name));
|
|
2049
|
+
virtual.sort((a, b) => a.name.localeCompare(b.name));
|
|
2050
|
+
if (routedName) {
|
|
2051
|
+
const routed = physical.find((p) => p.name === routedName) ?? virtual.find((v) => v.name === routedName);
|
|
2052
|
+
if (routed) {
|
|
2053
|
+
return {
|
|
2054
|
+
candidates: [routed.ip],
|
|
2055
|
+
confidence: "routed",
|
|
2056
|
+
ip: routed.ip,
|
|
2057
|
+
virtual: !physical.includes(routed)
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
const chosen = physical[0] ?? virtual[0];
|
|
2062
|
+
if (!chosen) {
|
|
2063
|
+
return { candidates: [], confidence: "none", virtual: false };
|
|
2064
|
+
}
|
|
2065
|
+
return {
|
|
2066
|
+
candidates: physical.length > 0 ? physical.map((p) => p.ip) : virtual.map((v) => v.ip),
|
|
2067
|
+
confidence: "heuristic",
|
|
2068
|
+
ip: chosen.ip,
|
|
2069
|
+
virtual: !physical.includes(chosen)
|
|
2070
|
+
};
|
|
2071
|
+
};
|
|
2072
|
+
interfaceFromRouteOutput = (output, style) => {
|
|
2073
|
+
if (style === "bsd") return /interface:\s*(\S+)/u.exec(output)?.[1];
|
|
2074
|
+
if (style === "linux") return /dev\s+(\S+)/u.exec(output)?.[1];
|
|
2075
|
+
return output.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0);
|
|
2076
|
+
};
|
|
2077
|
+
ROUTE_PROBES = [
|
|
2078
|
+
{ cmd: "route -n get default", style: "bsd" },
|
|
2079
|
+
// darwin/bsd; usage-errors elsewhere
|
|
2080
|
+
{ cmd: "ip route show default", style: "linux" },
|
|
2081
|
+
// linux; absent on darwin/win
|
|
2082
|
+
{
|
|
2083
|
+
cmd: `powershell -NoProfile -Command "(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1).InterfaceAlias"`,
|
|
2084
|
+
style: "windows"
|
|
2085
|
+
}
|
|
2086
|
+
];
|
|
2087
|
+
routedInterfaceName = () => {
|
|
2088
|
+
for (const probe of ROUTE_PROBES) {
|
|
2089
|
+
try {
|
|
2090
|
+
const out = execSync(probe.cmd, {
|
|
2091
|
+
encoding: "utf-8",
|
|
2092
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2093
|
+
timeout: 3e3
|
|
2094
|
+
});
|
|
2095
|
+
const name = interfaceFromRouteOutput(out, probe.style);
|
|
2096
|
+
if (name) return name;
|
|
2097
|
+
} catch {
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return null;
|
|
2101
|
+
};
|
|
2102
|
+
resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
// src/telemetry/records.ts
|
|
2107
|
+
var FPS_FLOOR2, formatClock, dipSpans, beaconToRecords, summarizeRecords;
|
|
2108
|
+
var init_records = __esm({
|
|
2109
|
+
"src/telemetry/records.ts"() {
|
|
2110
|
+
"use strict";
|
|
2111
|
+
FPS_FLOOR2 = 30;
|
|
2112
|
+
formatClock = (wall) => {
|
|
2113
|
+
const d = new Date(wall);
|
|
2114
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2115
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
2116
|
+
};
|
|
2117
|
+
dipSpans = (buckets) => {
|
|
2118
|
+
const spans = [];
|
|
2119
|
+
let start = -1;
|
|
2120
|
+
let worst = 0;
|
|
2121
|
+
for (let i = 0; i <= buckets.length; i++) {
|
|
2122
|
+
const low = i < buckets.length && (buckets[i] ?? 0) > 0 && (buckets[i] ?? 0) < FPS_FLOOR2;
|
|
2123
|
+
if (low && start === -1) {
|
|
2124
|
+
start = i;
|
|
2125
|
+
worst = buckets[i] ?? 0;
|
|
2126
|
+
} else if (low) {
|
|
2127
|
+
worst = Math.min(worst, buckets[i] ?? 0);
|
|
2128
|
+
} else if (start !== -1) {
|
|
2129
|
+
spans.push({ fps: worst, len: i - start, start });
|
|
2130
|
+
start = -1;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return spans;
|
|
2134
|
+
};
|
|
2135
|
+
beaconToRecords = (payload, arrivalWall) => {
|
|
2136
|
+
const out = [];
|
|
2137
|
+
const arrivalUp = payload.up;
|
|
2138
|
+
const wallAt = (up) => arrivalWall - Math.max(0, arrivalUp - up) * 1e3;
|
|
2139
|
+
const buckets = payload.fps ?? [];
|
|
2140
|
+
const spans = dipSpans(buckets);
|
|
2141
|
+
const spanEnd = new Map(spans.map((s) => [s.start + s.len - 1, s]));
|
|
2142
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
2143
|
+
const fps = buckets[i] ?? 0;
|
|
2144
|
+
const up = Math.max(0, arrivalUp - (buckets.length - 1 - i));
|
|
2145
|
+
const wall = wallAt(up);
|
|
2146
|
+
const time = formatClock(wall);
|
|
2147
|
+
out.push({
|
|
2148
|
+
record: { fps, time, type: "fps", up, wall },
|
|
2149
|
+
text: ""
|
|
2150
|
+
});
|
|
2151
|
+
const span = spanEnd.get(i);
|
|
2152
|
+
if (span) {
|
|
2153
|
+
out.push({
|
|
2154
|
+
record: { fps: span.fps, time, type: "fps", up, wall },
|
|
2155
|
+
text: `\u26A0 ${time} fps ${span.fps} \u2014 dip ${span.len}s`
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
for (const event of payload.events ?? []) {
|
|
2160
|
+
const wall = wallAt(event.up);
|
|
2161
|
+
const time = formatClock(wall);
|
|
2162
|
+
const type = event.type === "error" || event.type === "rejection" || event.type === "hidden" || event.type === "visible" ? event.type : "error";
|
|
2163
|
+
const msg = (event.msg ?? "").slice(0, 160);
|
|
2164
|
+
const text = type === "hidden" ? `\xB7 ${time} phone hidden` : type === "visible" ? `\xB7 ${time} phone visible` : `\u2716 ${time} ${type === "rejection" ? "unhandled rejection" : "page error"}: ${msg}`;
|
|
2165
|
+
out.push({ record: { msg, time, type, up: event.up, wall }, text });
|
|
2166
|
+
}
|
|
2167
|
+
return out;
|
|
2168
|
+
};
|
|
2169
|
+
summarizeRecords = (records) => {
|
|
2170
|
+
const fps = records.filter((r) => r.type === "fps" && typeof r.fps === "number");
|
|
2171
|
+
const buckets = fps.map((r) => r.fps ?? 0).filter((n) => n > 0);
|
|
2172
|
+
const spans = dipSpans(buckets);
|
|
2173
|
+
const orderKey = (r) => typeof r.wall === "number" ? r.wall : r.up * 1e3;
|
|
2174
|
+
let hiddenS = 0;
|
|
2175
|
+
let pendingHidden;
|
|
2176
|
+
for (const r of records) {
|
|
2177
|
+
if (pendingHidden !== void 0) {
|
|
2178
|
+
hiddenS += Math.max(0, orderKey(r) - pendingHidden) / 1e3;
|
|
2179
|
+
pendingHidden = void 0;
|
|
2180
|
+
}
|
|
2181
|
+
if (r.type === "hidden") {
|
|
2182
|
+
pendingHidden = orderKey(r);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
const worstSpan = spans.reduce(
|
|
2186
|
+
(acc, s) => acc === void 0 || s.len > acc.len ? s : acc,
|
|
2187
|
+
void 0
|
|
2188
|
+
);
|
|
2189
|
+
const worst = worstSpan ? {
|
|
2190
|
+
fps: worstSpan.fps,
|
|
2191
|
+
len: worstSpan.len,
|
|
2192
|
+
time: fps[worstSpan.start + worstSpan.len - 1]?.time ?? "?"
|
|
2193
|
+
} : void 0;
|
|
2194
|
+
let pageLoads = records.length > 0 ? 1 : 0;
|
|
2195
|
+
let prevUp = -1;
|
|
2196
|
+
for (const r of records) {
|
|
2197
|
+
if (prevUp !== -1 && r.up + 2 < prevUp) pageLoads += 1;
|
|
2198
|
+
prevUp = r.up;
|
|
2199
|
+
}
|
|
2200
|
+
const first = records[0];
|
|
2201
|
+
const last = records[records.length - 1];
|
|
2202
|
+
const durationS = first && last && typeof first.wall === "number" && typeof last.wall === "number" ? (
|
|
2203
|
+
// +1s: the first bucket already covers one second of play
|
|
2204
|
+
Math.round((last.wall - first.wall + 1e3) / 1e3)
|
|
2205
|
+
) : Math.round(last?.up ?? 0);
|
|
2206
|
+
return {
|
|
2207
|
+
buckets: buckets.length,
|
|
2208
|
+
dips: spans.length,
|
|
2209
|
+
durationS,
|
|
2210
|
+
errors: records.filter((r) => r.type === "error" || r.type === "rejection").length,
|
|
2211
|
+
hiddenS: Math.round(hiddenS),
|
|
2212
|
+
meanFps: buckets.length > 0 ? Math.round(buckets.reduce((a, b) => a + b, 0) / buckets.length) : void 0,
|
|
2213
|
+
pageLoads,
|
|
2214
|
+
worst
|
|
2215
|
+
};
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
// src/telemetry/session.ts
|
|
2221
|
+
import { appendFileSync, mkdirSync as mkdirSync4, readdirSync as readdirSync2 } from "fs";
|
|
2222
|
+
import path6 from "path";
|
|
2223
|
+
var pad, sessionStamp, createSessionStore, latestSessionFile;
|
|
2224
|
+
var init_session = __esm({
|
|
2225
|
+
"src/telemetry/session.ts"() {
|
|
2226
|
+
"use strict";
|
|
2227
|
+
pad = (n) => String(n).padStart(2, "0");
|
|
2228
|
+
sessionStamp = (wall) => {
|
|
2229
|
+
const d = new Date(wall);
|
|
2230
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
2231
|
+
};
|
|
2232
|
+
createSessionStore = (gameDir, startedWall) => {
|
|
2233
|
+
const dir = path6.join(gameDir, ".frogoe", "sessions");
|
|
2234
|
+
let file;
|
|
2235
|
+
return {
|
|
2236
|
+
file: () => file,
|
|
2237
|
+
write: (records) => {
|
|
2238
|
+
if (records.length === 0) return;
|
|
2239
|
+
if (!file) {
|
|
2240
|
+
mkdirSync4(dir, { recursive: true });
|
|
2241
|
+
file = path6.join(dir, `${sessionStamp(startedWall)}.jsonl`);
|
|
2242
|
+
}
|
|
2243
|
+
appendFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
};
|
|
2247
|
+
latestSessionFile = (gameDir) => {
|
|
2248
|
+
const dir = path6.join(gameDir, ".frogoe", "sessions");
|
|
2249
|
+
try {
|
|
2250
|
+
const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
2251
|
+
return files.length > 0 ? path6.join(dir, files[files.length - 1] ?? "") : null;
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
// src/run.ts
|
|
2260
|
+
var run_exports = {};
|
|
2261
|
+
__export(run_exports, {
|
|
2262
|
+
startServer: () => startServer
|
|
2263
|
+
});
|
|
2264
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, statSync, watch } from "fs";
|
|
2265
|
+
import os2 from "os";
|
|
2266
|
+
import path7 from "path";
|
|
2267
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
2268
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
2269
|
+
import { Hono } from "hono";
|
|
2270
|
+
var buildDevScript, MIME2, startServer;
|
|
2271
|
+
var init_run = __esm({
|
|
2272
|
+
"src/run.ts"() {
|
|
2273
|
+
"use strict";
|
|
2274
|
+
init_ip();
|
|
2275
|
+
init_records();
|
|
2276
|
+
init_session();
|
|
2277
|
+
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>`;
|
|
2278
|
+
MIME2 = {
|
|
2279
|
+
css: "text/css; charset=utf-8",
|
|
2280
|
+
htm: "text/html; charset=utf-8",
|
|
2281
|
+
html: "text/html; charset=utf-8",
|
|
2282
|
+
ico: "image/x-icon",
|
|
2283
|
+
jpeg: "image/jpeg",
|
|
2284
|
+
js: "text/javascript; charset=utf-8",
|
|
2285
|
+
json: "application/json; charset=utf-8",
|
|
2286
|
+
mjs: "text/javascript; charset=utf-8",
|
|
2287
|
+
png: "image/png",
|
|
2288
|
+
svg: "image/svg+xml",
|
|
2289
|
+
txt: "text/plain; charset=utf-8",
|
|
2290
|
+
webp: "image/webp",
|
|
2291
|
+
woff2: "font/woff2"
|
|
2292
|
+
};
|
|
2293
|
+
startServer = async (dir, requestedPort = 0, telemetry) => {
|
|
2294
|
+
const root = path7.resolve(dir);
|
|
2295
|
+
if (!existsSync5(path7.join(root, "index.html"))) {
|
|
2296
|
+
throw new Error(`frogoe run: no index.html in ${root} \u2014 is this a game folder?`);
|
|
2297
|
+
}
|
|
2298
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2299
|
+
let version = 0;
|
|
2300
|
+
let remoteSeen = false;
|
|
2301
|
+
let lastRemote = "";
|
|
2302
|
+
const own = selfAddresses(os2.networkInterfaces());
|
|
2303
|
+
const session = telemetry ? createSessionStore(root, Date.now()) : void 0;
|
|
2304
|
+
const app = new Hono();
|
|
2305
|
+
app.use("*", async (c, next) => {
|
|
2306
|
+
try {
|
|
2307
|
+
const address2 = getConnInfo(c).remote.address;
|
|
2308
|
+
if (address2 && !isLoopback(address2) && !own.has(address2)) {
|
|
2309
|
+
remoteSeen = true;
|
|
2310
|
+
lastRemote = address2;
|
|
2311
|
+
}
|
|
2312
|
+
} catch {
|
|
2313
|
+
}
|
|
2314
|
+
await next();
|
|
2315
|
+
});
|
|
2316
|
+
app.get("/__frogoe/reload", (c) => {
|
|
2317
|
+
const stream = new ReadableStream({
|
|
2318
|
+
cancel() {
|
|
2319
|
+
},
|
|
2320
|
+
start(controller) {
|
|
2321
|
+
clients.add(controller);
|
|
2322
|
+
controller.enqueue(new TextEncoder().encode("retry: 3000\n\n"));
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
return c.body(stream, {
|
|
2326
|
+
headers: {
|
|
2327
|
+
"cache-control": "no-store",
|
|
2328
|
+
connection: "keep-alive",
|
|
2329
|
+
"content-type": "text/event-stream"
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
});
|
|
2333
|
+
app.get(
|
|
2334
|
+
"/__frogoe/version",
|
|
2335
|
+
(c) => c.text(String(version), 200, { "cache-control": "no-store" })
|
|
2336
|
+
);
|
|
2337
|
+
app.post("/__frogoe/metrics", async (c) => {
|
|
2338
|
+
try {
|
|
2339
|
+
const payload = JSON.parse(await c.req.text());
|
|
2340
|
+
if (!session) return c.body(null, 204);
|
|
2341
|
+
const lines = beaconToRecords(payload, Date.now());
|
|
2342
|
+
session.write(lines.map((l) => l.record));
|
|
2343
|
+
for (const line of lines) {
|
|
2344
|
+
if (line.text) telemetry?.onEvent?.(line.text);
|
|
2345
|
+
}
|
|
2346
|
+
return c.body(null, 204);
|
|
2347
|
+
} catch {
|
|
2348
|
+
return c.body(null, 400);
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
app.get("*", (c) => {
|
|
2352
|
+
const raw = decodeURIComponent(new URL(c.req.url).pathname);
|
|
2353
|
+
const safe = path7.normalize(raw).replaceAll("\\", "/");
|
|
2354
|
+
let file = path7.join(root, safe === "/" ? "index.html" : safe);
|
|
2355
|
+
if (!file.startsWith(root)) {
|
|
2356
|
+
return c.text("forbidden", 403);
|
|
2357
|
+
}
|
|
2358
|
+
if (existsSync5(file) && statSync(file).isDirectory()) {
|
|
2359
|
+
file = path7.join(file, "index.html");
|
|
2360
|
+
}
|
|
2361
|
+
if (!existsSync5(file)) {
|
|
2362
|
+
return c.text(`frogoe run: not found: ${raw}`, 404);
|
|
2363
|
+
}
|
|
2364
|
+
const body = readFileSync5(file);
|
|
2365
|
+
const ext = path7.extname(file).slice(1).toLowerCase();
|
|
2366
|
+
const type = MIME2[ext] ?? "application/octet-stream";
|
|
2367
|
+
if (ext === "html" || ext === "htm") {
|
|
2368
|
+
const html = body.toString("utf-8");
|
|
2369
|
+
const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
|
|
2370
|
+
return c.body(injected, 200, {
|
|
2371
|
+
"cache-control": "no-store",
|
|
2372
|
+
"content-type": type
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
return c.body(body, 200, { "content-type": type });
|
|
2376
|
+
});
|
|
2377
|
+
const server = createAdaptorServer({ fetch: app.fetch });
|
|
2378
|
+
await new Promise((resolve, reject) => {
|
|
2379
|
+
server.once("error", reject);
|
|
2380
|
+
server.listen(requestedPort, "0.0.0.0", () => resolve());
|
|
2381
|
+
});
|
|
2382
|
+
const address = server.address();
|
|
2383
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
2384
|
+
if (!port) {
|
|
2385
|
+
server.closeAllConnections?.();
|
|
2386
|
+
server.close();
|
|
2387
|
+
throw new Error("frogoe run: server failed to bind a port");
|
|
2388
|
+
}
|
|
2389
|
+
const local = `http://localhost:${port}`;
|
|
2390
|
+
const lanInfo = resolveLan();
|
|
2391
|
+
const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
|
|
2392
|
+
let timer;
|
|
2393
|
+
const watcher = watch(root, { recursive: true }, (_event, file) => {
|
|
2394
|
+
const first = file?.split(path7.sep)[0];
|
|
2395
|
+
if (first === "snapshots" || first === ".frogoe") {
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
clearTimeout(timer);
|
|
2399
|
+
timer = setTimeout(() => {
|
|
2400
|
+
version += 1;
|
|
2401
|
+
const payload = new TextEncoder().encode("data: reload\n\n");
|
|
2402
|
+
for (const client of clients) {
|
|
2403
|
+
try {
|
|
2404
|
+
client.enqueue(payload);
|
|
2405
|
+
} catch {
|
|
2406
|
+
clients.delete(client);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}, 100);
|
|
2410
|
+
});
|
|
2411
|
+
return {
|
|
2412
|
+
port,
|
|
2413
|
+
sawRemote: () => remoteSeen,
|
|
2414
|
+
remote: () => remoteSeen ? lastRemote : void 0,
|
|
2415
|
+
stop() {
|
|
2416
|
+
clearTimeout(timer);
|
|
2417
|
+
watcher.close();
|
|
2418
|
+
server.closeAllConnections?.();
|
|
2419
|
+
server.close();
|
|
2420
|
+
},
|
|
2421
|
+
urls: { lan, local }
|
|
2422
|
+
};
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
// src/live/index.ts
|
|
2428
|
+
var live_exports = {};
|
|
2429
|
+
__export(live_exports, {
|
|
2430
|
+
collectLive: () => collectLive
|
|
2431
|
+
});
|
|
2432
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
2433
|
+
import path8 from "path";
|
|
2434
|
+
var VIEWPORTS, browserPath, ensureBrowser, waitForServer, collectLive;
|
|
2435
|
+
var init_live = __esm({
|
|
2436
|
+
"src/live/index.ts"() {
|
|
2437
|
+
"use strict";
|
|
2438
|
+
init_driver();
|
|
2439
|
+
init_phases();
|
|
2440
|
+
VIEWPORTS = [
|
|
2441
|
+
{ height: 844, name: "mobile", width: 390 },
|
|
2442
|
+
{ height: 800, name: "desktop", width: 1280 }
|
|
2443
|
+
];
|
|
2444
|
+
ensureBrowser = async () => {
|
|
2445
|
+
if (browserPath) {
|
|
2446
|
+
return browserPath;
|
|
2447
|
+
}
|
|
2448
|
+
const { Browser, getInstalledBrowsers, install } = await import("@puppeteer/browsers");
|
|
2449
|
+
const cacheDir = path8.resolve(process.cwd(), "node_modules/.frogoe-browser");
|
|
2450
|
+
const installed = await getInstalledBrowsers({ cacheDir });
|
|
2451
|
+
const existing = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
|
|
2452
|
+
browserPath = existing?.executablePath ?? (await install({
|
|
2453
|
+
browser: Browser.CHROMEHEADLESSSHELL,
|
|
2454
|
+
buildId: "131.0.6778.204",
|
|
2455
|
+
cacheDir,
|
|
2456
|
+
unpack: true
|
|
2457
|
+
})).executablePath;
|
|
2458
|
+
return browserPath;
|
|
2459
|
+
};
|
|
2460
|
+
waitForServer = async (url) => {
|
|
2461
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2462
|
+
try {
|
|
2463
|
+
const res = await fetch(url, { method: "HEAD" });
|
|
2464
|
+
if (res.ok) {
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
} catch {
|
|
2468
|
+
}
|
|
2469
|
+
await sleep2(500);
|
|
2470
|
+
}
|
|
2471
|
+
};
|
|
2472
|
+
collectLive = async (options) => {
|
|
2473
|
+
const dir = path8.resolve(options.dir);
|
|
2474
|
+
const settle = options.settleMs ?? 2e3;
|
|
2475
|
+
const findings = [];
|
|
2476
|
+
const screenshots = [];
|
|
2477
|
+
const metrics = {
|
|
2478
|
+
lifecycle: { ends: false, retryReloads: 0 },
|
|
2479
|
+
playability: "no-input"
|
|
2480
|
+
};
|
|
2481
|
+
const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
|
|
2482
|
+
const server = await startServer2(dir);
|
|
2483
|
+
const snapshotDir = path8.join(dir, "snapshots");
|
|
2484
|
+
mkdirSync5(snapshotDir, { recursive: true });
|
|
2485
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
2486
|
+
const executablePath = await ensureBrowser();
|
|
2487
|
+
const browser = await puppeteer.launch({
|
|
2488
|
+
args: ["--no-sandbox", "--disable-gpu"],
|
|
2489
|
+
defaultViewport: null,
|
|
2490
|
+
executablePath,
|
|
2491
|
+
headless: true
|
|
2492
|
+
});
|
|
2493
|
+
try {
|
|
2494
|
+
let serverReady = false;
|
|
2495
|
+
for (const viewport of VIEWPORTS) {
|
|
2496
|
+
const runViewport = async (viewport2) => {
|
|
2497
|
+
const page = await browser.newPage();
|
|
2498
|
+
await page.setViewport({ height: viewport2.height, width: viewport2.width });
|
|
2499
|
+
const driver = createPuppeteerDriver({
|
|
2500
|
+
page,
|
|
2501
|
+
size: { height: viewport2.height, width: viewport2.width }
|
|
2502
|
+
});
|
|
2503
|
+
const shot = async (name) => {
|
|
2504
|
+
writeFileSync4(path8.join(snapshotDir, name), await driver.screenshot());
|
|
2505
|
+
screenshots.push(path8.join("snapshots", name));
|
|
2506
|
+
};
|
|
2507
|
+
if (!serverReady) {
|
|
2508
|
+
await waitForServer(server.urls.local);
|
|
2509
|
+
serverReady = true;
|
|
2510
|
+
}
|
|
2511
|
+
await page.goto(server.urls.local, { timeout: 15e3, waitUntil: "domcontentloaded" });
|
|
2512
|
+
await sleep2(settle);
|
|
2513
|
+
if (viewport2.name === "mobile") {
|
|
2514
|
+
const outcome = await runLifecycle(driver, {
|
|
2515
|
+
settleMs: settle,
|
|
2516
|
+
shot,
|
|
2517
|
+
viewport: viewport2
|
|
2518
|
+
});
|
|
2519
|
+
findings.push(...outcome.findings);
|
|
2520
|
+
metrics.playability = outcome.playability;
|
|
2521
|
+
metrics.lifecycle = outcome.lifecycle;
|
|
2522
|
+
if (outcome.mobileFps !== void 0) {
|
|
2523
|
+
metrics.mobileFps = outcome.mobileFps;
|
|
2524
|
+
}
|
|
2525
|
+
} else {
|
|
2526
|
+
const outcome = await runDesktopPass(driver, { shot, viewport: viewport2 });
|
|
2527
|
+
findings.push(...outcome.findings);
|
|
2528
|
+
if (outcome.fps !== void 0) {
|
|
2529
|
+
metrics.desktopFps = outcome.fps;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
await page.close();
|
|
2533
|
+
};
|
|
2534
|
+
try {
|
|
2535
|
+
await runViewport(viewport);
|
|
2536
|
+
} catch (error) {
|
|
2537
|
+
findings.push({
|
|
2538
|
+
code: "live/runtime-failure",
|
|
2539
|
+
file: "game.js",
|
|
2540
|
+
fix: `the live sandbox crashed on ${viewport.name}: ${String(error).slice(0, 140)}`,
|
|
2541
|
+
message: `live sandbox failure [${viewport.name}]`,
|
|
2542
|
+
phase: "boot",
|
|
2543
|
+
severity: "error"
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
} finally {
|
|
2548
|
+
await browser.close();
|
|
2549
|
+
server.stop();
|
|
2550
|
+
}
|
|
2551
|
+
return { findings, metrics, screenshots };
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
// src/commands/check.ts
|
|
2557
|
+
var check_exports = {};
|
|
2558
|
+
__export(check_exports, {
|
|
2559
|
+
command: () => command3
|
|
2560
|
+
});
|
|
2561
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
2562
|
+
var command3;
|
|
2563
|
+
var init_check3 = __esm({
|
|
2564
|
+
"src/commands/check.ts"() {
|
|
2565
|
+
"use strict";
|
|
2566
|
+
init_check2();
|
|
2567
|
+
command3 = defineCommand3({
|
|
2568
|
+
args: {
|
|
2569
|
+
dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
|
|
2570
|
+
json: { type: "boolean", description: "machine-readable findings" },
|
|
2571
|
+
live: { type: "boolean", description: "also run the headless-browser sandbox pass" }
|
|
2572
|
+
},
|
|
2573
|
+
async run({ args }) {
|
|
2574
|
+
const dir = args.dir ? String(args.dir) : process.cwd();
|
|
2575
|
+
const result = checkProject(dir);
|
|
2576
|
+
if (args.live) {
|
|
2577
|
+
const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
|
|
2578
|
+
console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
|
|
2579
|
+
const live = await collectLive2({ dir });
|
|
2580
|
+
result.findings = [...result.findings, ...live.findings].sort(
|
|
2581
|
+
(a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
|
|
2582
|
+
);
|
|
2583
|
+
result.errors = result.findings.filter((f) => f.severity === "error").length;
|
|
2584
|
+
result.warnings = result.findings.filter((f) => f.severity === "warning").length;
|
|
2585
|
+
if (live.screenshots.length > 0) {
|
|
2586
|
+
console.log(` snapshots: ${live.screenshots.join(", ")}`);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
if (args.json) {
|
|
2590
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2591
|
+
} else {
|
|
2592
|
+
console.log(formatFindings(result));
|
|
2593
|
+
console.log(`
|
|
2594
|
+
${result.errors} error(s), ${result.warnings} warning(s)`);
|
|
2595
|
+
}
|
|
2596
|
+
if (result.errors > 0) {
|
|
2597
|
+
process.exitCode = 1;
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
meta: { description: "contract lint with stable finding codes" }
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
// src/commands/init.ts
|
|
2606
|
+
var init_exports = {};
|
|
2607
|
+
__export(init_exports, {
|
|
2608
|
+
command: () => command4
|
|
2609
|
+
});
|
|
2610
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
2611
|
+
var command4;
|
|
2612
|
+
var init_init2 = __esm({
|
|
2613
|
+
"src/commands/init.ts"() {
|
|
2614
|
+
"use strict";
|
|
2615
|
+
init_init();
|
|
2616
|
+
command4 = defineCommand4({
|
|
2617
|
+
args: {
|
|
2618
|
+
force: { type: "boolean", description: "rematerialize over an existing game" },
|
|
2619
|
+
name: { type: "positional", description: "folder to create" }
|
|
2620
|
+
},
|
|
2621
|
+
async run({ args }) {
|
|
2622
|
+
const name = args.name ?? ".";
|
|
2623
|
+
const result = scaffold(String(name), { force: args.force === true });
|
|
2624
|
+
console.log(`frogoe init \u2014 ${result.dir}`);
|
|
2625
|
+
for (const file of result.files) {
|
|
2626
|
+
console.log(` + ${file}`);
|
|
2627
|
+
}
|
|
2628
|
+
console.log(`
|
|
2629
|
+
next: cd ${name} && frogoe run`);
|
|
2630
|
+
},
|
|
2631
|
+
meta: { description: "scaffold a runnable game folder" }
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
// src/commands/report.ts
|
|
2637
|
+
var report_exports = {};
|
|
2638
|
+
__export(report_exports, {
|
|
2639
|
+
command: () => command5
|
|
2640
|
+
});
|
|
2641
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
2642
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
2643
|
+
var command5;
|
|
2644
|
+
var init_report = __esm({
|
|
2645
|
+
"src/commands/report.ts"() {
|
|
2646
|
+
"use strict";
|
|
2647
|
+
init_records();
|
|
2648
|
+
init_session();
|
|
2649
|
+
command5 = defineCommand5({
|
|
2650
|
+
args: {
|
|
2651
|
+
dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
|
|
2652
|
+
},
|
|
2653
|
+
async run({ args }) {
|
|
2654
|
+
const dir = args.dir ? String(args.dir) : process.cwd();
|
|
2655
|
+
const file = latestSessionFile(dir);
|
|
2656
|
+
if (!file) {
|
|
2657
|
+
console.log(`frogoe report: no sessions in ${dir} \u2014 play a run under \`frogoe run\` first`);
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
const records = readFileSync6(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
|
|
2661
|
+
const s = summarizeRecords(records);
|
|
2662
|
+
console.log(`
|
|
2663
|
+
frogoe report \u2014 ${file}`);
|
|
2664
|
+
console.log(
|
|
2665
|
+
` playtest ${Math.floor(s.durationS / 60)}m ${s.durationS % 60}s \xB7 ${s.pageLoads} page load${s.pageLoads === 1 ? "" : "s"} \xB7 ${s.buckets} fps buckets`
|
|
2666
|
+
);
|
|
2667
|
+
if (s.meanFps !== void 0) {
|
|
2668
|
+
console.log(` fps mean ${s.meanFps} \xB7 dips < 30: ${s.dips}`);
|
|
2669
|
+
}
|
|
2670
|
+
if (s.worst) {
|
|
2671
|
+
console.log(` worst dip: ${s.worst.fps} fps for ${s.worst.len}s at ${s.worst.time}`);
|
|
2672
|
+
}
|
|
2673
|
+
console.log(` errors: ${s.errors} \xB7 hidden: ${s.hiddenS}s`);
|
|
2674
|
+
console.log("");
|
|
2675
|
+
},
|
|
2676
|
+
meta: { description: "summarize the last playtest session (fps dips, errors)" }
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
// src/net/firewall.ts
|
|
2682
|
+
import { execSync as execSync2 } from "child_process";
|
|
2683
|
+
var run, binaryBlocked, probeFirewall;
|
|
2684
|
+
var init_firewall = __esm({
|
|
2685
|
+
"src/net/firewall.ts"() {
|
|
2686
|
+
"use strict";
|
|
2687
|
+
run = (args) => {
|
|
2688
|
+
try {
|
|
2689
|
+
return execSync2(`/usr/libexec/ApplicationFirewall/socketfilterfw ${args}`, {
|
|
2690
|
+
encoding: "utf-8",
|
|
2691
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2692
|
+
timeout: 3e3
|
|
2693
|
+
});
|
|
2694
|
+
} catch {
|
|
2695
|
+
return null;
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
binaryBlocked = (path10) => {
|
|
2699
|
+
const out = run(`--getappblocked "${path10}"`);
|
|
2700
|
+
if (out === null) return null;
|
|
2701
|
+
if (/blocked/iu.test(out)) return true;
|
|
2702
|
+
if (/allowed/iu.test(out)) return false;
|
|
2703
|
+
return null;
|
|
2704
|
+
};
|
|
2705
|
+
probeFirewall = () => {
|
|
2706
|
+
if (process.platform !== "darwin") return "unknown";
|
|
2707
|
+
const globalState = run("--getglobalstate");
|
|
2708
|
+
if (globalState === null) return "unknown";
|
|
2709
|
+
const state = /firewall is (enabled|disabled)/iu.exec(globalState)?.[1]?.toLowerCase();
|
|
2710
|
+
if (state === "disabled") return "off";
|
|
2711
|
+
if (state !== "enabled") return "unknown";
|
|
2712
|
+
const blocked = binaryBlocked(process.execPath);
|
|
2713
|
+
if (blocked === null) return "unknown";
|
|
2714
|
+
return blocked ? "blocked" : "open";
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
// src/net/plan.ts
|
|
2720
|
+
var lanRisky, planStartup, planNudge;
|
|
2721
|
+
var init_plan = __esm({
|
|
2722
|
+
"src/net/plan.ts"() {
|
|
2723
|
+
"use strict";
|
|
2724
|
+
lanRisky = (lan) => lan.confidence === "none" || lan.virtual || lan.confidence === "heuristic" && lan.candidates.length > 1;
|
|
2725
|
+
planStartup = (lan, firewall, tunnelFlag) => {
|
|
2726
|
+
if (tunnelFlag) return { eagerTunnel: true, notes: [] };
|
|
2727
|
+
if (lan.confidence === "none") {
|
|
2728
|
+
return {
|
|
2729
|
+
eagerTunnel: true,
|
|
2730
|
+
notes: ["no lan address found \u2014 the tunnel is the only phone path"]
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
if (firewall === "blocked") {
|
|
2734
|
+
return {
|
|
2735
|
+
eagerTunnel: true,
|
|
2736
|
+
notes: [
|
|
2737
|
+
"macos firewall blocks incoming connections for this runtime \u2014 System Settings \u2192 Network \u2192 Firewall \u2192 Options \u2192 Allow, or rely on the tunnel"
|
|
2738
|
+
]
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
if (lanRisky(lan)) {
|
|
2742
|
+
return {
|
|
2743
|
+
eagerTunnel: true,
|
|
2744
|
+
notes: [
|
|
2745
|
+
"uncertain lan address (vpn or multiple networks) \u2014 if the phone can't load the lan QR, use the tunnel"
|
|
2746
|
+
]
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
return { eagerTunnel: false, notes: [] };
|
|
2750
|
+
};
|
|
2751
|
+
planNudge = (sawRemote, tunnelRunning) => sawRemote || tunnelRunning ? null : "hint";
|
|
2752
|
+
}
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
// src/net/tunnel.ts
|
|
2756
|
+
import { spawn, spawnSync } from "child_process";
|
|
2757
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, chmodSync, writeFileSync as writeFileSync5 } from "fs";
|
|
2758
|
+
import os3 from "os";
|
|
2759
|
+
import path9 from "path";
|
|
2760
|
+
import { gunzipSync } from "zlib";
|
|
2761
|
+
var URL_PATTERN, parseTunnelUrl, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
|
|
2762
|
+
var init_tunnel = __esm({
|
|
2763
|
+
"src/net/tunnel.ts"() {
|
|
2764
|
+
"use strict";
|
|
2765
|
+
URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/iu;
|
|
2766
|
+
parseTunnelUrl = (chunk) => URL_PATTERN.exec(chunk)?.[0];
|
|
2767
|
+
octalAt = (block, offset, length) => {
|
|
2768
|
+
const raw = block.subarray(offset, offset + length).toString("utf-8");
|
|
2769
|
+
const digits = raw.replace(/[\0 ]/gu, "");
|
|
2770
|
+
return digits.length === 0 ? 0 : Number.parseInt(digits, 8);
|
|
2771
|
+
};
|
|
2772
|
+
extractSingleFile = (tar, wanted) => {
|
|
2773
|
+
for (let offset = 0; offset + 512 <= tar.length; ) {
|
|
2774
|
+
const header = tar.subarray(offset, offset + 512);
|
|
2775
|
+
const name = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/u, "");
|
|
2776
|
+
const size = octalAt(header, 124, 12);
|
|
2777
|
+
const type = header.toString("utf-8").charCodeAt(156);
|
|
2778
|
+
const dataStart = offset + 512;
|
|
2779
|
+
const dataEnd = dataStart + size;
|
|
2780
|
+
if (dataEnd > tar.length) return null;
|
|
2781
|
+
if (name === wanted && (type === 48 || type === 0)) {
|
|
2782
|
+
return tar.subarray(dataStart, dataEnd);
|
|
2783
|
+
}
|
|
2784
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
2785
|
+
}
|
|
2786
|
+
return null;
|
|
2787
|
+
};
|
|
2788
|
+
ENV_PIN = "FROGOE_CLOUDFLARED_VERSION";
|
|
2789
|
+
assetName = (platform, arch) => {
|
|
2790
|
+
const a = arch === "arm64" ? "arm64" : arch === "x64" ? "amd64" : arch === "ia32" ? "386" : null;
|
|
2791
|
+
if (!a) return null;
|
|
2792
|
+
switch (platform) {
|
|
2793
|
+
case "darwin":
|
|
2794
|
+
return a === "386" ? null : `cloudflared-darwin-${a}.tgz`;
|
|
2795
|
+
case "linux":
|
|
2796
|
+
return `cloudflared-linux-${a}`;
|
|
2797
|
+
// bare binaries
|
|
2798
|
+
case "win32":
|
|
2799
|
+
return a === "arm64" ? null : `cloudflared-windows-${a}.exe`;
|
|
2800
|
+
// bare executables
|
|
2801
|
+
default:
|
|
2802
|
+
return null;
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
2806
|
+
cacheBase = (platform, env, home) => {
|
|
2807
|
+
if (platform === "darwin") return path9.join(home, "Library", "Caches");
|
|
2808
|
+
if (platform === "win32") return env.LOCALAPPDATA ?? path9.join(home, "AppData", "Local");
|
|
2809
|
+
return path9.join(home, ".cache");
|
|
2810
|
+
};
|
|
2811
|
+
resolveLatestTag = async () => {
|
|
2812
|
+
const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
|
|
2813
|
+
headers: { accept: "application/vnd.github+json" }
|
|
2814
|
+
});
|
|
2815
|
+
if (!res.ok) throw new Error(`github api ${res.status}`);
|
|
2816
|
+
const body = await res.json();
|
|
2817
|
+
if (!body.tag_name) throw new Error("github api returned no tag_name");
|
|
2818
|
+
return body.tag_name;
|
|
2819
|
+
};
|
|
2820
|
+
mb = (bytes) => (bytes / (1024 * 1024)).toFixed(0);
|
|
2821
|
+
downloadWithProgress = async (res, onChunk) => {
|
|
2822
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
2823
|
+
const reader = res.body?.getReader();
|
|
2824
|
+
if (!reader) return Buffer.from(await res.arrayBuffer());
|
|
2825
|
+
const chunks = [];
|
|
2826
|
+
let done = 0;
|
|
2827
|
+
let nextReport = 0;
|
|
2828
|
+
for (; ; ) {
|
|
2829
|
+
const step = await reader.read();
|
|
2830
|
+
if (step.done) break;
|
|
2831
|
+
chunks.push(Buffer.from(step.value));
|
|
2832
|
+
done += step.value.byteLength;
|
|
2833
|
+
if (done >= nextReport) {
|
|
2834
|
+
onChunk(done, total);
|
|
2835
|
+
nextReport = done + 5 * 1024 * 1024;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return Buffer.concat(chunks);
|
|
2839
|
+
};
|
|
2840
|
+
binaryEchoes = (bin, tag) => {
|
|
2841
|
+
try {
|
|
2842
|
+
const probe = spawnSync(bin, ["--version"], { encoding: "utf-8", timeout: 1e4 });
|
|
2843
|
+
const out = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
|
|
2844
|
+
return probe.status === 0 && out.includes(tag);
|
|
2845
|
+
} catch {
|
|
2846
|
+
return false;
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
resolveBinary = async (onProgress) => {
|
|
2850
|
+
const pathProbe = spawnSync("cloudflared", ["--version"], { stdio: "ignore", timeout: 5e3 });
|
|
2851
|
+
if (pathProbe.status === 0) return { downloaded: false, path: "cloudflared" };
|
|
2852
|
+
const platform = process.platform;
|
|
2853
|
+
const asset = assetName(platform, process.arch);
|
|
2854
|
+
if (!asset) {
|
|
2855
|
+
throw new Error(
|
|
2856
|
+
`cloudflared publishes no build for ${platform}/${process.arch} \u2014 install it and put \`cloudflared\` on PATH`
|
|
2857
|
+
);
|
|
2858
|
+
}
|
|
2859
|
+
const tag = process.env[ENV_PIN] ?? await resolveLatestTag();
|
|
2860
|
+
const root = path9.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
|
|
2861
|
+
const dir = path9.join(root, tag);
|
|
2862
|
+
const bin = path9.join(dir, binaryFileName(platform));
|
|
2863
|
+
if (existsSync6(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
|
|
2864
|
+
onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
|
|
2865
|
+
const res = await fetch(
|
|
2866
|
+
`https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
|
|
2867
|
+
);
|
|
2868
|
+
if (!res.ok) throw new Error(`cloudflared ${tag} download failed (${res.status})`);
|
|
2869
|
+
const raw = await downloadWithProgress(res, (done, total) => {
|
|
2870
|
+
onProgress?.(`downloading cloudflared ${tag} \u2014 ${mb(done)}${total ? `/${mb(total)}` : ""} MB`);
|
|
2871
|
+
});
|
|
2872
|
+
const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
|
|
2873
|
+
if (!binary) throw new Error("cloudflared archive did not contain the binary");
|
|
2874
|
+
mkdirSync6(dir, { recursive: true });
|
|
2875
|
+
writeFileSync5(bin, binary);
|
|
2876
|
+
if (platform !== "win32") chmodSync(bin, 493);
|
|
2877
|
+
if (!binaryEchoes(bin, tag)) {
|
|
2878
|
+
throw new Error(
|
|
2879
|
+
`cloudflared ${tag} failed its version check \u2014 deleted; set ${ENV_PIN} or install via brew`
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
return { downloaded: true, path: bin };
|
|
2883
|
+
};
|
|
2884
|
+
startTunnel = async (port, options) => {
|
|
2885
|
+
const timeoutMs = options?.timeoutMs ?? 2e4;
|
|
2886
|
+
const bin = await resolveBinary(options?.onProgress);
|
|
2887
|
+
return new Promise((resolve, reject) => {
|
|
2888
|
+
const child = spawn(
|
|
2889
|
+
bin.path,
|
|
2890
|
+
["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"],
|
|
2891
|
+
{
|
|
2892
|
+
detached: true,
|
|
2893
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2894
|
+
windowsHide: true
|
|
2895
|
+
}
|
|
2896
|
+
);
|
|
2897
|
+
let settled = false;
|
|
2898
|
+
let url;
|
|
2899
|
+
let buffer = "";
|
|
2900
|
+
const exited = new Promise((notify) => {
|
|
2901
|
+
child.once("exit", () => notify());
|
|
2902
|
+
});
|
|
2903
|
+
const killTree = () => {
|
|
2904
|
+
if (process.platform === "win32" || !child.pid) {
|
|
2905
|
+
child.kill("SIGTERM");
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
try {
|
|
2909
|
+
process.kill(-child.pid, "SIGTERM");
|
|
2910
|
+
} catch {
|
|
2911
|
+
child.kill("SIGTERM");
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
const finish = (error) => {
|
|
2915
|
+
if (settled) return;
|
|
2916
|
+
settled = true;
|
|
2917
|
+
clearTimeout(timer);
|
|
2918
|
+
if (error || !url) {
|
|
2919
|
+
killTree();
|
|
2920
|
+
const tail = buffer.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).slice(-2).join(" | ");
|
|
2921
|
+
const base = error?.message ?? "frogoe tunnel: cloudflared exited before producing a URL";
|
|
2922
|
+
reject(new Error(tail ? `${base} \u2014 ${tail}` : base));
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
resolve({ exited, stop: killTree, url });
|
|
2926
|
+
};
|
|
2927
|
+
const timer = setTimeout(() => {
|
|
2928
|
+
finish(
|
|
2929
|
+
new Error(
|
|
2930
|
+
`frogoe tunnel: no URL from cloudflared within ${timeoutMs / 1e3}s \u2014 check your internet, or brew install cloudflared`
|
|
2931
|
+
)
|
|
2932
|
+
);
|
|
2933
|
+
}, timeoutMs);
|
|
2934
|
+
child.once("exit", (code) => {
|
|
2935
|
+
if (!settled) {
|
|
2936
|
+
finish(new Error(`frogoe tunnel: cloudflared exited early (code ${code ?? "signal"})`));
|
|
2937
|
+
}
|
|
2938
|
+
});
|
|
2939
|
+
const watch2 = (stream) => {
|
|
2940
|
+
stream.on("data", (data) => {
|
|
2941
|
+
if (settled && url) return;
|
|
2942
|
+
buffer += data.toString("utf-8");
|
|
2943
|
+
const found = parseTunnelUrl(buffer);
|
|
2944
|
+
if (found && !url) {
|
|
2945
|
+
url = found;
|
|
2946
|
+
finish();
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
};
|
|
2950
|
+
if (child.stderr && child.stdout) {
|
|
2951
|
+
watch2(child.stderr);
|
|
2952
|
+
watch2(child.stdout);
|
|
2953
|
+
}
|
|
2954
|
+
});
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
// src/commands/run.ts
|
|
2960
|
+
var run_exports2 = {};
|
|
2961
|
+
__export(run_exports2, {
|
|
2962
|
+
command: () => command6
|
|
2963
|
+
});
|
|
2964
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
2965
|
+
var NUDGE_MS, printQr, message, command6;
|
|
2966
|
+
var init_run2 = __esm({
|
|
2967
|
+
"src/commands/run.ts"() {
|
|
2968
|
+
"use strict";
|
|
2969
|
+
init_firewall();
|
|
2970
|
+
init_ip();
|
|
2971
|
+
init_plan();
|
|
2972
|
+
init_tunnel();
|
|
2973
|
+
init_run();
|
|
2974
|
+
NUDGE_MS = 1e4;
|
|
2975
|
+
printQr = (url) => {
|
|
2976
|
+
void import("qrcode-terminal").then((mod) => {
|
|
2977
|
+
const qrcode = mod.default ?? mod;
|
|
2978
|
+
qrcode.generate(url, { small: true }, (qr) => console.log(qr));
|
|
2979
|
+
}).catch(() => {
|
|
2980
|
+
console.log(` (qr unavailable \u2014 open ${url} directly)`);
|
|
2981
|
+
});
|
|
2982
|
+
};
|
|
2983
|
+
message = (error) => error instanceof Error ? error.message : String(error);
|
|
2984
|
+
command6 = defineCommand6({
|
|
2985
|
+
args: {
|
|
2986
|
+
dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
|
|
2987
|
+
port: { type: "string", description: "port (default: random free)" },
|
|
2988
|
+
tunnel: {
|
|
2989
|
+
type: "boolean",
|
|
2990
|
+
description: "serve through a public cloudflared quick tunnel (phone on any network)"
|
|
2991
|
+
}
|
|
2992
|
+
},
|
|
2993
|
+
async run({ args }) {
|
|
2994
|
+
const dir = args.dir ? String(args.dir) : process.cwd();
|
|
2995
|
+
const port = args.port ? Number(args.port) : 0;
|
|
2996
|
+
if (!Number.isInteger(port) || port < 0) {
|
|
2997
|
+
throw new Error(`frogoe run: invalid port "${String(args.port)}"`);
|
|
2998
|
+
}
|
|
2999
|
+
const server = await startServer(dir, port, {
|
|
3000
|
+
onEvent: (text) => console.log(` ${text}`)
|
|
3001
|
+
});
|
|
3002
|
+
const lan = resolveLan();
|
|
3003
|
+
const lanUrl = server.urls.lan;
|
|
3004
|
+
const lanForBanner = args.tunnel === true ? void 0 : lanUrl;
|
|
3005
|
+
const plan = planStartup(lan, probeFirewall(), args.tunnel === true);
|
|
3006
|
+
console.log(`
|
|
3007
|
+
frogoe run \u2014 serving game`);
|
|
3008
|
+
console.log(` local ${server.urls.local}`);
|
|
3009
|
+
if (lanForBanner) {
|
|
3010
|
+
console.log(
|
|
3011
|
+
` lan ${lanForBanner} (phone: same wifi \u2014 safe-area only exists on real devices)`
|
|
3012
|
+
);
|
|
3013
|
+
printQr(lanForBanner);
|
|
3014
|
+
}
|
|
3015
|
+
for (const note of plan.notes) {
|
|
3016
|
+
console.log(` ! ${note}`);
|
|
3017
|
+
}
|
|
3018
|
+
console.log(` reload on file change \u2014 ctrl+c to stop
|
|
3019
|
+
`);
|
|
3020
|
+
let tunnel;
|
|
3021
|
+
let stopping = false;
|
|
3022
|
+
const bringUpTunnel = async (banner) => {
|
|
3023
|
+
if (tunnel || stopping) return;
|
|
3024
|
+
console.log(`
|
|
3025
|
+
${banner}`);
|
|
3026
|
+
try {
|
|
3027
|
+
tunnel = await startTunnel(server.port, {
|
|
3028
|
+
onProgress: (line) => console.log(` ${line}`)
|
|
3029
|
+
});
|
|
3030
|
+
console.log(` tunnel ${tunnel.url} (phone: any network \u2014 reload \u22642s)`);
|
|
3031
|
+
printQr(tunnel.url);
|
|
3032
|
+
void tunnel.exited.then(() => {
|
|
3033
|
+
if (!stopping) console.log("\n tunnel disconnected \u2014 lan/local still serving");
|
|
3034
|
+
});
|
|
3035
|
+
} catch (error) {
|
|
3036
|
+
tunnel = void 0;
|
|
3037
|
+
console.log(` tunnel unavailable: ${message(error)}`);
|
|
3038
|
+
if (!lanUrl) {
|
|
3039
|
+
console.log(
|
|
3040
|
+
" no lan address and no tunnel \u2014 the page is only reachable on this machine"
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
if (plan.eagerTunnel) {
|
|
3046
|
+
await bringUpTunnel(
|
|
3047
|
+
args.tunnel === true ? "tunnel: starting\u2026 (first run may download cloudflared)" : "tunnel: starting as a fallback for this network\u2026"
|
|
3048
|
+
);
|
|
3049
|
+
}
|
|
3050
|
+
const tunnelIntent = args.tunnel === true || plan.eagerTunnel;
|
|
3051
|
+
const nudge = setTimeout(() => {
|
|
3052
|
+
if (planNudge(server.sawRemote(), tunnel !== void 0 || tunnelIntent) === "hint") {
|
|
3053
|
+
console.log(
|
|
3054
|
+
"\n no phone has connected yet \u2014 if the lan QR fails on the phone, restart with: frogoe run --tunnel"
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
}, NUDGE_MS);
|
|
3058
|
+
const connectWatch = setInterval(() => {
|
|
3059
|
+
if (!server.sawRemote()) return;
|
|
3060
|
+
clearInterval(connectWatch);
|
|
3061
|
+
const who = server.remote();
|
|
3062
|
+
console.log(`
|
|
3063
|
+
phone connected${who ? ` \u2014 ${who}` : ""} (live reload active)`);
|
|
3064
|
+
}, 1e3);
|
|
3065
|
+
const shutdown = () => {
|
|
3066
|
+
if (stopping) return;
|
|
3067
|
+
stopping = true;
|
|
3068
|
+
clearTimeout(nudge);
|
|
3069
|
+
clearInterval(connectWatch);
|
|
3070
|
+
tunnel?.stop();
|
|
3071
|
+
server.stop();
|
|
3072
|
+
process.exit(0);
|
|
3073
|
+
};
|
|
3074
|
+
process.once("SIGINT", shutdown);
|
|
3075
|
+
process.once("SIGTERM", shutdown);
|
|
3076
|
+
await new Promise(() => {
|
|
3077
|
+
});
|
|
3078
|
+
},
|
|
3079
|
+
meta: { description: "serve with live reload + phone QR" }
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
// src/cli.ts
|
|
3085
|
+
import { defineCommand as defineCommand7, runMain } from "citty";
|
|
3086
|
+
|
|
3087
|
+
// src/version.ts
|
|
3088
|
+
var VERSION = "0.1.0";
|
|
3089
|
+
|
|
3090
|
+
// src/cli.ts
|
|
3091
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
3092
|
+
stream.on?.("error", (error) => {
|
|
3093
|
+
if (error.code === "EPIPE") {
|
|
3094
|
+
process.exit(0);
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
3099
|
+
console.log(VERSION);
|
|
3100
|
+
process.exit(0);
|
|
3101
|
+
}
|
|
3102
|
+
var HELP = `frogoe ${VERSION} \u2014 write a closure, ship a game
|
|
3103
|
+
|
|
3104
|
+
Commands:
|
|
3105
|
+
init [name] scaffold a runnable game folder
|
|
3106
|
+
add <block> copy a registry HUD block into blocks/
|
|
3107
|
+
run [dir] serve with live reload + phone QR (--tunnel: any network)
|
|
3108
|
+
check [dir] contract lint (stable finding codes; --json)
|
|
3109
|
+
report [dir] last playtest session: fps dips, errors, when
|
|
3110
|
+
bundle [dir] dissolve externals \u2192 one self-contained HTML
|
|
3111
|
+
|
|
3112
|
+
Docs: skills/frogoe-core \u2014 the whole contract in five references.`;
|
|
3113
|
+
var main = defineCommand7({
|
|
3114
|
+
meta: { description: HELP },
|
|
3115
|
+
subCommands: {
|
|
3116
|
+
add: () => Promise.resolve().then(() => (init_add2(), add_exports)).then((m) => m.command),
|
|
3117
|
+
bundle: () => Promise.resolve().then(() => (init_bundle2(), bundle_exports)).then((m) => m.command),
|
|
3118
|
+
check: () => Promise.resolve().then(() => (init_check3(), check_exports)).then((m) => m.command),
|
|
3119
|
+
init: () => Promise.resolve().then(() => (init_init2(), init_exports)).then((m) => m.command),
|
|
3120
|
+
report: () => Promise.resolve().then(() => (init_report(), report_exports)).then((m) => m.command),
|
|
3121
|
+
run: () => Promise.resolve().then(() => (init_run2(), run_exports2)).then((m) => m.command)
|
|
3122
|
+
}
|
|
3123
|
+
});
|
|
3124
|
+
await runMain(main);
|