castle-web-cli 0.4.78 → 0.4.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +690 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-D3unT7do.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The `playtest` tool: schema, arg validation/expansion, capture-health
|
|
2
|
+
// diagnostics, and result digest -- everything that can be exercised without
|
|
3
|
+
// a real browser. Deliberately holds NO Playwright import (not even a type
|
|
4
|
+
// import) so this module (and everything that imports it -- tools.ts,
|
|
5
|
+
// loop.ts) stays safe to load in a serve that never calls playtest, with
|
|
6
|
+
// zero cost and zero risk of a missing-dependency crash. The Playwright-
|
|
7
|
+
// specific pieces (browser lifecycle, the actual page automation) live in
|
|
8
|
+
// native/playtest-browser.ts and native/playtest-executor.ts, reached only
|
|
9
|
+
// through the PlaytestExecutor interface below -- see the QA battery
|
|
10
|
+
// (scripts/tests/agent-qa/native/run-native.mjs), which fakes that interface
|
|
11
|
+
// entirely and never touches Playwright.
|
|
12
|
+
//
|
|
13
|
+
// diagnoseCapture / countDistinctFrames / captureReport are ported near-
|
|
14
|
+
// verbatim from castle-djinn's src/lib/playtest.ts (the iframe-postMessage
|
|
15
|
+
// playtest harness) -- same ~60fps-tick heuristic, same "a starved capture is
|
|
16
|
+
// NOT a broken game" warning language, adapted from a diagnostics object an
|
|
17
|
+
// iframe posts back to one a Playwright page reports directly.
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
// -- caps + fixed geometry ------------------------------------------------------
|
|
21
|
+
// Ratified constants. The viewport is FIXED (not per-deck): every digest and
|
|
22
|
+
// the schema description both state it, so the model never has to guess
|
|
23
|
+
// what coordinate space `x`/`y` live in.
|
|
24
|
+
export const PLAYTEST_MAX_DURATION_MS = 15_000;
|
|
25
|
+
export const PLAYTEST_MAX_ACTIONS = 20;
|
|
26
|
+
export const PLAYTEST_MAX_SHOTS = 6;
|
|
27
|
+
export const PLAYTEST_MAX_CALLS_PER_RUN = 4;
|
|
28
|
+
export const PLAYTEST_VIEWPORT = { width: 500, height: 700 };
|
|
29
|
+
// Fixed settle before the session clock starts -- lets the page's own load
|
|
30
|
+
// work (bundler eval, first paint, any async init) finish before t=0, so
|
|
31
|
+
// screenshots at t=0 show a settled frame instead of a half-loaded one.
|
|
32
|
+
export const PLAYTEST_SETTLE_MS = 1_500;
|
|
33
|
+
// A `key` action without an explicit durationMs gets this hold length.
|
|
34
|
+
const PLAYTEST_DEFAULT_KEY_DURATION_MS = 100;
|
|
35
|
+
function isFiniteNumber(v) {
|
|
36
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
37
|
+
}
|
|
38
|
+
function validateAction(raw, index) {
|
|
39
|
+
if (!raw || typeof raw !== "object")
|
|
40
|
+
return { error: `actions[${index}] must be an object` };
|
|
41
|
+
const a = raw;
|
|
42
|
+
if (!isFiniteNumber(a.t) || a.t < 0)
|
|
43
|
+
return { error: `actions[${index}].t must be a non-negative number` };
|
|
44
|
+
if (a.type !== "down" && a.type !== "move" && a.type !== "up" && a.type !== "tap" && a.type !== "key") {
|
|
45
|
+
return { error: `actions[${index}].type must be one of down/move/up/tap/key, got ${JSON.stringify(a.type)}` };
|
|
46
|
+
}
|
|
47
|
+
if (a.type === "key") {
|
|
48
|
+
if (typeof a.key !== "string" || a.key.trim() === "") {
|
|
49
|
+
return { error: `actions[${index}] (type "key") requires a non-empty \`key\` string` };
|
|
50
|
+
}
|
|
51
|
+
if (a.durationMs !== undefined && (!isFiniteNumber(a.durationMs) || a.durationMs <= 0)) {
|
|
52
|
+
return { error: `actions[${index}].durationMs must be a positive number when given` };
|
|
53
|
+
}
|
|
54
|
+
return { action: { t: a.t, type: "key", key: a.key, durationMs: a.durationMs } };
|
|
55
|
+
}
|
|
56
|
+
if (!isFiniteNumber(a.x) || !isFiniteNumber(a.y)) {
|
|
57
|
+
return { error: `actions[${index}] (type "${a.type}") requires numeric \`x\`/\`y\`` };
|
|
58
|
+
}
|
|
59
|
+
if (a.x < 0 || a.x > PLAYTEST_VIEWPORT.width || a.y < 0 || a.y > PLAYTEST_VIEWPORT.height) {
|
|
60
|
+
return {
|
|
61
|
+
error: `actions[${index}]'s coordinates (${a.x}, ${a.y}) fall outside the fixed ${PLAYTEST_VIEWPORT.width}x${PLAYTEST_VIEWPORT.height} viewport`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { action: { t: a.t, type: a.type, x: a.x, y: a.y } };
|
|
65
|
+
}
|
|
66
|
+
export function validatePlaytestArgs(args) {
|
|
67
|
+
if (!isFiniteNumber(args.durationMs) || args.durationMs < 0) {
|
|
68
|
+
return { ok: false, error: "durationMs is required and must be a non-negative number" };
|
|
69
|
+
}
|
|
70
|
+
if (args.durationMs > PLAYTEST_MAX_DURATION_MS) {
|
|
71
|
+
return { ok: false, error: `durationMs (${args.durationMs}) exceeds the ${PLAYTEST_MAX_DURATION_MS}ms cap` };
|
|
72
|
+
}
|
|
73
|
+
const durationMs = args.durationMs;
|
|
74
|
+
const rawActions = args.actions;
|
|
75
|
+
const actions = [];
|
|
76
|
+
if (rawActions !== undefined) {
|
|
77
|
+
if (!Array.isArray(rawActions))
|
|
78
|
+
return { ok: false, error: "actions must be an array" };
|
|
79
|
+
if (rawActions.length > PLAYTEST_MAX_ACTIONS) {
|
|
80
|
+
return { ok: false, error: `actions has ${rawActions.length} entries, exceeding the cap of ${PLAYTEST_MAX_ACTIONS}` };
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < rawActions.length; i++) {
|
|
83
|
+
const result = validateAction(rawActions[i], i);
|
|
84
|
+
if ("error" in result)
|
|
85
|
+
return { ok: false, error: result.error };
|
|
86
|
+
if (result.action.t > durationMs) {
|
|
87
|
+
return { ok: false, error: `actions[${i}].t (${result.action.t}) is after durationMs (${durationMs})` };
|
|
88
|
+
}
|
|
89
|
+
actions.push(result.action);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const rawShots = args.screenshots;
|
|
93
|
+
let screenshots;
|
|
94
|
+
if (rawShots === undefined) {
|
|
95
|
+
screenshots = [0, durationMs];
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
if (!Array.isArray(rawShots))
|
|
99
|
+
return { ok: false, error: "screenshots must be an array of timestamps (ms)" };
|
|
100
|
+
if (rawShots.length > PLAYTEST_MAX_SHOTS) {
|
|
101
|
+
return { ok: false, error: `screenshots has ${rawShots.length} entries, exceeding the cap of ${PLAYTEST_MAX_SHOTS}` };
|
|
102
|
+
}
|
|
103
|
+
screenshots = [];
|
|
104
|
+
for (let i = 0; i < rawShots.length; i++) {
|
|
105
|
+
const v = rawShots[i];
|
|
106
|
+
if (!isFiniteNumber(v) || v < 0)
|
|
107
|
+
return { ok: false, error: `screenshots[${i}] must be a non-negative number` };
|
|
108
|
+
if (v > durationMs)
|
|
109
|
+
return { ok: false, error: `screenshots[${i}] (${v}) is after durationMs (${durationMs})` };
|
|
110
|
+
screenshots.push(v);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, value: { durationMs, actions, screenshots } };
|
|
114
|
+
}
|
|
115
|
+
// Expands tap/key sugar into raw primitives and merges in screenshot
|
|
116
|
+
// requests, stable-sorted by t (ties: actions before screenshots, so a shot
|
|
117
|
+
// requested at the same timestamp as an action captures its effect).
|
|
118
|
+
export function buildTimeline(args) {
|
|
119
|
+
const events = [];
|
|
120
|
+
for (const a of args.actions) {
|
|
121
|
+
if (a.type === "tap") {
|
|
122
|
+
events.push({ t: a.t, kind: "mousedown", x: a.x, y: a.y });
|
|
123
|
+
events.push({ t: a.t, kind: "mouseup", x: a.x, y: a.y });
|
|
124
|
+
}
|
|
125
|
+
else if (a.type === "key") {
|
|
126
|
+
const holdMs = a.durationMs ?? PLAYTEST_DEFAULT_KEY_DURATION_MS;
|
|
127
|
+
events.push({ t: a.t, kind: "keydown", key: a.key });
|
|
128
|
+
events.push({ t: a.t + holdMs, kind: "keyup", key: a.key });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const kind = a.type === "down" ? "mousedown" : a.type === "up" ? "mouseup" : "mousemove";
|
|
132
|
+
events.push({ t: a.t, kind, x: a.x, y: a.y });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const t of args.screenshots)
|
|
136
|
+
events.push({ t, kind: "screenshot" });
|
|
137
|
+
return events
|
|
138
|
+
.map((e, i) => ({ e, i }))
|
|
139
|
+
.sort((a, b) => (a.e.t - b.e.t) || (a.i - b.i))
|
|
140
|
+
.map(({ e }) => e);
|
|
141
|
+
}
|
|
142
|
+
export function diagnoseCapture(d) {
|
|
143
|
+
const expectedTicks = Math.max(1, Math.round((d.wallMs || 0) / 16));
|
|
144
|
+
const starved = d.rafTicks <= 1 || d.rafTicks < expectedTicks * 0.2;
|
|
145
|
+
return { expectedTicks, starved };
|
|
146
|
+
}
|
|
147
|
+
export function countDistinctFrames(frames) {
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
for (const f of frames)
|
|
150
|
+
if (f.png.length > 0)
|
|
151
|
+
seen.add(f.png.toString("base64"));
|
|
152
|
+
return seen.size;
|
|
153
|
+
}
|
|
154
|
+
export function captureReport(frames, d) {
|
|
155
|
+
const distinct = countDistinctFrames(frames);
|
|
156
|
+
const { expectedTicks, starved } = diagnoseCapture(d);
|
|
157
|
+
const lines = [
|
|
158
|
+
`Capture health: ${d.rafTicks} animation frame(s) over ${d.wallMs}ms (a live ~60fps session is ~${expectedTicks}); ${d.framesCaptured}/${d.framesRequested} screenshot(s) captured${d.hidden ? "; document reported HIDDEN" : ""}.`,
|
|
159
|
+
];
|
|
160
|
+
if (starved) {
|
|
161
|
+
lines.push("WARNING: the capture environment was barely rendering (animation frames near zero). That points to a throttled/dead CAPTURE (e.g. the browser or its Chromium process), not a frozen game -- the screenshots can look stuck even though the game runs fine. Treat this as a capture failure: do NOT conclude the game is broken from it. Retry the playtest once before drawing any conclusion.");
|
|
162
|
+
}
|
|
163
|
+
if (frames.length > 1 && distinct <= 1 && !starved) {
|
|
164
|
+
lines.push(`All ${frames.length} captured frames are pixel-identical -- if something should have moved or responded, check the input mapping or game loop; if nothing should have changed yet, this is expected.`);
|
|
165
|
+
}
|
|
166
|
+
return { distinct, starved, expectedTicks, lines };
|
|
167
|
+
}
|
|
168
|
+
// -- deck URL ---------------------------------------------------------------------
|
|
169
|
+
// The serve's ROOT (`/`) is the editor shell UI (ide.ts serves the built
|
|
170
|
+
// cli/dist/shell bundle there); the deck itself is `/index.html`, served by
|
|
171
|
+
// Vite, which the shell's own play panel iframes as `/index.html?edit=0`
|
|
172
|
+
// (see PlaytestPanel in shell/main.tsx). Playtest must load the DECK page,
|
|
173
|
+
// not the shell -- v1 navigated to the origin and screenshotted the agent
|
|
174
|
+
// chat UI instead of the game. `edit=0` mirrors the play iframe: the serve
|
|
175
|
+
// injects `CastleEmbed={edit:true}` into every deck page, and `edit=0` is
|
|
176
|
+
// the URL override that flips it to play mode (kits route to their play-only
|
|
177
|
+
// view; the SDK applies its play-card layout). The play iframe's other param
|
|
178
|
+
// (`logs=1`, the postMessage console forwarder for the shell's logs drawer)
|
|
179
|
+
// is deliberately NOT mirrored: its inject bails on a top-level page, and
|
|
180
|
+
// Playwright captures console output natively.
|
|
181
|
+
export function playtestDeckUrl(serveOrigin) {
|
|
182
|
+
return `${serveOrigin.replace(/\/+$/, "")}/index.html?edit=0`;
|
|
183
|
+
}
|
|
184
|
+
const CONSOLE_LOG_CAP = 20;
|
|
185
|
+
function fmtConsolePhase(entry) {
|
|
186
|
+
return `[${entry.phase} +${entry.t}ms] ${entry.level}: ${entry.text}`;
|
|
187
|
+
}
|
|
188
|
+
function buildDigest(opts) {
|
|
189
|
+
const { callIndex, args, session, report, persisted } = opts;
|
|
190
|
+
const lines = [
|
|
191
|
+
`Playtest call ${callIndex}/${PLAYTEST_MAX_CALLS_PER_RUN} -- ${session.viewport.width}x${session.viewport.height} fixed viewport, ${args.durationMs}ms session (${args.actions.length} action(s), ${args.screenshots.length} screenshot(s) requested).`,
|
|
192
|
+
];
|
|
193
|
+
lines.push("Frames:");
|
|
194
|
+
for (const p of persisted)
|
|
195
|
+
lines.push(` t=${p.t}ms -> ${p.relPath}`);
|
|
196
|
+
lines.push(...report.lines);
|
|
197
|
+
if (session.browserInstallMs && session.browserInstallMs > 0) {
|
|
198
|
+
lines.push(`Note: a first-run browser download added ~${Math.max(1, Math.round(session.browserInstallMs / 1000))}s to this call -- that was one-time setup, not the game loading slowly; later playtests skip it.`);
|
|
199
|
+
}
|
|
200
|
+
const consoleEntries = session.console;
|
|
201
|
+
const errorCount = consoleEntries.filter((c) => c.level === "error" || c.level === "pageerror").length;
|
|
202
|
+
if (consoleEntries.length === 0) {
|
|
203
|
+
lines.push("Console: (nothing logged across load + settle + session)");
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const shown = consoleEntries.slice(-CONSOLE_LOG_CAP);
|
|
207
|
+
const omitted = consoleEntries.length - shown.length;
|
|
208
|
+
lines.push(`Console (${consoleEntries.length} total, ${errorCount} error(s)${omitted > 0 ? `, showing last ${shown.length}` : ""}):`);
|
|
209
|
+
for (const entry of shown)
|
|
210
|
+
lines.push(` ${fmtConsolePhase(entry)}`);
|
|
211
|
+
}
|
|
212
|
+
const distinctNote = persisted.length > 1 ? `${report.distinct} distinct` : `${report.distinct}`;
|
|
213
|
+
const activitySummary = `Playtested ${args.durationMs}ms: ${persisted.length} frame(s), ${distinctNote}` +
|
|
214
|
+
(errorCount > 0 ? `, ${errorCount} console error(s)` : "") +
|
|
215
|
+
(report.starved ? " [CAPTURE STARVED -- retry]" : "");
|
|
216
|
+
return { text: lines.join("\n"), activitySummary };
|
|
217
|
+
}
|
|
218
|
+
export async function runPlaytest(args, deckDir, ctx, signal) {
|
|
219
|
+
if (!ctx) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
output: "Error: playtest has no browser/serve wired up in this context (this deck isn't being run through a serve that supports it).",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const parsed = validatePlaytestArgs(args);
|
|
226
|
+
if (!parsed.ok)
|
|
227
|
+
return { ok: false, output: `Error: ${parsed.error}` };
|
|
228
|
+
if (ctx.callCount.value >= PLAYTEST_MAX_CALLS_PER_RUN) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
output: `Error: playtest budget exhausted -- this run has already called playtest ${PLAYTEST_MAX_CALLS_PER_RUN} time(s), the max per run. Rely on your own read of the code (or report what you've already observed) instead of playtesting again.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
ctx.callCount.value += 1;
|
|
235
|
+
const callIndex = ctx.callCount.value;
|
|
236
|
+
const timeline = buildTimeline(parsed.value);
|
|
237
|
+
const session = await ctx.executor.runSession({
|
|
238
|
+
url: playtestDeckUrl(ctx.serveUrl),
|
|
239
|
+
durationMs: parsed.value.durationMs,
|
|
240
|
+
timeline,
|
|
241
|
+
signal,
|
|
242
|
+
onProgress: ctx.onProgress,
|
|
243
|
+
onInstallEvent: ctx.onInstallEvent,
|
|
244
|
+
});
|
|
245
|
+
if (!session.ok) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
output: `Error: playtest capture failed: ${session.error ?? "unknown error"}. This is very likely a CAPTURE problem (the browser died or failed to load the page), not evidence the game itself is broken -- retry once before concluding anything about the game.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
fs.mkdirSync(ctx.framesDir, { recursive: true });
|
|
252
|
+
const persisted = [];
|
|
253
|
+
for (const frame of session.frames) {
|
|
254
|
+
const filename = `${callIndex}-${frame.t}.png`;
|
|
255
|
+
const absPath = path.join(ctx.framesDir, filename);
|
|
256
|
+
fs.writeFileSync(absPath, frame.png);
|
|
257
|
+
persisted.push({ t: frame.t, relPath: path.relative(deckDir, absPath).split(path.sep).join("/") });
|
|
258
|
+
}
|
|
259
|
+
const report = captureReport(session.frames, session.diagnostics);
|
|
260
|
+
const digest = buildDigest({ callIndex, args: parsed.value, session, report, persisted });
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
output: digest.text,
|
|
264
|
+
images: session.frames.map((f) => ({
|
|
265
|
+
label: `playtest call ${callIndex} frame t=${f.t}ms`,
|
|
266
|
+
dataUrl: `data:image/png;base64,${f.png.toString("base64")}`,
|
|
267
|
+
})),
|
|
268
|
+
playtestFrames: persisted.map((p) => p.relPath),
|
|
269
|
+
activitySummary: digest.activitySummary,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// -- tool schema (model-facing) ------------------------------------------------
|
|
273
|
+
export const PLAYTEST_TOOL_DESCRIPTION = [
|
|
274
|
+
`Load the served deck in a real headless browser, optionally drive timed mouse/keyboard input, and capture screenshots + console/error output. Use this after changing behavior or visuals to actually SEE what you built, not just read the code back.`,
|
|
275
|
+
`Coordinates (x/y) are CSS pixels in a FIXED ${PLAYTEST_VIEWPORT.width}x${PLAYTEST_VIEWPORT.height} viewport, regardless of the deck's own canvas size.`,
|
|
276
|
+
`durationMs is the session length (ms, up to ${PLAYTEST_MAX_DURATION_MS}). actions (up to ${PLAYTEST_MAX_ACTIONS}) are timed inputs: {t,type:"down"|"move"|"up",x,y} raw mouse primitives, {t,type:"tap",x,y} (down+up sugar), or {t,type:"key",key,durationMs?} (keydown at t, keyup at t+durationMs, default ${PLAYTEST_DEFAULT_KEY_DURATION_MS}ms -- overlapping holds are fine). screenshots (up to ${PLAYTEST_MAX_SHOTS} timestamps in ms) default to [0, durationMs].`,
|
|
277
|
+
`The result is a text digest (frame list, console/error log, capture-health) plus the actual frame images delivered right after. A "capture starved" warning means the CAPTURE wasn't rendering (throttled/dead browser) -- that is not proof the game is broken; retry before concluding anything. This tool is capped at ${PLAYTEST_MAX_CALLS_PER_RUN} calls per run.`,
|
|
278
|
+
].join(" ");
|
|
279
|
+
export const PLAYTEST_TOOL_PARAMETERS = {
|
|
280
|
+
type: "object",
|
|
281
|
+
properties: {
|
|
282
|
+
durationMs: {
|
|
283
|
+
type: "integer",
|
|
284
|
+
description: `Session length in ms, 0-${PLAYTEST_MAX_DURATION_MS}.`,
|
|
285
|
+
},
|
|
286
|
+
actions: {
|
|
287
|
+
type: "array",
|
|
288
|
+
maxItems: PLAYTEST_MAX_ACTIONS,
|
|
289
|
+
description: `Up to ${PLAYTEST_MAX_ACTIONS} timed input actions, in a fixed ${PLAYTEST_VIEWPORT.width}x${PLAYTEST_VIEWPORT.height} CSS-pixel viewport.`,
|
|
290
|
+
items: {
|
|
291
|
+
type: "object",
|
|
292
|
+
properties: {
|
|
293
|
+
t: { type: "integer", description: "Milliseconds from session start." },
|
|
294
|
+
type: { type: "string", enum: ["down", "move", "up", "tap", "key"] },
|
|
295
|
+
x: { type: "number", description: `CSS pixels, 0-${PLAYTEST_VIEWPORT.width}. Required for down/move/up/tap.` },
|
|
296
|
+
y: { type: "number", description: `CSS pixels, 0-${PLAYTEST_VIEWPORT.height}. Required for down/move/up/tap.` },
|
|
297
|
+
key: { type: "string", description: 'Key name (e.g. "ArrowLeft", "a", " "). Required for type "key".' },
|
|
298
|
+
durationMs: {
|
|
299
|
+
type: "integer",
|
|
300
|
+
description: `Hold length for a "key" action -- keyup fires at t+durationMs. Defaults to ${PLAYTEST_DEFAULT_KEY_DURATION_MS}ms.`,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
required: ["t", "type"],
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
screenshots: {
|
|
307
|
+
type: "array",
|
|
308
|
+
maxItems: PLAYTEST_MAX_SHOTS,
|
|
309
|
+
items: { type: "integer" },
|
|
310
|
+
description: `Up to ${PLAYTEST_MAX_SHOTS} timestamps (ms) to screenshot at. Defaults to [0, durationMs].`,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
required: ["durationMs"],
|
|
314
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { NativeRole } from "./types.js";
|
|
2
|
+
import { type PlaytestToolContext } from "./playtest.js";
|
|
3
|
+
export interface PolicyDecision {
|
|
4
|
+
allow: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export type PolicyCheck = (call: {
|
|
8
|
+
tool: string;
|
|
9
|
+
args: Record<string, unknown>;
|
|
10
|
+
}) => PolicyDecision;
|
|
11
|
+
export interface ToolExecContext {
|
|
12
|
+
deckDir: string;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
checkPolicy?: PolicyCheck;
|
|
15
|
+
playtest?: PlaytestToolContext;
|
|
16
|
+
restart?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolCallResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
output: string;
|
|
21
|
+
filesTouched?: string[];
|
|
22
|
+
images?: Array<{
|
|
23
|
+
label: string;
|
|
24
|
+
dataUrl: string;
|
|
25
|
+
}>;
|
|
26
|
+
playtestFrames?: string[];
|
|
27
|
+
activitySummary?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function toolSchemasForRole(role: NativeRole): Array<{
|
|
30
|
+
type: "function";
|
|
31
|
+
function: {
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
parameters: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
export declare function executeTool(name: string, args: Record<string, unknown>, role: NativeRole, ctx: ToolExecContext): Promise<ToolCallResult>;
|
|
38
|
+
export declare function activityLabelForCall(name: string, args: Record<string, unknown>): string;
|