ravensight-playtest 0.5.2 → 0.6.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 +249 -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 +92 -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 +165 -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 +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- 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
package/src/commands/init.js
CHANGED
|
@@ -6,6 +6,8 @@ import { withAuthHandling } from '../auth/session.js';
|
|
|
6
6
|
import { paths } from '../paths.js';
|
|
7
7
|
import { ui } from '../ui/index.js';
|
|
8
8
|
import { CliError, ExitCode } from '../errors.js';
|
|
9
|
+
import { recommendConcurrency } from '../detect.js';
|
|
10
|
+
import { isGodotProject } from '../run/drivers/godot-project.js';
|
|
9
11
|
|
|
10
12
|
const GITIGNORE_LINE = '.ravensight/jobs/';
|
|
11
13
|
|
|
@@ -66,7 +68,7 @@ export async function init(flags = {}, deps = {}) {
|
|
|
66
68
|
// Every API call in this command goes through the one 401 handler, so a
|
|
67
69
|
// refused credential is cleared and explained here the same way it is
|
|
68
70
|
// everywhere else.
|
|
69
|
-
return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose));
|
|
71
|
+
return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose, deps.recommend || recommendConcurrency));
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/**
|
|
@@ -76,7 +78,7 @@ export async function init(flags = {}, deps = {}) {
|
|
|
76
78
|
* @param {(question: string, labels: Array<string>) => Promise<number|null>} choose
|
|
77
79
|
* the picker; answers null when there is no terminal to ask on
|
|
78
80
|
*/
|
|
79
|
-
async function linkRepo(context, flags, repoRoot, choose) {
|
|
81
|
+
async function linkRepo(context, flags, repoRoot, choose, recommend) {
|
|
80
82
|
const whoami = await context.api.cli.whoami();
|
|
81
83
|
|
|
82
84
|
const games = whoami.games || [];
|
|
@@ -130,8 +132,20 @@ async function linkRepo(context, flags, repoRoot, choose) {
|
|
|
130
132
|
);
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
// What check would say: a repository with a project.godot is played with
|
|
136
|
+
// the Godot driver, and the concurrency is the one this machine can take.
|
|
137
|
+
// 0.5.0 wrote playwright_web and 2 right after check said Godot and 1.
|
|
138
|
+
const godot = Boolean(await isGodotProject(repoRoot));
|
|
139
|
+
const machine = recommend();
|
|
133
140
|
const config = await saveConfig(
|
|
134
|
-
{
|
|
141
|
+
{
|
|
142
|
+
...(existing || {}),
|
|
143
|
+
...defaultConfig(gameId, {
|
|
144
|
+
concurrency: machine.recommended,
|
|
145
|
+
...(godot ? { driver: 'godot_driver', build: { kind: 'godot_project', ref: '.' } } : {})
|
|
146
|
+
}),
|
|
147
|
+
api_url: context.apiUrl
|
|
148
|
+
},
|
|
135
149
|
{ repoRoot }
|
|
136
150
|
);
|
|
137
151
|
const gitignore = await ensureGitignore(repoRoot);
|
|
@@ -144,6 +158,8 @@ async function linkRepo(context, flags, repoRoot, choose) {
|
|
|
144
158
|
const name = (games.find(game => game.gameId === gameId) || {}).name || gameId;
|
|
145
159
|
ui.ok(`Linked this repo to ${name} (${gameId}).`);
|
|
146
160
|
ui.info(`Wrote ${paths.configFile({ repoRoot })}`);
|
|
161
|
+
if (godot) ui.info('This is a Godot project, so runs use godot_driver on this directory.');
|
|
162
|
+
ui.info(`Concurrency ${config.concurrency}, from this machine (${machine.cpus} logical cpus, ${machine.freeMemGb} GB free).`);
|
|
147
163
|
if (gitignore === 'added') ui.info(`Added ${GITIGNORE_LINES.join(' and ')} to .gitignore`);
|
|
148
164
|
if (gitignore === 'absent') ui.warn(`No .gitignore here. Add ${GITIGNORE_LINES.join(' and ')} to whatever ignores build output.`);
|
|
149
165
|
ui.blank();
|
package/src/commands/profile.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
3
|
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
4
|
+
import { settleFailureLine } from '../run/settle.js';
|
|
4
5
|
import { getDeps } from '../run/deps.js';
|
|
5
6
|
import EXIT from '../run/exit.js';
|
|
6
7
|
import { createHeartbeat } from '../run/heartbeat.js';
|
|
7
8
|
import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
|
|
8
9
|
import { jobDir } from '../run/paths.js';
|
|
9
|
-
import { runGameProfile, writeCapabilityReport } from '../run/profile.js';
|
|
10
|
+
import { runGameProfile, writeCapabilityReport, writeProfileFailure } from '../run/profile.js';
|
|
10
11
|
import * as stateFile from '../run/state.js';
|
|
11
12
|
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
12
13
|
import { formatCents, resolveSetup } from './run.js';
|
|
@@ -64,6 +65,11 @@ async function profile(flags, injected, deps, log) {
|
|
|
64
65
|
const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
|
|
65
66
|
|
|
66
67
|
const modules = ['game_profile'];
|
|
68
|
+
// The profile is a read of the repository: --driver, --godot-project and
|
|
69
|
+
// --godot-path are accepted for symmetry with run and change nothing here.
|
|
70
|
+
if (options.driver || options.godotProject || options.godotPath) {
|
|
71
|
+
log('profile reads the repository and does not launch the game, so --driver, --godot-project and --godot-path are not used here. A persona run (run) is what plays the game.');
|
|
72
|
+
}
|
|
67
73
|
const pack = await deps.packs.get({ api, gameId, modules });
|
|
68
74
|
// A profile job is the one shape that does not need a brief, so a missing one
|
|
69
75
|
// is not an error here. It is still read when there is one: a brief that
|
|
@@ -186,7 +192,8 @@ async function profile(flags, injected, deps, log) {
|
|
|
186
192
|
actions_taken: turns,
|
|
187
193
|
quit_reason: 'goal_reached'
|
|
188
194
|
});
|
|
189
|
-
await api.jobs.finish(gameId, jobId, {});
|
|
195
|
+
const closed = await api.jobs.finish(gameId, jobId, {});
|
|
196
|
+
if (closed && closed.refund_limited_note) log(closed.refund_limited_note);
|
|
190
197
|
journal.finished = true;
|
|
191
198
|
journal.runs[profileRun.run_id].completed = true;
|
|
192
199
|
journal.runs[profileRun.run_id].state = 'succeeded';
|
|
@@ -201,9 +208,20 @@ async function profile(flags, injected, deps, log) {
|
|
|
201
208
|
} catch (error) {
|
|
202
209
|
heartbeat.stop();
|
|
203
210
|
const code = exitCodeFor(error);
|
|
204
|
-
await safeFail({ api, gameId, jobId, runId: profileRun.run_id, error, log });
|
|
205
211
|
log(`profile failed: ${String(error && error.message)}`);
|
|
206
|
-
|
|
212
|
+
if (Array.isArray(error && error.rawAnswers) && error.rawAnswers.length > 0) {
|
|
213
|
+
try {
|
|
214
|
+
const saved = await writeProfileFailure(directory, error.rawAnswers, String(error.message));
|
|
215
|
+
log(`The model's answers, secrets redacted, are in ${saved}.`);
|
|
216
|
+
} catch (writeError) {
|
|
217
|
+
log(`the model's answers could not be saved: ${String(writeError && writeError.message)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const settled = await safeFail({ api, gameId, jobId, runId: profileRun.run_id, error, log });
|
|
221
|
+
if (settled) log(`job ${jobId} ${settled.job ? settled.job.state : 'failed'}: refunded ${formatCents(settled.refunded_cents)}.`);
|
|
222
|
+
if (settled && settled.refund_limited_note) log(settled.refund_limited_note);
|
|
223
|
+
// A failure is never exit 0, whatever the settlement says.
|
|
224
|
+
return code === EXIT.OK ? EXIT.FAILED : code;
|
|
207
225
|
}
|
|
208
226
|
}
|
|
209
227
|
|
|
@@ -223,9 +241,10 @@ async function safeFail({ api, gameId, jobId, runId, error, log }) {
|
|
|
223
241
|
log(`the run could not be marked failed: ${String(completeError && completeError.message)}`);
|
|
224
242
|
}
|
|
225
243
|
try {
|
|
226
|
-
await api.jobs.finish(gameId, jobId, { reason: 'profile failed' });
|
|
244
|
+
return await api.jobs.finish(gameId, jobId, { reason: 'profile failed' });
|
|
227
245
|
} catch (finishError) {
|
|
228
|
-
log(
|
|
246
|
+
log(settleFailureLine(jobId, finishError));
|
|
247
|
+
return null;
|
|
229
248
|
}
|
|
230
249
|
}
|
|
231
250
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getDeps } from '../run/deps.js';
|
|
5
|
+
import EXIT from '../run/exit.js';
|
|
6
|
+
import { jobsDir, runDir as runDirFor } from '../run/paths.js';
|
|
7
|
+
import { checkoutRef, replayTrace } from '../run/replay.js';
|
|
8
|
+
import { findTraceIn, parseTrace, TRACE_FILE } from '../run/trace.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `ravensight-playtest replay <runId> --ref <branch>`: play a recorded persona
|
|
12
|
+
* run again with no model and report the first place it stops matching.
|
|
13
|
+
*
|
|
14
|
+
* Free and local. The commit is extracted to a temp directory, so the developer's
|
|
15
|
+
* checkout is not touched. When this machine is signed in and the run's job is
|
|
16
|
+
* known, the outcome is recorded on the job, which is where the per-job cap
|
|
17
|
+
* lives; a replay that cannot be recorded still happened and still answers.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT PROVES. Matching means this seed and these inputs produced the same
|
|
20
|
+
* recorded state at every step, on this machine, on this Godot. It does not prove
|
|
21
|
+
* the game is deterministic in general. A divergence on a changed branch is
|
|
22
|
+
* evidence about the change only if the trace also matches where it was recorded,
|
|
23
|
+
* which is what `--baseline` checks and what the output says when it was not.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The largest game-state value kept in a recorded divergence. */
|
|
27
|
+
const MAX_DIFF_VALUE_CHARS = 200;
|
|
28
|
+
|
|
29
|
+
/** Where a run's trace lives on this machine, by run id alone. */
|
|
30
|
+
export async function findLocalTrace(repoRoot, runId, jobId = null) {
|
|
31
|
+
if (jobId) return { file: await findTraceIn(runDirFor(repoRoot, jobId, runId)), jobId };
|
|
32
|
+
const jobs = await readdir(jobsDir(repoRoot)).catch(() => []);
|
|
33
|
+
for (const candidate of jobs) {
|
|
34
|
+
const file = await findTraceIn(runDirFor(repoRoot, candidate, runId));
|
|
35
|
+
if (file) return { file, jobId: candidate };
|
|
36
|
+
}
|
|
37
|
+
return { file: null, jobId: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A run's trace text: this machine's copy, else the uploaded one. Null when
|
|
42
|
+
* neither exists, and never throws for an ordinary "not there".
|
|
43
|
+
*
|
|
44
|
+
* @returns {Promise<{text: string, jobId: string|null, source: string}|null>}
|
|
45
|
+
*/
|
|
46
|
+
export async function loadTrace({ repoRoot, runId, jobId = null, api = null, gameId = null, fetchImpl = fetch }) {
|
|
47
|
+
const local = await findLocalTrace(repoRoot, runId, jobId);
|
|
48
|
+
if (local.file) return { text: await readFile(local.file, 'utf8'), jobId: local.jobId, source: 'local' };
|
|
49
|
+
if (!api || !gameId || !jobId) return null;
|
|
50
|
+
try {
|
|
51
|
+
const { artifacts = [] } = await api.jobs.artifacts(gameId, jobId);
|
|
52
|
+
const entry = artifacts.find(item => item.run_id === runId && item.path === TRACE_FILE && item.status === 'verified');
|
|
53
|
+
if (!entry) return null;
|
|
54
|
+
const response = await fetchImpl(entry.url);
|
|
55
|
+
if (!response.ok) return null;
|
|
56
|
+
return { text: await response.text(), jobId, source: 'uploaded' };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function boundedValue(value) {
|
|
63
|
+
const text = JSON.stringify(value ?? null);
|
|
64
|
+
return text.length <= MAX_DIFF_VALUE_CHARS ? value ?? null : `${text.slice(0, MAX_DIFF_VALUE_CHARS)}...`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A replay result in the shape the server records. Hashes and differences, no state dump. */
|
|
68
|
+
export function replayReport(result, { ref, sha }) {
|
|
69
|
+
const divergence = result.first_divergence;
|
|
70
|
+
return {
|
|
71
|
+
ref: String(ref).slice(0, 200),
|
|
72
|
+
sha,
|
|
73
|
+
matched: result.matched,
|
|
74
|
+
fully_replayed: result.fully_replayed,
|
|
75
|
+
steps_total: result.steps_total,
|
|
76
|
+
steps_compared: result.steps_compared,
|
|
77
|
+
ticks: result.ticks,
|
|
78
|
+
stopped: result.stopped || null,
|
|
79
|
+
first_divergence: divergence ? {
|
|
80
|
+
step: divergence.step,
|
|
81
|
+
...(Number.isInteger(divergence.frame) ? { frame: divergence.frame } : {}),
|
|
82
|
+
...(divergence.reason ? { reason: String(divergence.reason).slice(0, 500) } : {}),
|
|
83
|
+
...(divergence.actual_error ? { actual_error: String(divergence.actual_error).slice(0, 500) } : {}),
|
|
84
|
+
...(divergence.expected ? { expected_hash: divergence.expected.state_hash } : {}),
|
|
85
|
+
...(divergence.actual ? { actual_hash: divergence.actual.state_hash } : {}),
|
|
86
|
+
...(divergence.differing_subtrees ? { differing_subtrees: divergence.differing_subtrees.slice(0, 20).map(name => String(name).slice(0, 200)) } : {}),
|
|
87
|
+
...(divergence.game_state_diff ? {
|
|
88
|
+
game_state_diff: Object.fromEntries(Object.entries(divergence.game_state_diff).slice(0, 20).map(([key, pair]) => [
|
|
89
|
+
key.slice(0, 80),
|
|
90
|
+
{ expected: boundedValue(pair.expected), actual: boundedValue(pair.actual) }
|
|
91
|
+
]))
|
|
92
|
+
} : {})
|
|
93
|
+
} : null
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One sentence per outcome, in the same words `simulation-replay` uses. */
|
|
98
|
+
export function replaySentence(result) {
|
|
99
|
+
if (result.matched) {
|
|
100
|
+
if (result.fully_replayed && (!(result.steps_compared > 0) || !(result.ticks > 0))) return 'Replay compared nothing: the recorded run took no step the game could be held to, so this says nothing about the game.';
|
|
101
|
+
if (result.fully_replayed) return `Replay matched every recorded state (${result.steps_compared} compared, ${result.ticks} ticks).`;
|
|
102
|
+
if (result.stopped === 'redacted_input') return 'Replay matched the recorded prefix only: the run typed a secret from the local brief here, and the trace does not hold it.';
|
|
103
|
+
return 'Replay matched the recorded prefix only. The trace does not establish a complete playthrough.';
|
|
104
|
+
}
|
|
105
|
+
const divergence = result.first_divergence || {};
|
|
106
|
+
const where = `step ${divergence.step}${Number.isInteger(divergence.frame) ? `, tick ${divergence.frame}` : ''}`;
|
|
107
|
+
if (divergence.reason) return `Replay diverged at ${where}: ${divergence.reason}${divergence.actual_error ? ` (${divergence.actual_error})` : ''}`;
|
|
108
|
+
if (divergence.actual_error) return `Replay diverged at ${where}: ${divergence.actual_error}`;
|
|
109
|
+
const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5)
|
|
110
|
+
.map(([key, pair]) => `${key} was ${JSON.stringify(pair.expected)}, now ${JSON.stringify(pair.actual)}`).join('; ');
|
|
111
|
+
const trees = (divergence.differing_subtrees || []).join(', ');
|
|
112
|
+
return `Replay diverged at ${where}.${diff ? ` ${diff}.` : ''}${trees ? ` Differs under: ${trees}.` : ''}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} runId
|
|
117
|
+
* @param {Object} flags
|
|
118
|
+
* @param {Object} [injected] test seam: deps, replayTrace, checkoutRef, log, fetch
|
|
119
|
+
* @returns {Promise<number>} an exit code
|
|
120
|
+
*/
|
|
121
|
+
export async function replayCommand(runId, flags = {}, injected = {}) {
|
|
122
|
+
const deps = injected.deps || (await getDeps());
|
|
123
|
+
const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
124
|
+
const play = injected.replayTrace || replayTrace;
|
|
125
|
+
const checkout = injected.checkoutRef || checkoutRef;
|
|
126
|
+
const repoRoot = path.resolve(flags.repoRoot || process.cwd());
|
|
127
|
+
|
|
128
|
+
let context = null;
|
|
129
|
+
try {
|
|
130
|
+
context = await deps.createContext({ apiUrl: flags.apiUrl, repoRoot, requireAuth: false });
|
|
131
|
+
} catch {
|
|
132
|
+
context = null;
|
|
133
|
+
}
|
|
134
|
+
const api = context && context.token ? context.api : null;
|
|
135
|
+
const gameId = flags.game || (context && context.config && context.config.game_id) || null;
|
|
136
|
+
|
|
137
|
+
const trace = await loadTrace({ repoRoot, runId, jobId: flags.job || null, api, gameId, fetchImpl: injected.fetch || fetch });
|
|
138
|
+
if (!trace) {
|
|
139
|
+
log(`There is no trace for ${runId} on this machine${flags.job ? ' or on the server' : ''}. Pass --job <jobId> to read an uploaded one. A run records a trace only on the Godot driver, in lockstep.`);
|
|
140
|
+
return EXIT.ENVIRONMENT;
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = parseTrace(trace.text);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
log(String(error.message));
|
|
147
|
+
return EXIT.ENVIRONMENT;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const project = path.resolve(repoRoot, flags.godotProject || (context && context.config && context.config.godot_project) || '.');
|
|
151
|
+
const subdir = path.relative(repoRoot, project);
|
|
152
|
+
if (subdir.startsWith('..')) {
|
|
153
|
+
log('The Godot project has to be inside this repository to replay a commit of it.');
|
|
154
|
+
return EXIT.ENVIRONMENT;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const replayOn = async ref => {
|
|
158
|
+
const checked = await checkout({ repoRoot, ref, projectSubdir: subdir, godotPath: flags.godotPath });
|
|
159
|
+
try {
|
|
160
|
+
const result = await play({ text: trace.text, projectDir: checked.projectDir, godotPath: flags.godotPath, headless: flags.headless ? true : undefined });
|
|
161
|
+
return { result, sha: checked.sha };
|
|
162
|
+
} finally {
|
|
163
|
+
await checked.cleanup();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const ref = flags.ref || 'HEAD';
|
|
168
|
+
let baseline = null;
|
|
169
|
+
try {
|
|
170
|
+
if (flags.baseline) {
|
|
171
|
+
if (!parsed.header.commit) {
|
|
172
|
+
log('This trace does not say which commit it was recorded on, so there is nothing to check it against.');
|
|
173
|
+
} else {
|
|
174
|
+
baseline = await replayOn(parsed.header.commit);
|
|
175
|
+
log(`On the commit it was recorded on (${parsed.header.commit.slice(0, 12)}): ${replaySentence(baseline.result)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const { result, sha } = await replayOn(ref);
|
|
179
|
+
|
|
180
|
+
let recorded = null;
|
|
181
|
+
if (api && gameId && trace.jobId && flags.record !== false) {
|
|
182
|
+
try {
|
|
183
|
+
recorded = await api.runs.recordReplay(gameId, trace.jobId, runId, replayReport(result, { ref, sha }));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log(`The replay was not recorded on the job: ${String(error && error.message)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (flags.json) {
|
|
190
|
+
(deps.ui && deps.ui.json ? deps.ui.json : value => console.log(JSON.stringify(value)))({ run_id: runId, ref, sha, ...result, baseline: baseline ? baseline.result : null, recorded });
|
|
191
|
+
} else {
|
|
192
|
+
log(`${ref} (${sha.slice(0, 12)}): ${replaySentence(result)}`);
|
|
193
|
+
if (!result.matched) {
|
|
194
|
+
if (baseline && !baseline.result.matched) log('The trace does not replay on the commit it was recorded on either, so this game is not reproducible on this seed and this divergence says nothing about the change.');
|
|
195
|
+
else if (baseline) log('It matches where it was recorded, so the change is what moved it.');
|
|
196
|
+
else log('Pass --baseline to check the trace against the commit it was recorded on. A game that does not honor its seed diverges everywhere, whatever changed.');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result.matched ? EXIT.OK : EXIT.FAILED;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
log(String(error && error.message));
|
|
202
|
+
return EXIT.ENVIRONMENT;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/commands/resume.js
CHANGED
|
@@ -85,6 +85,13 @@ async function resumeWith(context, jobId, flags, deps) {
|
|
|
85
85
|
{ hint: 'Run resume with no job id to see what is here.' }
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
|
+
if (state.options && state.options.labs === 'feature') {
|
|
89
|
+
throw new CliError(
|
|
90
|
+
`${jobId} is a Build a feature job, and those cannot be resumed.`,
|
|
91
|
+
1,
|
|
92
|
+
{ hint: 'The hourly sweep settles it and gives back what did not happen. The branch keeps whatever was written.' }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
88
95
|
const gameId = flags.game || state.game_id;
|
|
89
96
|
if (!gameId) throw new CliError(`The journal for ${jobId} does not say which game it belongs to.`);
|
|
90
97
|
|