castle-web-cli 0.4.78 → 0.4.79

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.
Files changed (46) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/native/loop.d.ts +2 -0
  6. package/dist/native/loop.js +698 -0
  7. package/dist/native/openrouter.d.ts +55 -0
  8. package/dist/native/openrouter.js +354 -0
  9. package/dist/native/playtest-browser.d.ts +34 -0
  10. package/dist/native/playtest-browser.js +354 -0
  11. package/dist/native/playtest-executor.d.ts +3 -0
  12. package/dist/native/playtest-executor.js +156 -0
  13. package/dist/native/playtest.d.ts +131 -0
  14. package/dist/native/playtest.js +314 -0
  15. package/dist/native/tools.d.ts +38 -0
  16. package/dist/native/tools.js +630 -0
  17. package/dist/native/types.d.ts +40 -0
  18. package/dist/native/types.js +41 -0
  19. package/dist/serve.js +12 -0
  20. package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
  21. package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
  22. package/dist/shell/index.html +2 -2
  23. package/kits/basic-2d/CLAUDE.md +29 -3
  24. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  25. package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
  26. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  27. package/kits/basic-2d/castle.json +5 -7
  28. package/kits/basic-2d/docs/pxart-format.md +4 -3
  29. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  30. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  31. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  32. package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
  33. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  34. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  35. package/kits/basic-2d/editors/editorHistory.js +8 -2
  36. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  37. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  38. package/kits/basic-2d/engine/blueprint.js +423 -0
  39. package/kits/basic-2d/engine/files.js +1 -1
  40. package/kits/basic-2d/engine/scene.js +29 -29
  41. package/kits/basic-2d/engine/ui.jsx +160 -21
  42. package/kits/basic-2d/engine/ui.module.css +155 -13
  43. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  44. package/kits/basic-2d/scenes/main.scene +3 -13
  45. package/package.json +2 -1
  46. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -0,0 +1,354 @@
