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
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();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createContext } from '../api/index.js';
|
|
2
|
+
import { ApiError } from '../api/errors.js';
|
|
3
|
+
import { resolveGameId } from '../config.js';
|
|
4
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
5
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
6
|
+
import { ui } from '../ui/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `punch` and `tape` share: the game, the client, the refusals in plain
|
|
10
|
+
* words, and how an item is printed.
|
|
11
|
+
*
|
|
12
|
+
* The punch list holds what has to be finished before handover, closed one at
|
|
13
|
+
* a time. Blue tape holds small first-impression annoyances, captured fast and
|
|
14
|
+
* promoted to the punch list when they matter. Both live on the server under
|
|
15
|
+
* the game's Labs lists, so the dashboard and this CLI read the same items.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const LIST_NAMES = Object.freeze({ punch: 'punch list', tape: 'blue tape list' });
|
|
19
|
+
export const STATUSES = Object.freeze(['open', 'done', 'dropped', 'all']);
|
|
20
|
+
export const ITEM_ID = /^li_[A-Za-z0-9_-]+$/;
|
|
21
|
+
export const LIMIT = 500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run `fn` with the client and the game this repo is linked to (or `--game`).
|
|
25
|
+
* @param {Object} flags
|
|
26
|
+
* @param {Object} deps test seam: context
|
|
27
|
+
* @param {(setup: {api: Object, gameId: string}) => Promise<number>} fn
|
|
28
|
+
* @returns {Promise<number>}
|
|
29
|
+
*/
|
|
30
|
+
export async function withList(flags, deps, fn) {
|
|
31
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
32
|
+
return withAuthHandling(context, async () => {
|
|
33
|
+
const gameId = resolveGameId(flags, context.config);
|
|
34
|
+
return fn({ api: context.api, gameId });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A refusal from the lists routes as a sentence a person can act on. Anything
|
|
40
|
+
* this does not recognise is handed back unchanged for `report` to print.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} error
|
|
43
|
+
* @param {{list: string, itemId?: string|null}} where
|
|
44
|
+
* @returns {unknown}
|
|
45
|
+
*/
|
|
46
|
+
export function listError(error, { list, itemId = null }) {
|
|
47
|
+
if (!(error instanceof ApiError) && !(error && typeof error.status === 'number' && typeof error.code === 'string')) return error;
|
|
48
|
+
const name = LIST_NAMES[list] || 'list';
|
|
49
|
+
if (error.code === 'labs_not_enabled') {
|
|
50
|
+
return new CliError(
|
|
51
|
+
`The ${name} is part of Ravensight Labs (beta). Labs is not on for this game.`,
|
|
52
|
+
ExitCode.ENVIRONMENT,
|
|
53
|
+
{ hint: 'Turn Labs on from the Labs page in the dashboard. Nothing was changed.' }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (error.code === 'item_closed') {
|
|
57
|
+
return new CliError(`${itemId || 'That item'} is closed. Reopen it first, then promote it.`, ExitCode.FAILURE);
|
|
58
|
+
}
|
|
59
|
+
if (error.code === 'quota_exceeded') {
|
|
60
|
+
return new CliError('This account has no AI units left. Add funds, then try again. Nothing was charged.', ExitCode.FAILURE);
|
|
61
|
+
}
|
|
62
|
+
if (error.code === 'labs_list_attempts') {
|
|
63
|
+
return new CliError('Too many drafts came back empty this hour. Try again later. Nothing was charged.', ExitCode.FAILURE);
|
|
64
|
+
}
|
|
65
|
+
if (error.code === 'ai_unavailable') {
|
|
66
|
+
return new CliError('The AI service is not available right now. Try again later. Nothing was charged.', ExitCode.FAILURE);
|
|
67
|
+
}
|
|
68
|
+
if (error.code === 'list_full') {
|
|
69
|
+
return new CliError(
|
|
70
|
+
`The ${name} already has ${LIMIT} open items. Close or drop some, then add this one.`,
|
|
71
|
+
ExitCode.FAILURE
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (error.status === 404) {
|
|
75
|
+
if (itemId) {
|
|
76
|
+
return new CliError(
|
|
77
|
+
`No item ${itemId} is on this game's ${name}.`,
|
|
78
|
+
ExitCode.FAILURE,
|
|
79
|
+
{ hint: `Run "ravensight-playtest ${list} list --status all" to see the item ids.` }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return new CliError(
|
|
83
|
+
`This game has no ${name} that this token can see.`,
|
|
84
|
+
ExitCode.FAILURE,
|
|
85
|
+
{ hint: 'Run "ravensight-playtest check" to confirm the game is linked. An older server has no lists yet.' }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Call the server and turn its refusals into sentences.
|
|
93
|
+
* @template T
|
|
94
|
+
* @param {() => Promise<T>} call
|
|
95
|
+
* @param {{list: string, itemId?: string|null}} where
|
|
96
|
+
* @returns {Promise<T>}
|
|
97
|
+
*/
|
|
98
|
+
export async function guarded(call, where) {
|
|
99
|
+
try {
|
|
100
|
+
return await call();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw listError(error, where);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Refuse an id that cannot be an item before asking the server about it.
|
|
108
|
+
* @param {string} itemId
|
|
109
|
+
*/
|
|
110
|
+
export function requireItemId(itemId) {
|
|
111
|
+
const value = String(itemId || '').trim();
|
|
112
|
+
if (!ITEM_ID.test(value)) {
|
|
113
|
+
throw new CliError(`"${value}" is not an item id. Item ids start with li_.`, ExitCode.FAILURE, {
|
|
114
|
+
hint: 'Run "ravensight-playtest punch list" or "ravensight-playtest tape list" to see them.'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Refuse empty text here rather than spend a round trip on it.
|
|
122
|
+
* @param {string} text
|
|
123
|
+
*/
|
|
124
|
+
export function requireText(text) {
|
|
125
|
+
const value = String(text || '').trim();
|
|
126
|
+
if (!value) throw new CliError('Say what the item is, in quotes.', ExitCode.FAILURE);
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string|undefined} status */
|
|
131
|
+
export function requireStatus(status) {
|
|
132
|
+
if (status === undefined || status === null) return 'open';
|
|
133
|
+
if (!STATUSES.includes(status)) {
|
|
134
|
+
throw new CliError(`--status takes ${STATUSES.join(', ')}.`, ExitCode.FAILURE);
|
|
135
|
+
}
|
|
136
|
+
return status;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Where an item came from, in a few words. */
|
|
140
|
+
export function sourceWords(source) {
|
|
141
|
+
if (!source || !source.kind || source.kind === 'manual') return 'manual';
|
|
142
|
+
const kind = String(source.kind).replace(/_/g, ' ');
|
|
143
|
+
return source.ref ? `${kind} ${source.ref}` : kind;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The plain-language hints under an item. A hint, never a block. */
|
|
147
|
+
export function printHints(item) {
|
|
148
|
+
const issues = item && item.plain_check && Array.isArray(item.plain_check.issues) ? item.plain_check.issues : [];
|
|
149
|
+
for (const issue of issues) {
|
|
150
|
+
if (issue && issue.message) ui.info(` hint: ${issue.message}`);
|
|
151
|
+
}
|
|
152
|
+
return issues.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Print a list answer as a table, or say it is empty.
|
|
157
|
+
* @param {Object} answer `{ items, counts }`
|
|
158
|
+
* @param {string} list
|
|
159
|
+
* @param {string} status
|
|
160
|
+
*/
|
|
161
|
+
export function printList(answer, list, status) {
|
|
162
|
+
const items = answer && Array.isArray(answer.items) ? answer.items : [];
|
|
163
|
+
const name = LIST_NAMES[list];
|
|
164
|
+
if (items.length === 0) {
|
|
165
|
+
ui.info(status === 'all' ? `The ${name} is empty.` : `The ${name} has no ${status} items.`);
|
|
166
|
+
} else {
|
|
167
|
+
ui.table(items.map(item => ({
|
|
168
|
+
item_id: item.item_id,
|
|
169
|
+
status: item.status,
|
|
170
|
+
text: item.text,
|
|
171
|
+
source: sourceWords(item.source) + (item.promoted_to ? `, now ${item.promoted_to}` : '') + (item.fix_job_id ? `, fix ${item.fix_job_id}` : '')
|
|
172
|
+
})), [
|
|
173
|
+
{ key: 'item_id', label: 'ITEM' },
|
|
174
|
+
{ key: 'status', label: 'STATUS' },
|
|
175
|
+
{ key: 'text', label: 'TEXT' },
|
|
176
|
+
{ key: 'source', label: 'SOURCE' }
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
const counts = answer && answer.counts;
|
|
180
|
+
if (counts) {
|
|
181
|
+
ui.blank();
|
|
182
|
+
ui.info(`${counts.open ?? 0} open, ${counts.done ?? 0} done, ${counts.dropped ?? 0} dropped.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Find one item by id, for `fix li_...`. The punch list first, because that is
|
|
188
|
+
* where a finding to fix usually sits, then blue tape on a 404.
|
|
189
|
+
*
|
|
190
|
+
* @param {Object} api
|
|
191
|
+
* @param {string} gameId
|
|
192
|
+
* @param {string} itemId
|
|
193
|
+
* @returns {Promise<{item: Object, list: string}|null>}
|
|
194
|
+
*/
|
|
195
|
+
export async function findListItem(api, gameId, itemId) {
|
|
196
|
+
for (const list of ['punch', 'tape']) {
|
|
197
|
+
try {
|
|
198
|
+
const answer = await api.labs.lists.get(gameId, list, itemId);
|
|
199
|
+
if (answer && answer.item) return { item: answer.item, list };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error && error.status === 404) continue;
|
|
202
|
+
throw listError(error, { list, itemId });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
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,116 @@
|
|
|
1
|
+
import { ui } from '../ui/index.js';
|
|
2
|
+
import { ExitCode } from '../errors.js';
|
|
3
|
+
import { guarded, printHints, printList, requireItemId, requireStatus, requireText, sourceWords, withList } from './lists.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `ravensight-playtest punch`: the punch list (Labs beta).
|
|
7
|
+
*
|
|
8
|
+
* What has to be finished before handover, closed one item at a time. Reading,
|
|
9
|
+
* adding and closing are free. `draft` asks for proposals from the game's
|
|
10
|
+
* findings and specs, which is one AI unit, and saves nothing unless
|
|
11
|
+
* `--accept` is passed. Nothing here closes an item on its own: `fix li_...`
|
|
12
|
+
* records the fix job on the item and leaves closing it to a person.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const LIST = 'punch';
|
|
16
|
+
|
|
17
|
+
/** `punch list [--status open|done|dropped|all]` */
|
|
18
|
+
export async function punchList(flags = {}, deps = {}) {
|
|
19
|
+
const status = requireStatus(flags.status);
|
|
20
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
21
|
+
const answer = await guarded(() => api.labs.lists.list(gameId, LIST, { status }), { list: LIST });
|
|
22
|
+
if (flags.json) {
|
|
23
|
+
ui.json(answer);
|
|
24
|
+
return ExitCode.OK;
|
|
25
|
+
}
|
|
26
|
+
printList(answer, LIST, status);
|
|
27
|
+
return ExitCode.OK;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `punch add "<text>"` */
|
|
32
|
+
export async function punchAdd(text, flags = {}, deps = {}) {
|
|
33
|
+
const value = requireText(text);
|
|
34
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
35
|
+
const answer = await guarded(() => api.labs.lists.add(gameId, LIST, { text: value }), { list: LIST });
|
|
36
|
+
if (flags.json) {
|
|
37
|
+
ui.json(answer);
|
|
38
|
+
return ExitCode.OK;
|
|
39
|
+
}
|
|
40
|
+
printAdded(answer);
|
|
41
|
+
return ExitCode.OK;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `punch done <li_id>` and `punch drop <li_id>` */
|
|
46
|
+
export async function punchClose(itemId, status, flags = {}, deps = {}) {
|
|
47
|
+
const id = requireItemId(itemId);
|
|
48
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
49
|
+
const answer = await guarded(() => api.labs.lists.update(gameId, LIST, id, { status }), { list: LIST, itemId: id });
|
|
50
|
+
if (flags.json) {
|
|
51
|
+
ui.json(answer);
|
|
52
|
+
return ExitCode.OK;
|
|
53
|
+
}
|
|
54
|
+
const text = answer && answer.item && answer.item.text ? `: ${answer.item.text}` : '';
|
|
55
|
+
ui.ok(`${status === 'done' ? 'Done' : 'Dropped'} ${id}${text}`);
|
|
56
|
+
return ExitCode.OK;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `punch draft [--accept]` */
|
|
61
|
+
export async function punchDraft(flags = {}, deps = {}) {
|
|
62
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
63
|
+
if (!flags.json) {
|
|
64
|
+
ui.info('Drafting a punch list is one AI unit. It is not charged when there is nothing to propose.');
|
|
65
|
+
ui.info(flags.accept ? 'Every proposal will be added to the punch list.' : 'Nothing is saved. Pass --accept to add the proposals.');
|
|
66
|
+
}
|
|
67
|
+
const confirm = deps.confirm || ui.confirm;
|
|
68
|
+
if (!flags.yes && !(await confirm('Draft the punch list?', { yes: flags.yes }))) {
|
|
69
|
+
ui.info('Nothing was charged. Pass --yes to draft without a terminal.');
|
|
70
|
+
return ExitCode.CANCELED;
|
|
71
|
+
}
|
|
72
|
+
const answer = await guarded(() => api.labs.lists.draft(gameId), { list: LIST });
|
|
73
|
+
const proposals = answer && Array.isArray(answer.proposals) ? answer.proposals : [];
|
|
74
|
+
const added = [];
|
|
75
|
+
if (flags.accept) {
|
|
76
|
+
try {
|
|
77
|
+
for (const proposal of proposals) {
|
|
78
|
+
const body = proposal.source ? { text: proposal.text, source: proposal.source } : { text: proposal.text };
|
|
79
|
+
added.push(await guarded(() => api.labs.lists.add(gameId, LIST, body), { list: LIST }));
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Say what was saved before the refusal, so nobody adds it twice.
|
|
83
|
+
if (!flags.json) for (const saved of added) printAdded(saved);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
ui.json({ ...answer, ...(flags.accept ? { added } : {}) });
|
|
89
|
+
return ExitCode.OK;
|
|
90
|
+
}
|
|
91
|
+
if (proposals.length === 0) {
|
|
92
|
+
ui.info('No proposals, so nothing was charged.');
|
|
93
|
+
return ExitCode.OK;
|
|
94
|
+
}
|
|
95
|
+
const units = Number(answer.units) || 0;
|
|
96
|
+
ui.info(`${proposals.length} proposal${proposals.length === 1 ? '' : 's'}, ${units} AI unit${units === 1 ? '' : 's'} charged:`);
|
|
97
|
+
if (flags.accept) {
|
|
98
|
+
for (const saved of added) printAdded(saved);
|
|
99
|
+
return ExitCode.OK;
|
|
100
|
+
}
|
|
101
|
+
proposals.forEach((proposal, index) => {
|
|
102
|
+
ui.info(` ${index + 1}. ${proposal.text} (${sourceWords(proposal.source)})`);
|
|
103
|
+
});
|
|
104
|
+
ui.blank();
|
|
105
|
+
ui.info('Nothing was saved. Add the ones you want with "ravensight-playtest punch add".');
|
|
106
|
+
return ExitCode.OK;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One added item, with its plain-language hints under it. */
|
|
111
|
+
export function printAdded(answer) {
|
|
112
|
+
const item = answer && answer.item ? answer.item : {};
|
|
113
|
+
if (answer && answer.existing) ui.info(`Already on the list: ${item.item_id}, ${item.text}`);
|
|
114
|
+
else ui.ok(`Added ${item.item_id}: ${item.text}`);
|
|
115
|
+
if (printHints(item) > 0) ui.info(' These hints follow the rules of Simplified Technical English. They never block an item.');
|
|
116
|
+
}
|
|
@@ -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
|
|