ravensight-playtest 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/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { createClient } from '../api/client.js';
|
|
2
|
+
import { ApiError } from '../api/errors.js';
|
|
3
|
+
import { resolveApiUrl, paths } from '../paths.js';
|
|
4
|
+
import { resolveToken, looksLikeIngestKey } from '../auth/session.js';
|
|
5
|
+
import { backendName } from '../auth/keychain.js';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import { packs } from '../packs/index.js';
|
|
8
|
+
import { detectFfmpeg, freeBytes, recommendConcurrency } from '../detect.js';
|
|
9
|
+
import { inspectGodot } from '../godot.js';
|
|
10
|
+
import { CLI_VERSION, compareVersions } from '../version.js';
|
|
11
|
+
import { ui } from '../ui/index.js';
|
|
12
|
+
import { ExitCode } from '../errors.js';
|
|
13
|
+
|
|
14
|
+
export const NODE_MIN_MAJOR = 22;
|
|
15
|
+
const DISK_WARN_BYTES = 2 * 1024 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const row = (name, status, detail, fix = null) => ({ name, status, detail, fix });
|
|
18
|
+
|
|
19
|
+
/** "1 persona", "3 personas". A doctor that cannot count reads like a draft. */
|
|
20
|
+
const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every environment check, as data.
|
|
24
|
+
*
|
|
25
|
+
* Returns rows rather than printing, so `--json` and the human table are the
|
|
26
|
+
* same check run two ways, and so the whole thing is testable against an
|
|
27
|
+
* in process server with no terminal involved.
|
|
28
|
+
*
|
|
29
|
+
* Status meanings are load bearing. `fail` is "this command cannot work", and
|
|
30
|
+
* it is what sets exit code 3. `warn` is "something optional is missing", and
|
|
31
|
+
* CI must not fail on it: ffmpeg and Godot are genuinely optional, and a run
|
|
32
|
+
* that never touches them should not be blocked because they are absent.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} [flags]
|
|
35
|
+
* @param {string} [flags.apiUrl]
|
|
36
|
+
* @param {string} [flags.game]
|
|
37
|
+
* @param {string[]} [flags.modules]
|
|
38
|
+
* @param {string[]} [flags.personas]
|
|
39
|
+
* @param {string} [flags.repoRoot]
|
|
40
|
+
* @param {Object} [deps]
|
|
41
|
+
* @returns {Promise<{rows: Array, ok: boolean}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function runChecks(flags = {}, deps = {}) {
|
|
44
|
+
const apiUrl = flags.apiUrl || resolveApiUrl();
|
|
45
|
+
const rows = [];
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------- runtime */
|
|
48
|
+
|
|
49
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
50
|
+
rows.push(row(
|
|
51
|
+
'node',
|
|
52
|
+
nodeMajor >= NODE_MIN_MAJOR ? 'ok' : 'fail',
|
|
53
|
+
`v${process.versions.node}`,
|
|
54
|
+
`install Node ${NODE_MIN_MAJOR} or newer`
|
|
55
|
+
));
|
|
56
|
+
|
|
57
|
+
/* ----------------------------------------------------------- the server */
|
|
58
|
+
|
|
59
|
+
const api = deps.api || createClient({ apiUrl, token: null });
|
|
60
|
+
let version = null;
|
|
61
|
+
try {
|
|
62
|
+
version = await api.cli.version();
|
|
63
|
+
rows.push(row('api reachable', 'ok', `${apiUrl} (schema ${version.schema_version})`));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
rows.push(row(
|
|
66
|
+
'api reachable',
|
|
67
|
+
'fail',
|
|
68
|
+
error instanceof ApiError ? error.message : String(error && error.message),
|
|
69
|
+
`check RAVENSIGHT_API_URL, currently ${apiUrl}`
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (version) {
|
|
74
|
+
const tooOld = compareVersions(CLI_VERSION, version.min_supported) < 0;
|
|
75
|
+
const behind = compareVersions(CLI_VERSION, version.latest) < 0;
|
|
76
|
+
rows.push(row(
|
|
77
|
+
'cli version',
|
|
78
|
+
tooOld ? 'fail' : behind ? 'warn' : 'ok',
|
|
79
|
+
`${CLI_VERSION} (server wants at least ${version.min_supported}, latest ${version.latest})`,
|
|
80
|
+
tooOld || behind ? 'npm install -g ravensight-playtest@latest' : null
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------- the credential */
|
|
85
|
+
|
|
86
|
+
const { token, source, host } = await resolveToken({ apiUrl });
|
|
87
|
+
// `source` is where the token actually came from; `backend` is where this
|
|
88
|
+
// machine would put one. They differ when a token was written to the file
|
|
89
|
+
// while the keychain was unavailable, and the row reports the real one rather
|
|
90
|
+
// than the one that sounds better.
|
|
91
|
+
const backend = await (deps.backendName || backendName)();
|
|
92
|
+
const storeDetail = () => {
|
|
93
|
+
if (source === 'env') return 'RAVENSIGHT_TOKEN from the environment';
|
|
94
|
+
if (source === 'file') return `${paths.credentialsFile()} (a 0600 file, not a keychain)`;
|
|
95
|
+
if (source === 'keychain') return 'OS keychain';
|
|
96
|
+
return backend === 'keychain'
|
|
97
|
+
? 'OS keychain, with nothing in it for this host yet'
|
|
98
|
+
: `${paths.credentialsFile()} (no OS keychain found)`;
|
|
99
|
+
};
|
|
100
|
+
rows.push(row(
|
|
101
|
+
'credential store',
|
|
102
|
+
'ok',
|
|
103
|
+
storeDetail(),
|
|
104
|
+
source === 'file' || (source === null && backend === 'file')
|
|
105
|
+
? 'install libsecret for a real keychain on Linux'
|
|
106
|
+
: null
|
|
107
|
+
));
|
|
108
|
+
|
|
109
|
+
if (looksLikeIngestKey(token)) {
|
|
110
|
+
rows.push(row(
|
|
111
|
+
'token valid',
|
|
112
|
+
'fail',
|
|
113
|
+
'that is an ingest key, not a CLI token',
|
|
114
|
+
'run login, or set RAVENSIGHT_TOKEN to a gt_cli_ token'
|
|
115
|
+
));
|
|
116
|
+
} else if (!token) {
|
|
117
|
+
rows.push(row('token valid', 'fail', `not logged in to ${host}`, 'ravensight-playtest login'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let whoami = null;
|
|
121
|
+
if (token && !looksLikeIngestKey(token)) {
|
|
122
|
+
const authed = deps.authedApi || createClient({ apiUrl, token });
|
|
123
|
+
try {
|
|
124
|
+
whoami = await authed.cli.whoami();
|
|
125
|
+
rows.push(row(
|
|
126
|
+
'token valid',
|
|
127
|
+
'ok',
|
|
128
|
+
`${whoami.user.email || whoami.user.name || whoami.user.id}, scopes ${whoami.scopes.join(' ')}`
|
|
129
|
+
));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const message = error instanceof ApiError ? error.message : String(error && error.message);
|
|
132
|
+
rows.push(row('token valid', 'fail', message, 'ravensight-playtest login'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ------------------------------------------------------------ the game */
|
|
136
|
+
|
|
137
|
+
const config = flags.config === undefined
|
|
138
|
+
? await loadConfig({ repoRoot: flags.repoRoot })
|
|
139
|
+
: flags.config;
|
|
140
|
+
const gameId = flags.game || (config && config.game_id) || null;
|
|
141
|
+
|
|
142
|
+
if (!gameId) {
|
|
143
|
+
rows.push(row(
|
|
144
|
+
'game linked',
|
|
145
|
+
'warn',
|
|
146
|
+
'this directory is not linked to a game',
|
|
147
|
+
'ravensight-playtest init --game <gameId>'
|
|
148
|
+
));
|
|
149
|
+
} else if (whoami) {
|
|
150
|
+
const visible = (whoami.games || []).some(game => game.gameId === gameId);
|
|
151
|
+
rows.push(row(
|
|
152
|
+
'game visible',
|
|
153
|
+
visible ? 'ok' : 'fail',
|
|
154
|
+
visible ? gameId : `${gameId} is not visible to this token`,
|
|
155
|
+
visible ? null : 'approve this game for the token, or run login again'
|
|
156
|
+
));
|
|
157
|
+
|
|
158
|
+
/* -------------------------------------------------- balance vs price */
|
|
159
|
+
|
|
160
|
+
if (visible) {
|
|
161
|
+
const modules = flags.modules || (config && config.modules) || ['game_profile', 'persona_playtest'];
|
|
162
|
+
const personas = flags.personas || (config && config.personas) || [];
|
|
163
|
+
try {
|
|
164
|
+
const estimate = await authed.jobs.estimate(gameId, { modules, personas });
|
|
165
|
+
if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
|
|
166
|
+
rows.push(row(
|
|
167
|
+
'balance',
|
|
168
|
+
'warn',
|
|
169
|
+
`estimate ${ui.money(estimate.estimate_cents)}, balance hidden by your role`,
|
|
170
|
+
'ask an owner or admin to check the balance'
|
|
171
|
+
));
|
|
172
|
+
} else {
|
|
173
|
+
rows.push(row(
|
|
174
|
+
'balance',
|
|
175
|
+
estimate.ok ? 'ok' : 'fail',
|
|
176
|
+
`estimate ${ui.money(estimate.estimate_cents)}, available ${ui.money(estimate.balance_cents)}`,
|
|
177
|
+
estimate.ok ? null : `top up at ${estimate.topup_url}`
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
// Not a failure and not hidden by role: a job this size is still
|
|
181
|
+
// allowed, it may just outrun its wall clock, which is worth seeing
|
|
182
|
+
// here rather than only after `run` has already asked for a price.
|
|
183
|
+
if (estimate.wall_warning) {
|
|
184
|
+
rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
rows.push(row(
|
|
188
|
+
'balance',
|
|
189
|
+
'warn',
|
|
190
|
+
error instanceof ApiError ? error.message : String(error && error.message)
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ------------------------------------------------------ pack cache */
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const pack = await packs.get({ api: authed, gameId, modules });
|
|
198
|
+
const manifest = (await authed.packs.manifest()).body;
|
|
199
|
+
const verified = packs.verifyAgainstManifest(manifest, pack.bundle);
|
|
200
|
+
rows.push(row(
|
|
201
|
+
'content pack',
|
|
202
|
+
verified.ok ? 'ok' : 'fail',
|
|
203
|
+
verified.ok
|
|
204
|
+
? `${plural(pack.bundle.personas.length, 'persona')}, ${plural(verified.checked, 'component')} verified${pack.stale ? ' (cached)' : ''}`
|
|
205
|
+
: `these components do not match the manifest: ${verified.mismatched.join(', ')}`,
|
|
206
|
+
verified.ok ? null : `delete ${paths.packCacheDir(apiUrl)} and run check again`
|
|
207
|
+
));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
rows.push(row(
|
|
210
|
+
'content pack',
|
|
211
|
+
'fail',
|
|
212
|
+
error instanceof ApiError ? error.message : String(error && error.message),
|
|
213
|
+
'the CLI cannot run without personas and schemas'
|
|
214
|
+
));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* --------------------------------------------------------- the machine */
|
|
221
|
+
|
|
222
|
+
const repoRoot = flags.repoRoot || process.cwd();
|
|
223
|
+
const free = await freeBytes(repoRoot);
|
|
224
|
+
rows.push(row(
|
|
225
|
+
'disk',
|
|
226
|
+
free === null ? 'warn' : free >= DISK_WARN_BYTES ? 'ok' : 'warn',
|
|
227
|
+
free === null ? 'could not read free space' : `${ui.bytes(free)} free in ${repoRoot}`,
|
|
228
|
+
'screenshots and a video take 50 to 300 MB per run'
|
|
229
|
+
));
|
|
230
|
+
|
|
231
|
+
const machine = recommendConcurrency(deps.machine);
|
|
232
|
+
rows.push(row(
|
|
233
|
+
'cpu and memory',
|
|
234
|
+
'ok',
|
|
235
|
+
`${machine.cpus} logical cpus, ${machine.freeMemGb} GB free, use concurrency ${machine.recommended}`
|
|
236
|
+
));
|
|
237
|
+
|
|
238
|
+
const ffmpeg = await (deps.detectFfmpeg || detectFfmpeg)();
|
|
239
|
+
rows.push(row(
|
|
240
|
+
'ffmpeg (optional)',
|
|
241
|
+
ffmpeg.path ? 'ok' : 'warn',
|
|
242
|
+
ffmpeg.path ? `${ffmpeg.path}: ${ffmpeg.version}` : 'not found',
|
|
243
|
+
'needed only to re-encode a session video before upload'
|
|
244
|
+
));
|
|
245
|
+
|
|
246
|
+
const godot = await (deps.inspectGodot || inspectGodot)(
|
|
247
|
+
{ repoRoot, godotPath: flags.godotPath },
|
|
248
|
+
deps
|
|
249
|
+
);
|
|
250
|
+
rows.push(row(
|
|
251
|
+
'godot (optional)',
|
|
252
|
+
godot.binary.path ? 'ok' : 'warn',
|
|
253
|
+
godot.binary.path
|
|
254
|
+
? `${godot.binary.path}${godot.binary.version ? `: ${godot.binary.version}` : ''}`
|
|
255
|
+
// The detector's own message says WHY: nothing found at all, versus a
|
|
256
|
+
// --godot-path that exists but is not a Godot 4.2+ binary. Those two want
|
|
257
|
+
// different fixes, and "not found" would hide the second.
|
|
258
|
+
: godot.binary.reason || 'not found',
|
|
259
|
+
'needed only for a godot_driver run; set RAVENSIGHT_GODOT to point at it'
|
|
260
|
+
));
|
|
261
|
+
if (godot.isProject) {
|
|
262
|
+
// Only reported for a Godot repo: telling a web game's developer that their
|
|
263
|
+
// project.godot does not pin a version would be noise about a file they do
|
|
264
|
+
// not have. But for a Godot repo the row always appears, including when a
|
|
265
|
+
// version could not be read: "not checked" and "checked and fine" must not
|
|
266
|
+
// look the same.
|
|
267
|
+
const unknownDetail = {
|
|
268
|
+
both: 'could not read the engine version from project.godot, and no Godot binary was found',
|
|
269
|
+
project: 'could not read the engine version from project.godot',
|
|
270
|
+
binary: `project asks for ${godot.projectVersion}, and no Godot binary was found to compare`
|
|
271
|
+
};
|
|
272
|
+
rows.push(row(
|
|
273
|
+
'godot project',
|
|
274
|
+
godot.matches === false || godot.unknown ? 'warn' : 'ok',
|
|
275
|
+
godot.matches === false
|
|
276
|
+
? `project asks for ${godot.projectVersion}, found ${godot.binary.version}`
|
|
277
|
+
: godot.unknown
|
|
278
|
+
? unknownDetail[godot.unknown]
|
|
279
|
+
: `project.godot asks for ${godot.projectVersion}, and that is what was found`,
|
|
280
|
+
godot.matches === false
|
|
281
|
+
? 'install the matching Godot, or set RAVENSIGHT_GODOT to it'
|
|
282
|
+
: godot.unknown
|
|
283
|
+
? 'only matters for a godot_driver run'
|
|
284
|
+
: null
|
|
285
|
+
));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { rows, ok: rows.every(entry => entry.status !== 'fail') };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `ravensight-playtest check`
|
|
293
|
+
* @param {Object} [flags]
|
|
294
|
+
* @param {Object} [deps]
|
|
295
|
+
* @returns {Promise<number>} exit code, 3 when anything failed
|
|
296
|
+
*/
|
|
297
|
+
export async function check(flags = {}, deps = {}) {
|
|
298
|
+
const { rows, ok } = await runChecks(flags, deps);
|
|
299
|
+
|
|
300
|
+
if (flags.json) {
|
|
301
|
+
ui.json({ ok, checks: rows });
|
|
302
|
+
return ok ? ExitCode.OK : ExitCode.ENVIRONMENT;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
ui.table(rows, [
|
|
306
|
+
{ key: 'status', label: '' },
|
|
307
|
+
{ key: 'name', label: 'CHECK' },
|
|
308
|
+
{ key: 'detail', label: 'DETAIL' }
|
|
309
|
+
]);
|
|
310
|
+
ui.blank();
|
|
311
|
+
for (const entry of rows) {
|
|
312
|
+
if (entry.status !== 'ok' && entry.fix) ui.info(`${entry.name}: ${entry.fix}`);
|
|
313
|
+
}
|
|
314
|
+
ui.blank();
|
|
315
|
+
if (ok) ui.ok('Ready.');
|
|
316
|
+
else ui.error('Some checks failed. Fix the ones above and run check again.');
|
|
317
|
+
return ok ? ExitCode.OK : ExitCode.ENVIRONMENT;
|
|
318
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stand-in for cli-core, shaped exactly like `src/run/deps.js` normalises the
|
|
3
|
+
* real `src/api/index.js` barrel into.
|
|
4
|
+
*
|
|
5
|
+
* Shared between the `run`, `profile` and `resume` tests, because all three go
|
|
6
|
+
* through the same context, the same pack and the same job routes, and three
|
|
7
|
+
* copies of this would drift.
|
|
8
|
+
*
|
|
9
|
+
* **It enforces the server's run state machine.** `RUN_TRANSITIONS` here is a
|
|
10
|
+
* copy of the server's table (`PlaytestRun.js`, re-exported by cli-core's
|
|
11
|
+
* `src/states.js`), and a hop that is not in it throws the 409
|
|
12
|
+
* `invalid_transition` the real server would. A fake that accepts every
|
|
13
|
+
* transition makes an illegal one invisible, which is how a CLI ships a state
|
|
14
|
+
* machine the server refuses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SKILL = ['---', 'name: persona-playtest', 'prompt_version: persona-playtest@0.1.0', '---', '# Persona Playtest'].join('\n');
|
|
18
|
+
const PROFILE_SKILL = ['---', 'name: game-profile', 'prompt_version: game-profile@0.1.0', '---', '# Game Profile'].join('\n');
|
|
19
|
+
|
|
20
|
+
/** The server's table, verbatim. */
|
|
21
|
+
export const RUN_TRANSITIONS = Object.freeze({
|
|
22
|
+
queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
|
|
23
|
+
launching: ['playing', 'failed', 'canceled', 'interrupted'],
|
|
24
|
+
playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
|
|
25
|
+
reporting: ['uploading', 'failed', 'interrupted'],
|
|
26
|
+
uploading: ['succeeded', 'failed', 'interrupted'],
|
|
27
|
+
interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
|
|
28
|
+
succeeded: [],
|
|
29
|
+
failed: [],
|
|
30
|
+
canceled: [],
|
|
31
|
+
budget_exceeded: [],
|
|
32
|
+
skipped: []
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const RUN_TERMINAL_STATES = Object.freeze(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped']);
|
|
36
|
+
|
|
37
|
+
/** Shaped like cli-core's `ApiError`: `status`, `code`, `body`. */
|
|
38
|
+
export class FakeApiError extends Error {
|
|
39
|
+
constructor({ status, code, message, body = {} }) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'ApiError';
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.body = body;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Enough of the report schema for the runner's validator to be real. */
|
|
49
|
+
export const REPORT_SCHEMA = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
required: ['schema_version', 'run_id', 'job_id', 'findings', 'sentiment', 'would_continue', 'usage', 'quit_reason', 'actions_taken'],
|
|
52
|
+
properties: {
|
|
53
|
+
schema_version: { const: '1.0' },
|
|
54
|
+
run_id: { type: 'string' },
|
|
55
|
+
job_id: { type: 'string' },
|
|
56
|
+
persona: { type: 'string' },
|
|
57
|
+
driver: { type: 'string' },
|
|
58
|
+
target: { type: 'string' },
|
|
59
|
+
prompt_version: { type: 'string' },
|
|
60
|
+
started_at: { type: 'string' },
|
|
61
|
+
ended_at: { type: 'string' },
|
|
62
|
+
actions_taken: { type: 'integer' },
|
|
63
|
+
quit_reason: { type: 'string' },
|
|
64
|
+
quit_detail: { type: 'string' },
|
|
65
|
+
findings: { type: 'array' },
|
|
66
|
+
sentiment: { type: 'object' },
|
|
67
|
+
would_continue: { type: 'boolean' },
|
|
68
|
+
usage: { type: 'object' }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const CAPABILITY_SCHEMA = {
|
|
73
|
+
type: 'object',
|
|
74
|
+
required: ['schema_version', 'job_id', 'game', 'eligibility', 'warnings'],
|
|
75
|
+
properties: {
|
|
76
|
+
schema_version: { const: '1.0' },
|
|
77
|
+
job_id: { type: 'string' },
|
|
78
|
+
generated_at: { type: 'string' },
|
|
79
|
+
game: { type: 'object' },
|
|
80
|
+
source: { type: 'object' },
|
|
81
|
+
logic_split: { type: 'object' },
|
|
82
|
+
telemetry: { type: 'object' },
|
|
83
|
+
builds: { type: 'object' },
|
|
84
|
+
eligibility: { type: 'array' },
|
|
85
|
+
estimates: { type: 'array' },
|
|
86
|
+
asks_for_customer: { type: 'array' },
|
|
87
|
+
warnings: { type: 'array' }
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {Object} [overrides]
|
|
93
|
+
* @returns {Object} a pack bundle, the shape `packs.get()` answers
|
|
94
|
+
*/
|
|
95
|
+
export function fakePack(overrides = {}) {
|
|
96
|
+
return {
|
|
97
|
+
pack_version: '1.0.0',
|
|
98
|
+
personas: [
|
|
99
|
+
{ slug: 'curious-kid', name: 'Curious Kid', patience_actions: 25, model_tier_hint: 'tier_fast', body: 'clicks everything' },
|
|
100
|
+
{ slug: 'grumpy-veteran', name: 'Grumpy Veteran', patience_actions: 30, model_tier_hint: 'tier_frontier', body: 'has seen it all' },
|
|
101
|
+
{ slug: 'speedrunner', name: 'Speedrunner', patience_actions: 40, model_tier_hint: 'tier_standard', body: 'goes fast' }
|
|
102
|
+
],
|
|
103
|
+
skills: [
|
|
104
|
+
{ module: 'persona_playtest', name: 'persona-playtest', content: SKILL },
|
|
105
|
+
{ module: 'game_profile', name: 'game-profile', content: PROFILE_SKILL }
|
|
106
|
+
],
|
|
107
|
+
schemas: [
|
|
108
|
+
{ name: 'playtest-report', content: REPORT_SCHEMA },
|
|
109
|
+
{ name: 'capability-report', content: CAPABILITY_SCHEMA }
|
|
110
|
+
],
|
|
111
|
+
// Mirrors the server's clientRouting() shape (src/services/routingService.js):
|
|
112
|
+
// step names and the per-run action ceiling, plus the persona ceiling and
|
|
113
|
+
// the wall-clock figures run.js's local persona check and price prompt read.
|
|
114
|
+
routing: {
|
|
115
|
+
max_actions: 150,
|
|
116
|
+
steps: {},
|
|
117
|
+
max_personas: 100,
|
|
118
|
+
wall_warning_personas: 8,
|
|
119
|
+
job_wall_seconds_max: 5400,
|
|
120
|
+
wall_seconds_max_per_run: 900
|
|
121
|
+
},
|
|
122
|
+
code_brief: null,
|
|
123
|
+
engine: 'web',
|
|
124
|
+
intent_notes: [],
|
|
125
|
+
...overrides
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {Object} [fields]
|
|
131
|
+
* @returns {Object} a brief record, the shape `api.brief.get` answers
|
|
132
|
+
*/
|
|
133
|
+
export function fakeBrief(fields = {}) {
|
|
134
|
+
return {
|
|
135
|
+
brief_id: 'br_1',
|
|
136
|
+
version: 3,
|
|
137
|
+
status: 'complete',
|
|
138
|
+
parent_version: 2,
|
|
139
|
+
fields: {
|
|
140
|
+
goals: [{ text: 'a new player reaches level 2', priority: 1 }],
|
|
141
|
+
success_criteria: 'a new player reaches level 2\nnobody gets stuck in the menu',
|
|
142
|
+
build_notes: { controls: 'arrows and space', how_to_start: 'press start' },
|
|
143
|
+
known_issues: ['the pause menu has no art yet'],
|
|
144
|
+
design_intent_notes: ['the fog is intentional'],
|
|
145
|
+
...fields
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} runId
|
|
152
|
+
* @param {Object} [overrides]
|
|
153
|
+
* @returns {string} a report.json body a model would have written
|
|
154
|
+
*/
|
|
155
|
+
export function fakeReportJson(runId, overrides = {}) {
|
|
156
|
+
return JSON.stringify({
|
|
157
|
+
schema_version: '1.0',
|
|
158
|
+
run_id: runId,
|
|
159
|
+
job_id: 'pt_job_1',
|
|
160
|
+
prompt_version: 'persona-playtest@0.1.0',
|
|
161
|
+
findings: [],
|
|
162
|
+
sentiment: { fun: 3, clarity: 3, frustration: 3 },
|
|
163
|
+
would_continue: true,
|
|
164
|
+
usage: { by_model: [], total_usd: 0 },
|
|
165
|
+
quit_reason: 'persona_quit',
|
|
166
|
+
actions_taken: 0,
|
|
167
|
+
...overrides
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A report.md carrying the four sections the server lints for. */
|
|
172
|
+
export const REPORT_MD = [
|
|
173
|
+
'# Playtest',
|
|
174
|
+
'',
|
|
175
|
+
'## What worked',
|
|
176
|
+
'The start button worked.',
|
|
177
|
+
'',
|
|
178
|
+
'## Top issues',
|
|
179
|
+
'None.',
|
|
180
|
+
'',
|
|
181
|
+
"## What this run didn't cover",
|
|
182
|
+
'Anything past the title screen.',
|
|
183
|
+
'',
|
|
184
|
+
'## Suggested next step',
|
|
185
|
+
'Run the speedrunner next.',
|
|
186
|
+
''
|
|
187
|
+
].join('\n');
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {Object} [options]
|
|
191
|
+
* @returns {Object} deps, with a `calls` log and a `runStates` map
|
|
192
|
+
*/
|
|
193
|
+
export function fakeCore({
|
|
194
|
+
estimate = {},
|
|
195
|
+
register = {},
|
|
196
|
+
runs = null,
|
|
197
|
+
config = null,
|
|
198
|
+
publish = true,
|
|
199
|
+
aggregateFinishes = false,
|
|
200
|
+
brief = fakeBrief(),
|
|
201
|
+
pack = fakePack()
|
|
202
|
+
} = {}) {
|
|
203
|
+
const calls = [];
|
|
204
|
+
const record = (name, payload) => calls.push({ name, ...payload });
|
|
205
|
+
const defaultRuns = [
|
|
206
|
+
{ run_id: 'pt_run_a', module: 'persona_playtest', persona: 'curious-kid', playtest_token: 'tok_a', budget_usd: 0.6 },
|
|
207
|
+
{ run_id: 'pt_run_b', module: 'persona_playtest', persona: 'grumpy-veteran', playtest_token: 'tok_b', budget_usd: 0.6 },
|
|
208
|
+
{ run_id: 'pt_run_agg', module: 'aggregate_report', persona: null, playtest_token: 'tok_agg', budget_usd: 0.3 }
|
|
209
|
+
];
|
|
210
|
+
const plan = runs || defaultRuns;
|
|
211
|
+
|
|
212
|
+
/** Every run's current state, so the table can be enforced. */
|
|
213
|
+
const runStates = new Map(plan.map(entry => [entry.run_id, entry.state || 'queued']));
|
|
214
|
+
|
|
215
|
+
const move = (runId, to) => {
|
|
216
|
+
const from = runStates.get(runId) || 'queued';
|
|
217
|
+
if (!(RUN_TRANSITIONS[from] || []).includes(to)) {
|
|
218
|
+
throw new FakeApiError({
|
|
219
|
+
status: 409,
|
|
220
|
+
code: 'invalid_transition',
|
|
221
|
+
message: `A run cannot go from ${from} to ${to}.`,
|
|
222
|
+
body: { from, to }
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
runStates.set(runId, to);
|
|
226
|
+
return to;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const api = {
|
|
230
|
+
apiUrl: 'https://api.test',
|
|
231
|
+
token: 'gt_cli_test',
|
|
232
|
+
cliVersion: '0.1.0',
|
|
233
|
+
runStates,
|
|
234
|
+
brief: {
|
|
235
|
+
async get(gameId) {
|
|
236
|
+
record('brief', { gameId });
|
|
237
|
+
return brief ? { brief, rendered: '# brief' } : { brief: null };
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
jobs: {
|
|
241
|
+
async estimate(gameId, body) {
|
|
242
|
+
record('estimate', { gameId, body });
|
|
243
|
+
return {
|
|
244
|
+
estimate_cents: 200,
|
|
245
|
+
breakdown: [{ unit: 'playtest_run', count: 2, unit_cents: 100 }],
|
|
246
|
+
balance_cents: 1000,
|
|
247
|
+
ok: true,
|
|
248
|
+
topup_url: 'https://pay.test',
|
|
249
|
+
...estimate
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
async register(gameId, body, options) {
|
|
253
|
+
record('register', { gameId, body, options });
|
|
254
|
+
if (register.throws) throw register.throws;
|
|
255
|
+
return {
|
|
256
|
+
job_id: 'pt_job_1',
|
|
257
|
+
replayed: false,
|
|
258
|
+
price: { price_cents: 200, breakdown: [] },
|
|
259
|
+
links: { dashboard: 'https://dash.test/pt_job_1' },
|
|
260
|
+
runs: plan,
|
|
261
|
+
...register
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
async uploads(gameId, jobId, body) {
|
|
265
|
+
record('uploads', { jobId, body });
|
|
266
|
+
return { urls: body.files.map(file => ({ path: file.path, put_url: 'https://s3.test/x', tagging: null })) };
|
|
267
|
+
},
|
|
268
|
+
async aggregateComplete(gameId, jobId, body) {
|
|
269
|
+
record('aggregateComplete', { jobId, body });
|
|
270
|
+
return aggregateFinishes
|
|
271
|
+
? { aggregate: {}, run: { run_id: 'pt_run_agg', state: 'succeeded' } }
|
|
272
|
+
: { aggregate: {} };
|
|
273
|
+
},
|
|
274
|
+
async capabilityReport(gameId, jobId, body) {
|
|
275
|
+
record('capabilityReport', { jobId, body });
|
|
276
|
+
return { capability_report: { accepted: true } };
|
|
277
|
+
},
|
|
278
|
+
async finish(gameId, jobId, body) {
|
|
279
|
+
record('finish', { jobId, body });
|
|
280
|
+
return { job: { state: 'succeeded' }, refunded_cents: 0, already_settled: false };
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
runs: {
|
|
284
|
+
async heartbeat(gameId, jobId, runId, options) {
|
|
285
|
+
record('heartbeat', { runId, options });
|
|
286
|
+
return { run: { usage: { total_usd: 0.11 } }, cancel_requested: false };
|
|
287
|
+
},
|
|
288
|
+
async transition(gameId, jobId, runId, state, options) {
|
|
289
|
+
record('transition', { runId, state, options });
|
|
290
|
+
move(runId, state);
|
|
291
|
+
return { run: { run_id: runId, state }, cancel_requested: false };
|
|
292
|
+
},
|
|
293
|
+
async complete(gameId, jobId, runId, body) {
|
|
294
|
+
record('complete', { runId, body });
|
|
295
|
+
const from = runStates.get(runId) || 'queued';
|
|
296
|
+
if (RUN_TERMINAL_STATES.includes(from)) {
|
|
297
|
+
throw new FakeApiError({ status: 409, code: 'run_already_complete', message: 'That run has already finished.', body: { state: from } });
|
|
298
|
+
}
|
|
299
|
+
runStates.set(runId, body.state);
|
|
300
|
+
return { run: { run_id: runId, state: body.state }, dropped_findings: 0, report_lint: [] };
|
|
301
|
+
},
|
|
302
|
+
async list(gameId, jobId) {
|
|
303
|
+
record('list', { jobId });
|
|
304
|
+
return { runs: plan.map(entry => ({ ...entry, state: runStates.get(entry.run_id), actions_taken: 0 })) };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const deps = {
|
|
310
|
+
calls,
|
|
311
|
+
api,
|
|
312
|
+
runStates,
|
|
313
|
+
async createContext(options) {
|
|
314
|
+
record('createContext', { options });
|
|
315
|
+
return { api, apiUrl: api.apiUrl, token: api.token, config, repoRoot: options.repoRoot };
|
|
316
|
+
},
|
|
317
|
+
resolveGameId: (flags, loaded) => flags.game || (loaded && loaded.game_id) || 'g_1',
|
|
318
|
+
cliVersion: '0.1.0',
|
|
319
|
+
brief: {
|
|
320
|
+
async get({ api: client, gameId }) {
|
|
321
|
+
const answer = await client.brief.get(gameId);
|
|
322
|
+
return (answer && answer.brief) || null;
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
packs: {
|
|
326
|
+
async get(options) {
|
|
327
|
+
record('packs', { options });
|
|
328
|
+
return pack;
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
async finalizeRun(dir, options) {
|
|
332
|
+
record('finalizeRun', { dir, options });
|
|
333
|
+
// The real one uploads and then calls `complete`, so the fake moves the
|
|
334
|
+
// run the same way: a `finalizeRun` on an already terminal run is a 409
|
|
335
|
+
// there too.
|
|
336
|
+
return api.runs.complete(null, options.jobId, options.runId, options.body);
|
|
337
|
+
},
|
|
338
|
+
async uploadRunDir(dir, options) {
|
|
339
|
+
record('uploadRunDir', { dir, options });
|
|
340
|
+
return { uploaded: [] };
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
if (publish) {
|
|
344
|
+
deps.publishJobFiles = async payload => {
|
|
345
|
+
record('publishJobFiles', { payload });
|
|
346
|
+
return Object.fromEntries(payload.files.map(name => [name, { size: 10, sha256: 'a'.repeat(64) }]));
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return deps;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A driver that answers instantly and records nothing the tests need.
|
|
354
|
+
* @returns {Object}
|
|
355
|
+
*/
|
|
356
|
+
export function fakeDriver() {
|
|
357
|
+
let step = 0;
|
|
358
|
+
return {
|
|
359
|
+
async launch() {},
|
|
360
|
+
async observe() {
|
|
361
|
+
step += 1;
|
|
362
|
+
return { step, text: 'the title screen of a small game about bunnies', url: 'http://game.test/', elapsedMs: 1 };
|
|
363
|
+
},
|
|
364
|
+
async act() {
|
|
365
|
+
return { ok: true, changed: true };
|
|
366
|
+
},
|
|
367
|
+
async screenshot(name) {
|
|
368
|
+
return { path: `/tmp/${name}.png`, relativePath: `screenshots/01-${name}.png` };
|
|
369
|
+
},
|
|
370
|
+
async consoleErrors() {
|
|
371
|
+
return [];
|
|
372
|
+
},
|
|
373
|
+
async stop() {
|
|
374
|
+
return { videoPath: null };
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export default fakeCore;
|