1
+ // Owns the ONE warm Chromium instance a serve uses for `playtest` calls.
2
+ // Lazily launched on first use, closed after PLAYTEST_IDLE_SHUTDOWN_MS with
3
+ // no in-flight call, and closed for good on serve shutdown. A browser that
4
+ // died (crashed mid-call, or the OS reaped it) is simply relaunched the NEXT
5
+ // time `withBrowser` runs -- the call that discovered it dead already failed
6
+ // with its own capture-failure error (see playtest-executor.ts's try/catch);
7
+ // this file never retries WITHIN a call.
8
+ //
9
+ // The only file in native/** that imports the `playwright-core` package, and
10
+ // it does so with a dynamic `import()` inside loadPlaywright() -- never a
11
+ // top-level import -- so requiring this module (transitively, via agent.ts)
12
+ // costs nothing and cannot crash a serve that never calls playtest, even if
13
+ // the package isn't installed at all. A missing package surfaces as a normal
14
+ // tool-error string instead.
15
+ //
16
+ // Browser binaries are installed ON DEMAND, not at npm-install time: the dep
17
+ // is playwright-core (identical API to `playwright`, same pinned Chromium
18
+ // build per version, but NO postinstall browser download), and the first
19
+ // call that finds the pinned build missing from the cache spawns playwright-
20
+ // core's own install CLI inline, behind a module-level single-flight promise
21
+ // every concurrent caller (and the task-start prewarm) shares. Detection
22
+ // idiom, verified against playwright-core 1.61: chromium.executablePath()
23
+ // does NOT throw when the browser is missing -- it returns the path the
24
+ // registry WOULD use (honoring PLAYWRIGHT_BROWSERS_PATH), so existence is an
25
+ // fs.existsSync check on that path; the try/catch is belt-and-suspenders for
26
+ // versions where it throws instead. The install wait is deliberately not
27
+ // tied to any caller's AbortSignal or timeout budget: the download is shared
28
+ // state, and a per-call session timeout only applies once the session
29
+ // actually starts (after install).
30
+ import * as fs from "fs";
31
+ import * as path from "path";
32
+ import { createRequire } from "module";
33
+ import { spawn } from "child_process";
34
+ const PLAYTEST_IDLE_SHUTDOWN_MS = 2 * 60_000;
35
+ // The background-throttling-disabling family: without these, a headless
36
+ // Chromium can throttle/suspend requestAnimationFrame on a page it considers
37
+ // backgrounded (there is no real OS window/tab focus in headless mode), which
38
+ // would starve the very rAF-tick diagnostic playtest relies on to tell "the
39
+ // capture wasn't rendering" apart from "the game is genuinely frozen".
40
+ const LAUNCH_ARGS = [
41
+ "--disable-background-timer-throttling",
42
+ "--disable-backgrounding-occluded-windows",
43
+ "--disable-renderer-backgrounding",
44
+ ];
45
+ const MISSING_PLAYWRIGHT_MESSAGE = "playwright-core is not installed in this CLI (run `npm install` from the repo root).";
46
+ const MANUAL_INSTALL_HINT = "install manually with `npx playwright install chromium`";
47
+ export const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
48
+ export const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
49
+ // Progress labels are throttled to 20-point steps: every onProgress string
50
+ // becomes a task-feed ROW (see runTaskAgentIn's onActivity wiring in
51
+ // agent.ts), so per-percent updates would flood the feed with ~100 rows.
52
+ const PROGRESS_STEP = 20;
53
+ function launchFailureMessage(err) {
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ const looksLikeMissingBrowser = /executable doesn't exist|download.*chromium/i.test(message);
56
+ return looksLikeMissingBrowser
57
+ ? `chromium is not installed for playwright -- ${MANUAL_INSTALL_HINT}. (${message})`
58
+ : `could not launch chromium: ${message}`;
59
+ }
60
+ async function loadPlaywrightReal() {
61
+ return (await import("playwright-core"));
62
+ }
63
+ // playwright-core's exports map does NOT expose ./cli.js (verified against
64
+ // 1.61: resolving it throws ERR_PACKAGE_PATH_NOT_EXPORTED), but it DOES
65
+ // export ./package.json -- so resolve that and join cli.js beside it.
66
+ function resolveInstallCli() {
67
+ const req = createRequire(import.meta.url);
68
+ try {
69
+ return req.resolve("playwright-core/cli.js");
70
+ }
71
+ catch {
72
+ /* not exported -- expected; fall through */
73
+ }
74
+ try {
75
+ return path.join(path.dirname(req.resolve("playwright-core/package.json")), "cli.js");
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function runInstallReal(onLine) {
82
+ return new Promise((resolve, reject) => {
83
+ const cliPath = resolveInstallCli();
84
+ if (!cliPath) {
85
+ reject(new Error(MISSING_PLAYWRIGHT_MESSAGE));
86
+ return;
87
+ }
88
+ // Inherit env so PLAYWRIGHT_BROWSERS_PATH (and proxy settings) apply --
89
+ // the install lands in the same cache executablePath() reads from.
90
+ const child = spawn(process.execPath, [cliPath, "install", "chromium"], {
91
+ env: process.env,
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ });
94
+ let buf = "";
95
+ const onData = (chunk) => {
96
+ buf += chunk.toString("utf8");
97
+ let nl = buf.search(/[\r\n]/);
98
+ while (nl >= 0) {
99
+ const line = buf.slice(0, nl);
100
+ buf = buf.slice(nl + 1);
101
+ if (line.trim())
102
+ onLine(line);
103
+ nl = buf.search(/[\r\n]/);
104
+ }
105
+ };
106
+ child.stdout.on("data", onData);
107
+ child.stderr.on("data", onData);
108
+ child.on("error", (err) => reject(err));
109
+ child.on("exit", (code, signal) => {
110
+ if (code === 0)
111
+ resolve();
112
+ else
113
+ reject(new Error(`install CLI exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
114
+ });
115
+ });
116
+ }
117
+ // MODULE-level (not per-manager): one Chromium download per process, no
118
+ // matter how many managers/serves/prewarms race for it. Reset to null when
119
+ // the flight settles -- on success the fs existence check gates any future
120
+ // call, and on failure a later call must be free to retry rather than
121
+ // forever re-awaiting a poisoned promise.
122
+ let installFlight = null;
123
+ // Fires exactly once per install (module-level, not per caller/waiter) --
124
+ // the serve's stdout/stderr otherwise never mentions a prewarm-initiated
125
+ // install at all, since prewarm() passes no hooks and no caller is watching
126
+ // the per-run transcript. Bracket-prefix style matches the "[agent usage]"
127
+ // lines agent.ts already prints for process-level telemetry.
128
+ function logInstallStart() {
129
+ console.error("[playtest] downloading browser (~250MB compressed, one-time)...");
130
+ }
131
+ function logInstallFinished(elapsedMs, cachePath) {
132
+ const elapsedS = (elapsedMs / 1000).toFixed(1);
133
+ console.error(`[playtest] browser installed in ${elapsedS}s -> ${cachePath ?? "(cache path unknown)"}`);
134
+ }
135
+ function logInstallFailed(elapsedMs, err) {
136
+ const elapsedS = (elapsedMs / 1000).toFixed(1);
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ console.error(`[playtest] browser install failed after ${elapsedS}s: ${message}`);
139
+ }
140
+ function startInstallFlight(runInstall, getCachePath) {
141
+ const startedAt = Date.now();
142
+ logInstallStart();
143
+ const flight = {
144
+ progressListeners: new Set(),
145
+ promise: Promise.resolve()
146
+ .then(() => {
147
+ let lastStep = -1;
148
+ return runInstall((line) => {
149
+ // The install CLI prints progress bars like
150
+ // "|■■■■ | 43% of 129.7 MiB" -- surface the percent, throttled
151
+ // to PROGRESS_STEP boundaries so the feed gets a handful of rows,
152
+ // not a hundred.
153
+ const m = /(\d{1,3})%/.exec(line);
154
+ if (!m)
155
+ return;
156
+ const pct = Math.min(100, parseInt(m[1], 10));
157
+ const step = Math.floor(pct / PROGRESS_STEP);
158
+ if (step <= lastStep)
159
+ return;
160
+ lastStep = step;
161
+ for (const listener of flight.progressListeners) {
162
+ listener(`${INSTALL_START_LABEL} ${pct}%`);
163
+ }
164
+ });
165
+ })
166
+ .then(() => {
167
+ logInstallFinished(Date.now() - startedAt, getCachePath());
168
+ return { ok: true };
169
+ }, (err) => {
170
+ logInstallFailed(Date.now() - startedAt, err);
171
+ return {
172
+ ok: false,
173
+ error: `playtest browser download failed: ${err instanceof Error ? err.message : String(err)}. If this persists (offline, proxy), ${MANUAL_INSTALL_HINT}.`,
174
+ };
175
+ })
176
+ .finally(() => {
177
+ installFlight = null;
178
+ }),
179
+ };
180
+ return flight;
181
+ }
182
+ // Awaits (joining or starting) the shared install, reporting this CALLER's
183
+ // perspective through its own hooks: the initiator sees the download label,
184
+ // joiners see the shared-wait label, and both get their own started/
185
+ // finished/failed transcript events with THEIR elapsed wait (which is what
186
+ // the digest's "first-run download added ~Ns" note wants).
187
+ async function ensureInstalled(runInstall, hooks, getCachePath) {
188
+ const startedAt = Date.now();
189
+ const shared = installFlight !== null;
190
+ const flight = installFlight ?? (installFlight = startInstallFlight(runInstall, getCachePath));
191
+ hooks?.onProgress?.(shared ? INSTALL_WAIT_LABEL : INSTALL_START_LABEL);
192
+ hooks?.onInstallEvent?.({ phase: "started", shared });
193
+ const listener = (label) => hooks?.onProgress?.(label);
194
+ flight.progressListeners.add(listener);
195
+ try {
196
+ const result = await flight.promise;
197
+ const elapsedMs = Date.now() - startedAt;
198
+ if (!result.ok) {
199
+ hooks?.onInstallEvent?.({ phase: "failed", elapsedMs, error: result.error });
200
+ return result;
201
+ }
202
+ hooks?.onInstallEvent?.({ phase: "finished", elapsedMs, cachePath: getCachePath() });
203
+ return { ok: true, waitedMs: elapsedMs };
204
+ }
205
+ finally {
206
+ flight.progressListeners.delete(listener);
207
+ }
208
+ }
209
+ export function createPlaytestBrowserManager(seams) {
210
+ const loadPlaywright = seams?.loadPlaywright ?? loadPlaywrightReal;
211
+ const runInstall = seams?.runInstall ?? runInstallReal;
212
+ let browser = null;
213
+ let idleTimer = null;
214
+ let launching = null;
215
+ function clearIdleTimer() {
216
+ if (idleTimer) {
217
+ clearTimeout(idleTimer);
218
+ idleTimer = null;
219
+ }
220
+ }
221
+ function armIdleTimer() {
222
+ clearIdleTimer();
223
+ idleTimer = setTimeout(() => {
224
+ const dying = browser;
225
+ browser = null;
226
+ if (dying)
227
+ void dying.close().catch(() => undefined);
228
+ }, PLAYTEST_IDLE_SHUTDOWN_MS);
229
+ // This is pure housekeeping (closes an idle browser eventually) -- it
230
+ // must never be the thing keeping the process alive on its own, e.g. a
231
+ // one-shot script that calls withBrowser once and then expects to exit
232
+ // as soon as its own work is done, or a test harness that never calls
233
+ // shutdown().
234
+ idleTimer.unref();
235
+ }
236
+ // Missing-browser detection -- see the module header for the verified
237
+ // executablePath() semantics this leans on.
238
+ function missingExecutable(chromium) {
239
+ try {
240
+ return !fs.existsSync(chromium.executablePath());
241
+ }
242
+ catch {
243
+ return true;
244
+ }
245
+ }
246
+ async function launchBrowser(playwright) {
247
+ try {
248
+ const launched = await playwright.chromium.launch({ args: LAUNCH_ARGS });
249
+ launched.on("disconnected", () => {
250
+ if (browser === launched)
251
+ browser = null;
252
+ });
253
+ browser = launched;
254
+ return { ok: true, browser: launched };
255
+ }
256
+ catch (err) {
257
+ return { ok: false, error: launchFailureMessage(err) };
258
+ }
259
+ }
260
+ // Two DIFFERENT single-flights, deliberately layered: the install check
261
+ // runs per caller (so every concurrent caller passes its OWN hooks through
262
+ // ensureInstalled and gets its own labels/transcript events -- the shared
263
+ // thing is only the module-level download promise), while the launch that
264
+ // follows is manager-level single-flighted the old way (sub-second, no
265
+ // surfacing needed, must not spawn N Chromiums for N concurrent callers).
266
+ async function ensureBrowser(hooks) {
267
+ if (browser?.isConnected())
268
+ return { ok: true, browser };
269
+ browser = null;
270
+ let playwright;
271
+ try {
272
+ playwright = await loadPlaywright();
273
+ }
274
+ catch {
275
+ return { ok: false, error: MISSING_PLAYWRIGHT_MESSAGE };
276
+ }
277
+ let installedMs;
278
+ if (missingExecutable(playwright.chromium)) {
279
+ // Reported in the transcript's `finished` event: the concrete
280
+ // executable the registry resolved post-install (honors
281
+ // PLAYWRIGHT_BROWSERS_PATH), or undefined if it still can't say.
282
+ const getCachePath = () => {
283
+ try {
284
+ return playwright.chromium.executablePath();
285
+ }
286
+ catch {
287
+ return undefined;
288
+ }
289
+ };
290
+ const installed = await ensureInstalled(runInstall, hooks, getCachePath);
291
+ if (!installed.ok)
292
+ return installed;
293
+ installedMs = installed.waitedMs;
294
+ }
295
+ // Another caller may have finished launching while this one waited on
296
+ // the download; reuse its browser rather than racing a second launch.
297
+ // (Cast: TS's flow analysis still sees the `browser = null` from before
298
+ // the awaits -- it can't know the install/launch awaits let other
299
+ // callers reassign it -- and would otherwise narrow this read to null.)
300
+ const existing = browser;
301
+ if (existing?.isConnected())
302
+ return { ok: true, browser: existing, installedMs };
303
+ if (!launching) {
304
+ launching = launchBrowser(playwright).finally(() => {
305
+ launching = null;
306
+ });
307
+ }
308
+ const launched = await launching;
309
+ if (!launched.ok)
310
+ return launched;
311
+ return { ok: true, browser: launched.browser, installedMs };
312
+ }
313
+ return {
314
+ async withBrowser(fn, hooks) {
315
+ clearIdleTimer();
316
+ try {
317
+ const acquired = await ensureBrowser(hooks);
318
+ if (!acquired.ok)
319
+ return acquired;
320
+ try {
321
+ const value = await fn(acquired.browser);
322
+ return { ok: true, value, installedMs: acquired.installedMs };
323
+ }
324
+ catch (err) {
325
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
326
+ }
327
+ }
328
+ finally {
329
+ armIdleTimer();
330
+ }
331
+ },
332
+ prewarm() {
333
+ void (async () => {
334
+ try {
335
+ const playwright = await loadPlaywright();
336
+ if (missingExecutable(playwright.chromium)) {
337
+ await ensureInstalled(runInstall, undefined, () => undefined);
338
+ }
339
+ }
340
+ catch {
341
+ /* package missing / install failed: degrade silently to the lazy
342
+ path, which re-detects and surfaces a proper tool error */
343
+ }
344
+ })();
345
+ },
346
+ async shutdown() {
347
+ clearIdleTimer();
348
+ const dying = browser;
349
+ browser = null;
350
+ if (dying)
351
+ await dying.close().catch(() => undefined);
352
+ },
353
+ };
354
+ }
@@ -0,0 +1,3 @@
1
+ import type { PlaytestBrowserManager } from "./playtest-browser.js";
2
+ import { type PlaytestExecutor } from "./playtest.js";
3
+ export declare function createPlaywrightPlaytestExecutor(browserManager: PlaytestBrowserManager): PlaytestExecutor;
@@ -0,0 +1,156 @@
1
+ // The real (Playwright-backed) PlaytestExecutor: navigates a fresh
2
+ // browser context + page to the served deck, settles, replays a timeline of
3
+ // mouse/keyboard primitives and screenshot captures on one wall clock, and
4
+ // reports console/pageerror output plus rAF-tick capture-health diagnostics.
5
+ // See native/playtest.ts for the browser-free types this implements and
6
+ // native/playtest-browser.ts for the warm-Chromium lifecycle this borrows a
7
+ // browser from. Only type-only Playwright imports here (erased at compile
8
+ // time) -- the actual `playwright-core` package is loaded lazily by
9
+ // playtest-browser.ts, never by this file directly.
10
+ import { PLAYTEST_SETTLE_MS, PLAYTEST_VIEWPORT, } from "./playtest.js";
11
+ const NAV_TIMEOUT_MS = 20_000;
12
+ export function createPlaywrightPlaytestExecutor(browserManager) {
13
+ return {
14
+ async runSession(input) {
15
+ const outcome = await browserManager.withBrowser((browser) => runSessionOnBrowser(browser, input), {
16
+ onProgress: input.onProgress,
17
+ onInstallEvent: input.onInstallEvent,
18
+ });
19
+ if (outcome.ok) {
20
+ // Surface how long THIS call sat waiting on the one-time browser
21
+ // download (if it did) -- buildDigest turns it into a "first-run
22
+ // download added ~Ns" note.
23
+ if (outcome.installedMs && outcome.installedMs > 0) {
24
+ return { ...outcome.value, browserInstallMs: outcome.installedMs };
25
+ }
26
+ return outcome.value;
27
+ }
28
+ return emptyResult(outcome.error, input);
29
+ },
30
+ };
31
+ }
32
+ function emptyResult(error, input) {
33
+ return {
34
+ ok: false,
35
+ error,
36
+ viewport: PLAYTEST_VIEWPORT,
37
+ frames: [],
38
+ console: [],
39
+ diagnostics: {
40
+ rafTicks: 0,
41
+ wallMs: 0,
42
+ framesRequested: input.timeline.filter((e) => e.kind === "screenshot").length,
43
+ framesCaptured: 0,
44
+ hidden: false,
45
+ },
46
+ };
47
+ }
48
+ function phaseFor(relMs, b) {
49
+ if (b.settleStartRel === null || relMs < b.settleStartRel)
50
+ return "load";
51
+ if (b.sessionStartRel === null || relMs < b.sessionStartRel)
52
+ return "settle";
53
+ return "session";
54
+ }
55
+ function attachConsoleCapture(page, navStart, boundaries) {
56
+ const log = [];
57
+ const push = (level, text) => {
58
+ const relMs = Date.now() - navStart;
59
+ log.push({ t: relMs, phase: phaseFor(relMs, boundaries), level, text });
60
+ };
61
+ page.on("console", (msg) => {
62
+ const type = msg.type();
63
+ push(type === "warning" ? "warn" : type === "error" ? "error" : "log", msg.text());
64
+ });
65
+ page.on("pageerror", (err) => push("pageerror", err.message));
66
+ return log;
67
+ }
68
+ async function runTimeline(page, input) {
69
+ const sessionStart = Date.now();
70
+ const frames = [];
71
+ let framesRequested = 0;
72
+ for (const event of input.timeline) {
73
+ if (input.signal?.aborted)
74
+ throw new Error("playtest aborted mid-session");
75
+ const waitMs = event.t - (Date.now() - sessionStart);
76
+ if (waitMs > 0)
77
+ await page.waitForTimeout(waitMs);
78
+ switch (event.kind) {
79
+ case "mousemove":
80
+ await page.mouse.move(event.x, event.y);
81
+ break;
82
+ case "mousedown":
83
+ await page.mouse.move(event.x, event.y);
84
+ await page.mouse.down();
85
+ break;
86
+ case "mouseup":
87
+ await page.mouse.move(event.x, event.y);
88
+ await page.mouse.up();
89
+ break;
90
+ case "keydown":
91
+ await page.keyboard.down(event.key);
92
+ break;
93
+ case "keyup":
94
+ await page.keyboard.up(event.key);
95
+ break;
96
+ case "screenshot": {
97
+ framesRequested++;
98
+ const png = await page.screenshot({ type: "png" });
99
+ frames.push({ t: event.t, png });
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ const remaining = input.durationMs - (Date.now() - sessionStart);
105
+ if (remaining > 0)
106
+ await page.waitForTimeout(remaining);
107
+ return { frames, framesRequested, wallMs: Date.now() - sessionStart };
108
+ }
109
+ async function measureDiagnostics(page, wallMs) {
110
+ const rafTicks = await page.evaluate(() => window.__castleRafTicks ?? 0);
111
+ const hidden = await page.evaluate(() => document.hidden);
112
+ return { rafTicks, wallMs, framesRequested: 0, framesCaptured: 0, hidden };
113
+ }
114
+ async function runSessionOnBrowser(browser, input) {
115
+ const navStart = Date.now();
116
+ const boundaries = { settleStartRel: null, sessionStartRel: null };
117
+ const context = await browser.newContext({ viewport: PLAYTEST_VIEWPORT });
118
+ let consoleLog = [];
119
+ try {
120
+ const page = await context.newPage();
121
+ consoleLog = attachConsoleCapture(page, navStart, boundaries);
122
+ if (input.signal?.aborted)
123
+ throw new Error("playtest aborted before navigation");
124
+ await page.goto(input.url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
125
+ boundaries.settleStartRel = Date.now() - navStart;
126
+ await page.waitForTimeout(PLAYTEST_SETTLE_MS);
127
+ boundaries.sessionStartRel = Date.now() - navStart;
128
+ await page.evaluate(() => {
129
+ const win = window;
130
+ win.__castleRafTicks = 0;
131
+ const tick = () => {
132
+ win.__castleRafTicks = (win.__castleRafTicks ?? 0) + 1;
133
+ requestAnimationFrame(tick);
134
+ };
135
+ requestAnimationFrame(tick);
136
+ });
137
+ const { frames, framesRequested, wallMs } = await runTimeline(page, input);
138
+ const diagnostics = await measureDiagnostics(page, wallMs);
139
+ diagnostics.framesRequested = framesRequested;
140
+ diagnostics.framesCaptured = frames.length;
141
+ return { ok: true, viewport: PLAYTEST_VIEWPORT, frames, console: consoleLog, diagnostics };
142
+ }
143
+ catch (err) {
144
+ return {
145
+ ok: false,
146
+ error: err instanceof Error ? err.message : String(err),
147
+ viewport: PLAYTEST_VIEWPORT,
148
+ frames: [],
149
+ console: consoleLog,
150
+ diagnostics: { rafTicks: 0, wallMs: 0, framesRequested: 0, framesCaptured: 0, hidden: false },
151
+ };
152
+ }
153
+ finally {
154
+ await context.close().catch(() => undefined);
155
+ }
156
+ }
@@ -0,0 +1,131 @@
1
+ export declare const PLAYTEST_MAX_DURATION_MS = 15000;
2
+ export declare const PLAYTEST_MAX_ACTIONS = 20;
3
+ export declare const PLAYTEST_MAX_SHOTS = 6;
4
+ export declare const PLAYTEST_MAX_CALLS_PER_RUN = 4;
5
+ export declare const PLAYTEST_VIEWPORT: {
6
+ readonly width: 500;
7
+ readonly height: 700;
8
+ };
9
+ export declare const PLAYTEST_SETTLE_MS = 1500;
10
+ export type PlaytestActionType = "down" | "move" | "up" | "tap" | "key";
11
+ export interface PlaytestActionRaw {
12
+ t: number;
13
+ type: PlaytestActionType;
14
+ x?: number;
15
+ y?: number;
16
+ key?: string;
17
+ durationMs?: number;
18
+ }
19
+ export interface PlaytestArgs {
20
+ durationMs: number;
21
+ actions: PlaytestActionRaw[];
22
+ screenshots: number[];
23
+ }
24
+ export type TimelineEvent = {
25
+ t: number;
26
+ kind: "mousemove" | "mousedown" | "mouseup";
27
+ x: number;
28
+ y: number;
29
+ } | {
30
+ t: number;
31
+ kind: "keydown" | "keyup";
32
+ key: string;
33
+ } | {
34
+ t: number;
35
+ kind: "screenshot";
36
+ };
37
+ export declare function validatePlaytestArgs(args: Record<string, unknown>): {
38
+ ok: true;
39
+ value: PlaytestArgs;
40
+ } | {
41
+ ok: false;
42
+ error: string;
43
+ };
44
+ export declare function buildTimeline(args: PlaytestArgs): TimelineEvent[];
45
+ export interface PlaytestDiagnostics {
46
+ rafTicks: number;
47
+ wallMs: number;
48
+ framesRequested: number;
49
+ framesCaptured: number;
50
+ hidden: boolean;
51
+ }
52
+ export declare function diagnoseCapture(d: PlaytestDiagnostics): {
53
+ expectedTicks: number;
54
+ starved: boolean;
55
+ };
56
+ export interface PlaytestFrame {
57
+ t: number;
58
+ png: Buffer;
59
+ }
60
+ export declare function countDistinctFrames(frames: PlaytestFrame[]): number;
61
+ export declare function captureReport(frames: PlaytestFrame[], d: PlaytestDiagnostics): {
62
+ distinct: number;
63
+ starved: boolean;
64
+ expectedTicks: number;
65
+ lines: string[];
66
+ };
67
+ export declare function playtestDeckUrl(serveOrigin: string): string;
68
+ export interface PlaytestConsoleEntry {
69
+ t: number;
70
+ phase: "load" | "settle" | "session";
71
+ level: "log" | "warn" | "error" | "pageerror";
72
+ text: string;
73
+ }
74
+ export type PlaytestInstallEvent = {
75
+ phase: "started";
76
+ shared: boolean;
77
+ } | {
78
+ phase: "finished";
79
+ elapsedMs: number;
80
+ cachePath?: string;
81
+ } | {
82
+ phase: "failed";
83
+ elapsedMs: number;
84
+ error: string;
85
+ };
86
+ export interface PlaytestSessionInput {
87
+ url: string;
88
+ durationMs: number;
89
+ timeline: TimelineEvent[];
90
+ signal?: AbortSignal;
91
+ onProgress?: (label: string) => void;
92
+ onInstallEvent?: (evt: PlaytestInstallEvent) => void;
93
+ }
94
+ export interface PlaytestSessionResult {
95
+ ok: boolean;
96
+ error?: string;
97
+ viewport: {
98
+ width: number;
99
+ height: number;
100
+ };
101
+ frames: PlaytestFrame[];
102
+ console: PlaytestConsoleEntry[];
103
+ diagnostics: PlaytestDiagnostics;
104
+ browserInstallMs?: number;
105
+ }
106
+ export interface PlaytestExecutor {
107
+ runSession(input: PlaytestSessionInput): Promise<PlaytestSessionResult>;
108
+ }
109
+ export interface PlaytestToolContext {
110
+ executor: PlaytestExecutor;
111
+ serveUrl: string;
112
+ framesDir: string;
113
+ callCount: {
114
+ value: number;
115
+ };
116
+ onProgress?: (label: string) => void;
117
+ onInstallEvent?: (evt: PlaytestInstallEvent) => void;
118
+ }
119
+ export interface PlaytestToolResult {
120
+ ok: boolean;
121
+ output: string;
122
+ images?: Array<{
123
+ label: string;
124
+ dataUrl: string;
125
+ }>;
126
+ playtestFrames?: string[];
127
+ activitySummary?: string;
128
+ }
129
+ export declare function runPlaytest(args: Record<string, unknown>, deckDir: string, ctx: PlaytestToolContext | undefined, signal: AbortSignal | undefined): Promise<PlaytestToolResult>;
130
+ export declare const PLAYTEST_TOOL_DESCRIPTION: string;
131
+ export declare const PLAYTEST_TOOL_PARAMETERS: Record<string, unknown>;