ravensight-playtest 0.5.2 → 0.7.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/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { capabilityLive } from '../run/acceptance.js';
|
|
8
|
+
import { finishJob } from '../run/settle.js';
|
|
9
|
+
import { getDeps } from '../run/deps.js';
|
|
10
|
+
import { DIMENSION_3D, dimensionGate } from '../run/dimension.js';
|
|
11
|
+
import { GodotDriver } from '../run/drivers/godot.js';
|
|
12
|
+
import EXIT from '../run/exit.js';
|
|
13
|
+
import { jobDir, runDir as runDirFor } from '../run/paths.js';
|
|
14
|
+
import { checkoutRef } from '../run/replay.js';
|
|
15
|
+
import * as stateFile from '../run/state.js';
|
|
16
|
+
import { seedFrom } from '../run/trace.js';
|
|
17
|
+
import { playJob, explainRegisterRefusal, resolvePersonas, resolveSetup, formatCents, startBalanceLine, startBalanceShort } from './run.js';
|
|
18
|
+
import { unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
|
|
19
|
+
import { loadLocalBrief } from '../briefLocal.js';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `ravensight-playtest validate <branch>`: N persona runs on the base and N on
|
|
25
|
+
* the head of one Godot project, and what changed between them.
|
|
26
|
+
*
|
|
27
|
+
* Every commit is extracted to a temp directory, so the developer's checkout is
|
|
28
|
+
* not touched, and both must import and boot BEFORE anything is priced: a head
|
|
29
|
+
* that does not run is a free answer. The whole declared plan is one charge at
|
|
30
|
+
* register, so a balance that runs out cannot leave one side short. A pair is the
|
|
31
|
+
* same persona on the same seed, one run on each side.
|
|
32
|
+
*
|
|
33
|
+
* The verdict is the server's, computed from what the runs reported. This command
|
|
34
|
+
* prints it and never words it more strongly than the server did.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_RUNS_PER_SIDE = 3;
|
|
38
|
+
export const MAX_RUNS_PER_SIDE = 10;
|
|
39
|
+
|
|
40
|
+
const CAPABILITY = 'validate';
|
|
41
|
+
|
|
42
|
+
/** The default base: what origin says its default branch is, else main. */
|
|
43
|
+
export async function defaultBase(repoRoot) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
46
|
+
return stdout.trim().replace(/^origin\//, '') || 'main';
|
|
47
|
+
} catch {
|
|
48
|
+
return 'main';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The two commits import, boot and stop, free, before any price is shown.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<string|null>} why not, or null when both run
|
|
56
|
+
*/
|
|
57
|
+
export async function bootProblem(checked, { godotPath, makeDriver, headless = true }) {
|
|
58
|
+
const driver = makeDriver({ projectDir: checked.projectDir, godotPath, headless, lockstep: true, seed: 1, recordTrace: false });
|
|
59
|
+
try {
|
|
60
|
+
await driver.launch({});
|
|
61
|
+
return null;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return String(error && error.message).slice(0, 500);
|
|
64
|
+
} finally {
|
|
65
|
+
await driver.stop({ reason: 'preflight' }).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function number(value) {
|
|
70
|
+
if (value === null || value === undefined) return 'n/a';
|
|
71
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The server's comparison, as lines. Its notes are its words, not this command's. */
|
|
75
|
+
export function reportLines(report) {
|
|
76
|
+
const lines = [
|
|
77
|
+
`Base ${report.base.ref} (${report.base.sha.slice(0, 12)}): ${report.base.usable} usable of ${report.base.planned} runs.`,
|
|
78
|
+
`Head ${report.head.ref} (${report.head.sha.slice(0, 12)}): ${report.head.usable} usable of ${report.head.planned} runs.`
|
|
79
|
+
];
|
|
80
|
+
for (const metric of report.metrics) {
|
|
81
|
+
lines.push(`${metric.label}: base ${number(metric.base.value)} (n=${metric.base.n}), head ${number(metric.head.value)} (n=${metric.head.n}), change ${number(metric.delta)}. ${metric.note}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push(report.verdict_note);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} [head] the branch to validate, default HEAD
|
|
89
|
+
* @param {Object} flags
|
|
90
|
+
* @param {Object} [injected] test seam
|
|
91
|
+
* @returns {Promise<number>} an exit code
|
|
92
|
+
*/
|
|
93
|
+
export async function validateCommand(head, flags = {}, injected = {}) {
|
|
94
|
+
const deps = injected.deps || (await getDeps());
|
|
95
|
+
const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
96
|
+
const options = {
|
|
97
|
+
apiUrl: flags.apiUrl,
|
|
98
|
+
repo: flags.repoRoot,
|
|
99
|
+
game: flags.game,
|
|
100
|
+
yes: flags.yes,
|
|
101
|
+
headless: flags.headless === true,
|
|
102
|
+
godotPath: flags.godotPath,
|
|
103
|
+
concurrency: flags.concurrency,
|
|
104
|
+
personas: flags.personas,
|
|
105
|
+
uploadTrace: true,
|
|
106
|
+
skipAggregate: true
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const setup = await resolveSetup(deps, options, log);
|
|
110
|
+
if (setup.code !== undefined) return setup.code;
|
|
111
|
+
const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
|
|
112
|
+
|
|
113
|
+
// A resume replays the original register: same key, same plan, no second charge.
|
|
114
|
+
const resumed = flags.resume ? await stateFile.load(repoRoot, flags.resume) : null;
|
|
115
|
+
if (flags.resume && !resumed?.validate) {
|
|
116
|
+
log(`There is no validate journal for ${flags.resume} in this repository.`);
|
|
117
|
+
return EXIT.ENVIRONMENT;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const size = resumed ? resumed.validate.register.validate.runs_per_side : Number(flags.runs ?? DEFAULT_RUNS_PER_SIDE);
|
|
121
|
+
if (!Number.isInteger(size) || size < 1 || size > MAX_RUNS_PER_SIDE) {
|
|
122
|
+
log(`--runs must be a whole number from 1 to ${MAX_RUNS_PER_SIDE}. Nothing was charged.`);
|
|
123
|
+
return EXIT.ENVIRONMENT;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let labs = null;
|
|
127
|
+
try { labs = await api.labs.get(gameId); } catch { labs = null; }
|
|
128
|
+
if (!capabilityLive(labs, CAPABILITY)) {
|
|
129
|
+
log('Validate changes is part of Ravensight Labs (beta), and it is not on for this game. Turn Labs on from the Labs page in the dashboard. Nothing was charged.');
|
|
130
|
+
return EXIT.ENVIRONMENT;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const project = path.resolve(repoRoot, flags.godotProject || context.config?.godot_project || '.');
|
|
134
|
+
const subdir = path.relative(repoRoot, project);
|
|
135
|
+
if (subdir.startsWith('..')) {
|
|
136
|
+
log('The Godot project has to be inside this repository so its commits can be extracted. Nothing was charged.');
|
|
137
|
+
return EXIT.ENVIRONMENT;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const baseRef = resumed ? resumed.validate.register.validate.base_ref : (flags.base || await defaultBase(repoRoot));
|
|
141
|
+
const headRef = resumed ? resumed.validate.register.validate.head_ref : (head || 'HEAD');
|
|
142
|
+
const checkout = injected.checkoutRef || checkoutRef;
|
|
143
|
+
const makeProbe = injected.makeDriver || (driverOptions => new GodotDriver(driverOptions));
|
|
144
|
+
|
|
145
|
+
const pack = await deps.packs.get({ api, gameId, modules: ['validate', 'persona_playtest'] });
|
|
146
|
+
const personaSlugs = resumed
|
|
147
|
+
? resumed.validate.register.personas
|
|
148
|
+
: resolvePersonas({ personas: flags.personas }, context.config, pack);
|
|
149
|
+
const unknown = unknownPersonas(personaSlugs, pack.personas || []);
|
|
150
|
+
if (unknown.length > 0) {
|
|
151
|
+
log(`${unknownPersonaMessage(unknown, pack.personas || [])} Nothing was registered or charged.`);
|
|
152
|
+
return EXIT.ENVIRONMENT;
|
|
153
|
+
}
|
|
154
|
+
if (personaSlugs.length > size) {
|
|
155
|
+
log(`${personaSlugs.length} personas need at least ${personaSlugs.length} runs a side. Nothing was charged.`);
|
|
156
|
+
return EXIT.ENVIRONMENT;
|
|
157
|
+
}
|
|
158
|
+
const personas = personaSlugs.map(slug => (pack.personas || []).find(persona => persona.slug === slug));
|
|
159
|
+
|
|
160
|
+
const brief = await deps.brief.get({ api, gameId });
|
|
161
|
+
if (!brief || brief.status !== 'complete') {
|
|
162
|
+
log('A persona run needs a complete expectations brief. Run "ravensight-playtest brief" first. Nothing was charged.');
|
|
163
|
+
return EXIT.ENVIRONMENT;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let baseCheckout = null;
|
|
167
|
+
let headCheckout = null;
|
|
168
|
+
try {
|
|
169
|
+
log(`Extracting ${baseRef} and ${headRef} and checking that both start. This costs nothing.`);
|
|
170
|
+
try {
|
|
171
|
+
baseCheckout = await checkout({ repoRoot, ref: baseRef, projectSubdir: subdir, godotPath: flags.godotPath });
|
|
172
|
+
headCheckout = await checkout({ repoRoot, ref: headRef, projectSubdir: subdir, godotPath: flags.godotPath });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
log(`${String(error && error.message)} Nothing was charged.`);
|
|
175
|
+
return EXIT.ENVIRONMENT;
|
|
176
|
+
}
|
|
177
|
+
if (baseCheckout.sha === headCheckout.sha) {
|
|
178
|
+
log(`${headRef} and ${baseRef} are the same commit, so there is nothing to compare. Nothing was charged.`);
|
|
179
|
+
return EXIT.ENVIRONMENT;
|
|
180
|
+
}
|
|
181
|
+
// Free, before a price. A resumed job was already asked for and paid for.
|
|
182
|
+
if (!resumed) {
|
|
183
|
+
for (const [name, checked] of [[baseRef, baseCheckout], [headRef, headCheckout]]) {
|
|
184
|
+
const { refusal, dimension } = await dimensionGate({ projectDir: checked.projectDir, alpha3d: Boolean(flags.alpha3d), name: 'Validate changes' });
|
|
185
|
+
if (refusal) {
|
|
186
|
+
log(`${name}: ${refusal} Nothing was charged.`);
|
|
187
|
+
return EXIT.ENVIRONMENT;
|
|
188
|
+
}
|
|
189
|
+
if (dimension === DIMENSION_3D && name === headRef) log('3D Alpha: a proof of concept on one template. It is priced as any validation is, and a persona plays with keys and input actions, never mouse look.');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const [name, checked] of [[baseRef, baseCheckout], [headRef, headCheckout]]) {
|
|
193
|
+
const problem = await bootProblem(checked, { godotPath: flags.godotPath, makeDriver: makeProbe, headless: true });
|
|
194
|
+
if (problem) {
|
|
195
|
+
log(`${name} does not start: ${problem}`);
|
|
196
|
+
log('Nothing was charged.');
|
|
197
|
+
return EXIT.FAILED;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body = resumed ? resumed.validate.register : {
|
|
202
|
+
modules: ['validate'],
|
|
203
|
+
personas: personaSlugs,
|
|
204
|
+
driver: 'godot_driver',
|
|
205
|
+
cli_version: cliVersion,
|
|
206
|
+
brief_version: brief.version ?? null,
|
|
207
|
+
build: { kind: 'godot_project', ref: subdir || '.' },
|
|
208
|
+
repo: { commit: headCheckout.sha, dirty: false, branch: headRef.slice(0, 200) },
|
|
209
|
+
pack_versions: { pack: pack.pack_version },
|
|
210
|
+
validate: { base_ref: baseRef.slice(0, 200), base_sha: baseCheckout.sha, head_ref: headRef.slice(0, 200), head_sha: headCheckout.sha, runs_per_side: size }
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
let idempotencyKey = resumed ? resumed.idempotency_key : randomUUID();
|
|
214
|
+
if (!resumed) {
|
|
215
|
+
let estimate;
|
|
216
|
+
try {
|
|
217
|
+
estimate = await api.jobs.estimate(gameId, { modules: ['validate'], personas: personaSlugs, validate: { runs_per_side: size } });
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// Labs went off after the status read. A 403 is not a login problem, so it is
|
|
220
|
+
// said as the register says it, rather than falling through to the login advice.
|
|
221
|
+
if (error && error.code === 'labs_not_enabled') {
|
|
222
|
+
log(String(error.message));
|
|
223
|
+
return EXIT.ENVIRONMENT;
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
const line = estimate.breakdown?.[0];
|
|
228
|
+
if (!line || line.unit !== 'playtest_run' || line.count !== size * 2) {
|
|
229
|
+
log('This server did not quote every run of the plan. Nothing was registered or charged. Update the server and CLI together.');
|
|
230
|
+
return EXIT.ENVIRONMENT;
|
|
231
|
+
}
|
|
232
|
+
log(`This validation: ${size} runs on ${baseRef} and ${size} on ${headRef}, ${formatCents(estimate.estimate_cents)} in total. Balance: ${formatCents(estimate.balance_cents)}.`);
|
|
233
|
+
if (size < MIN_RUNS_FOR_A_VERDICT) log(`With fewer than ${MIN_RUNS_FOR_A_VERDICT} runs a side the result is a direction, never a verdict. --runs ${MIN_RUNS_FOR_A_VERDICT} costs ${formatCents((estimate.estimate_cents / size) * MIN_RUNS_FOR_A_VERDICT)}.`);
|
|
234
|
+
log('A run that does not earn its unit is refunded and does not count. If one side ends with no usable run there is nothing to compare, and the runs on the other side that played and reported keep their units: those runs happened and their reports are real.');
|
|
235
|
+
const floorLine = startBalanceLine(estimate);
|
|
236
|
+
if (floorLine) log(floorLine);
|
|
237
|
+
if (estimate.ok === false && floorLine) {
|
|
238
|
+
log(startBalanceShort(estimate));
|
|
239
|
+
return EXIT.ENVIRONMENT;
|
|
240
|
+
}
|
|
241
|
+
if (estimate.ok === false) {
|
|
242
|
+
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again. Nothing was charged.`);
|
|
243
|
+
return EXIT.ENVIRONMENT;
|
|
244
|
+
}
|
|
245
|
+
if (estimate.wall_warning) log(estimate.wall_warning);
|
|
246
|
+
const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
|
|
247
|
+
if (!flags.yes && confirm && !await confirm(`Validate for ${formatCents(estimate.estimate_cents)}?`, { yes: flags.yes })) {
|
|
248
|
+
log('Nothing was charged.');
|
|
249
|
+
return EXIT.CANCELED;
|
|
250
|
+
}
|
|
251
|
+
body.confirm_price_cents = estimate.estimate_cents;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let registered;
|
|
255
|
+
try {
|
|
256
|
+
registered = await api.jobs.register(gameId, body, { idempotencyKey });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error && error.code === 'labs_not_enabled') {
|
|
259
|
+
log(String(error.message));
|
|
260
|
+
return EXIT.ENVIRONMENT;
|
|
261
|
+
}
|
|
262
|
+
if (error && error.code === 'funding_required') {
|
|
263
|
+
log(`${String(error.message)} Top up at ${error.body?.topup_url || 'ravensight.io'}.`);
|
|
264
|
+
return EXIT.ENVIRONMENT;
|
|
265
|
+
}
|
|
266
|
+
const handled = explainRegisterRefusal(error, log);
|
|
267
|
+
if (handled !== null) return handled;
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const jobId = registered.job_id;
|
|
272
|
+
await mkdir(jobDir(repoRoot, jobId), { recursive: true });
|
|
273
|
+
const journal = resumed || stateFile.newState({
|
|
274
|
+
jobId,
|
|
275
|
+
gameId,
|
|
276
|
+
idempotencyKey,
|
|
277
|
+
cliVersion,
|
|
278
|
+
packVersion: pack.pack_version,
|
|
279
|
+
modules: ['validate'],
|
|
280
|
+
driver: 'godot_driver',
|
|
281
|
+
target: subdir || '.',
|
|
282
|
+
priceCents: registered.price && registered.price.price_cents,
|
|
283
|
+
options: { uploadTrace: true }
|
|
284
|
+
});
|
|
285
|
+
journal.validate = { register: body };
|
|
286
|
+
const addEntries = runs => {
|
|
287
|
+
for (const entry of runs) {
|
|
288
|
+
if (!journal.runs[entry.run_id]) journal.runs[entry.run_id] = stateFile.newRunEntry({ runId: entry.run_id, module: entry.module, persona: entry.persona });
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
addEntries(registered.runs || []);
|
|
292
|
+
await stateFile.save(repoRoot, journal);
|
|
293
|
+
log(`job ${jobId}${registered.replayed ? ' (replayed)' : ''}: ${formatCents(registered.price && registered.price.price_cents)} charged.`);
|
|
294
|
+
|
|
295
|
+
const prepare = runs => runs
|
|
296
|
+
.filter(entry => entry.module === 'persona_playtest')
|
|
297
|
+
.map(entry => {
|
|
298
|
+
if (!entry.validation) throw new Error('This server did not say which side a run plays. Update the server and CLI together.');
|
|
299
|
+
return {
|
|
300
|
+
...entry,
|
|
301
|
+
target: entry.validation.side === 'base' ? baseCheckout.projectDir : headCheckout.projectDir,
|
|
302
|
+
trace_header: { commit: entry.validation.side === 'base' ? baseCheckout.sha : headCheckout.sha, side: entry.validation.side, pair: entry.validation.pair },
|
|
303
|
+
// The same seed for both sides of a pair, which is what makes the pair one.
|
|
304
|
+
seed: seedFrom(`${jobId}:${entry.validation.pair}`)
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
let localBrief = {};
|
|
309
|
+
try {
|
|
310
|
+
localBrief = await loadLocalBrief({ repoRoot });
|
|
311
|
+
} catch (error) {
|
|
312
|
+
log(`${String(error && error.message)} The runs go ahead without expected_noise and test_account.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const playAll = async runs => (injected.playJob || playJob)({
|
|
316
|
+
deps,
|
|
317
|
+
api,
|
|
318
|
+
apiUrl,
|
|
319
|
+
gameId,
|
|
320
|
+
jobId,
|
|
321
|
+
repoRoot,
|
|
322
|
+
runs,
|
|
323
|
+
pack,
|
|
324
|
+
personas,
|
|
325
|
+
driverName: 'godot_driver',
|
|
326
|
+
target: baseCheckout.projectDir,
|
|
327
|
+
options,
|
|
328
|
+
journal,
|
|
329
|
+
config: { ...context.config, game_version: context.config?.game_version },
|
|
330
|
+
brief,
|
|
331
|
+
localBrief,
|
|
332
|
+
injected,
|
|
333
|
+
log,
|
|
334
|
+
finish: false
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
let played = await playAll(prepare(registered.runs || []));
|
|
338
|
+
if (played === EXIT.CANCELED) {
|
|
339
|
+
log(`job ${jobId} was interrupted. "ravensight-playtest validate --resume ${jobId}" carries on with no second charge.`);
|
|
340
|
+
return played;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let report = await api.jobs.validation(gameId, jobId);
|
|
344
|
+
|
|
345
|
+
// MORE PAIRS ARE A SEPARATE PURCHASE, asked for up front with --extend-to and
|
|
346
|
+
// bought only if the first answer could not reach a conclusion.
|
|
347
|
+
const extendTo = Number(flags.extendTo);
|
|
348
|
+
if (report.too_few && Number.isInteger(extendTo) && extendTo > size) {
|
|
349
|
+
const pairs = Math.min(extendTo, MAX_RUNS_PER_SIDE) - size;
|
|
350
|
+
const price = formatCents(pairs * 2 * (registered.price.price_cents / (size * 2)));
|
|
351
|
+
const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
|
|
352
|
+
if (flags.yes || !confirm || await confirm(`Add ${pairs} more run(s) a side for ${price}?`, { yes: flags.yes })) {
|
|
353
|
+
try {
|
|
354
|
+
const added = await api.runs.addValidatePairs(gameId, jobId, pairs);
|
|
355
|
+
addEntries(added.runs || []);
|
|
356
|
+
await stateFile.save(repoRoot, journal);
|
|
357
|
+
log(`Added ${pairs} run(s) a side: ${formatCents(added.price && added.price.price_cents)} charged.`);
|
|
358
|
+
played = await playAll(prepare(added.runs || []));
|
|
359
|
+
if (played === EXIT.CANCELED) return played;
|
|
360
|
+
report = await api.jobs.validation(gameId, jobId);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
log(`More runs could not be added: ${String(error && error.message)}. The comparison stands on the runs it has.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await closeComparison({ api, deps, gameId, jobId, repoRoot, report, runs: registered.runs || [], log });
|
|
368
|
+
const finished = await finishJob({ api, gameId, jobId, body: { reason: '' }, log });
|
|
369
|
+
if (finished) {
|
|
370
|
+
journal.finished = true;
|
|
371
|
+
await stateFile.save(repoRoot, journal);
|
|
372
|
+
log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (flags.json) {
|
|
376
|
+
(deps.ui && deps.ui.json ? deps.ui.json : value => console.log(JSON.stringify(value)))({ job_id: jobId, ...report, refunded_cents: finished ? finished.refunded_cents : null });
|
|
377
|
+
} else {
|
|
378
|
+
for (const line of reportLines(report)) log(line);
|
|
379
|
+
log(`Local reports and traces: ${jobDir(repoRoot, jobId)}. "ravensight-playtest replay <runId> --ref <branch>" plays one again for free.`);
|
|
380
|
+
}
|
|
381
|
+
if (report.verdict === 'not_comparable') return EXIT.FAILED;
|
|
382
|
+
if (flags.failOnRegression && (report.verdict === 'regressed' || report.verdict === 'mixed')) return EXIT.FAILED;
|
|
383
|
+
return EXIT.OK;
|
|
384
|
+
} finally {
|
|
385
|
+
await baseCheckout?.cleanup();
|
|
386
|
+
await headCheckout?.cleanup();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A side needs this many usable runs before the server will call a difference. */
|
|
391
|
+
const MIN_RUNS_FOR_A_VERDICT = 5;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The job's own run: it holds the comparison as a file beside the persona runs.
|
|
395
|
+
* It costs nothing and calls nothing, and the answer the dashboard shows is the
|
|
396
|
+
* server's own computation rather than this upload, so a failure here is logged
|
|
397
|
+
* and does not change the result.
|
|
398
|
+
*/
|
|
399
|
+
async function closeComparison({ api, deps, gameId, jobId, repoRoot, report, runs, log }) {
|
|
400
|
+
const own = runs.find(entry => entry.module === 'validate');
|
|
401
|
+
if (!own) return;
|
|
402
|
+
const directory = runDirFor(repoRoot, jobId, own.run_id);
|
|
403
|
+
try {
|
|
404
|
+
await mkdir(directory, { recursive: true });
|
|
405
|
+
await writeFile(path.join(directory, 'validation-report.json'), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
|
406
|
+
await writeFile(path.join(directory, 'validation-report.md'), String(await api.jobs.validationMarkdown(gameId, jobId)), { mode: 0o600 });
|
|
407
|
+
for (const state of ['launching', 'playing', 'reporting', 'uploading']) {
|
|
408
|
+
await api.runs.transition(gameId, jobId, own.run_id, state, { reason: '' });
|
|
409
|
+
}
|
|
410
|
+
await deps.finalizeRun(directory, { api, gameId, jobId, runId: own.run_id, body: { state: 'succeeded', actions_taken: 0 } });
|
|
411
|
+
} catch (error) {
|
|
412
|
+
log(`The comparison file was not stored (${String(error && error.message)}). The comparison itself is unaffected.`);
|
|
413
|
+
}
|
|
414
|
+
}
|