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,292 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { capabilityLive } from '../run/acceptance.js';
|
|
5
|
+
import { getDeps } from '../run/deps.js';
|
|
6
|
+
import EXIT from '../run/exit.js';
|
|
7
|
+
import { checkoutRef } from '../run/replay.js';
|
|
8
|
+
import { jobLink } from '../dashboard.js';
|
|
9
|
+
import { resolveSetup } from './run.js';
|
|
10
|
+
import { detectGodotBinary, majorMinor } from '../godot.js';
|
|
11
|
+
import { buildPack, HOSTED_GODOT, PACK_NAME } from '../simulation/godotPack.js';
|
|
12
|
+
import { defaultBase } from './validate.js';
|
|
13
|
+
import { DIMENSION_3D, dimensionGate } from '../run/dimension.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `ravensight-playtest validate <branch> --hosted`: two uploaded Godot builds, the
|
|
17
|
+
* base and the head, played on one seed on hosted machines, and compared.
|
|
18
|
+
*
|
|
19
|
+
* Code stays here. Both commits are extracted to temp directories and exported to
|
|
20
|
+
* game packs on this machine; only the packs are uploaded. A commit that will not
|
|
21
|
+
* export is a free answer, because nothing is priced until both packs exist.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_GAMES = 3;
|
|
25
|
+
export const MAX_GAMES = 10;
|
|
26
|
+
export const DEFAULT_STEPS = 300;
|
|
27
|
+
export const MAX_STEPS = 10000;
|
|
28
|
+
const POLL_MS = 15000;
|
|
29
|
+
const WAIT_MS = 80 * 60 * 1000;
|
|
30
|
+
const money = cents => `$${(cents / 100).toFixed(2)}`;
|
|
31
|
+
const BUILD_POLL_MS = 5000;
|
|
32
|
+
const BUILD_WAIT_MS = 15 * 60 * 1000;
|
|
33
|
+
const PAIR_ATTEMPTS = 3;
|
|
34
|
+
|
|
35
|
+
/** The whole request is decided here, so one commit pair on one seed is one request. */
|
|
36
|
+
export function simulationFor({ seed, games, steps }) {
|
|
37
|
+
return { version: 1, seed, runs: games, max_steps: steps, workers: 1, strategies: [{ name: 'random', policy: 'random' }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Why a comparison ended with no report, the dashboard's sentences. Money is the ledger's line.
|
|
41
|
+
const COMPARISON_REASONS = {
|
|
42
|
+
base_failed: 'No game finished on the base build, so the head build did not run.',
|
|
43
|
+
head_failed: 'No game finished on the head build, so there is no comparison.',
|
|
44
|
+
platform_failure: 'A machine failed on our side, so there is no comparison.',
|
|
45
|
+
time_limit: 'A game reached the time limit, so there is no comparison.',
|
|
46
|
+
charge_refused: 'The balance could not cover both builds, so neither one ran.',
|
|
47
|
+
canceled: 'This comparison was canceled.',
|
|
48
|
+
not_comparable: 'The two builds could not be compared, so there is no comparison.'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The server's words, one line each. This command adds no reading of its own. */
|
|
52
|
+
export function comparisonLines(pair) {
|
|
53
|
+
const name = side => `${side.ref || side.buildId}${side.sha ? ` (${side.sha.slice(0, 12)})` : ''}`;
|
|
54
|
+
const lines = [`Base ${name(pair.base)}`, `Head ${name(pair.head)}`, `Seed ${pair.seed}`];
|
|
55
|
+
const report = pair.report;
|
|
56
|
+
if (!report) {
|
|
57
|
+
lines.push(COMPARISON_REASONS[pair.reason] || (pair.reason ? 'No comparison was delivered.' : 'No comparison was delivered: not finished.'));
|
|
58
|
+
} else {
|
|
59
|
+
lines.push(`Verdict: ${report.verdict}`, report.note);
|
|
60
|
+
for (const game of report.games) {
|
|
61
|
+
lines.push(`Game ${game.run_index + 1}: ${game.outcome_differs ? `failed on the ${game.head_status === 'failed' ? 'head' : 'base'} only (base ${game.base_status}, head ${game.head_status})` : !game.compared ? `not compared (base ${game.base_status}, head ${game.head_status})` : game.matched ? 'same state at every recorded step' : `first differs at state ${game.first_divergence.state}${game.first_divergence.exact ? '' : ' or shortly before'}`}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const money_ = pair.money;
|
|
65
|
+
if (money_) lines.push(!money_.chargedCents ? 'Nothing was charged.' : money_.refundedCents ? `Charged ${money(money_.chargedCents)}, refunded ${money(money_.refundedCents)}, kept ${money(money_.keptCents)}.` : `Charged ${money(money_.chargedCents)}.`);
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A ready build of these exact bytes is reused, so a retry pays no second deposit. */
|
|
70
|
+
async function ensureBuild({ api, gameId, built, sleep, fetchImpl, log, label, clock, created: createdIds }) {
|
|
71
|
+
const manifest = { sha256: built.sha256, bytes: built.bytes, entrypoint: PACK_NAME, args: [] };
|
|
72
|
+
const find = async () => (await api.hosted.builds(gameId)).builds.find(build => build.sha256 === built.sha256 && !['deleting', 'failed', 'rejected'].includes(build.state));
|
|
73
|
+
let build = await find();
|
|
74
|
+
// An upload that never finished is picked up again under the same request key.
|
|
75
|
+
if (!build || build.state === 'uploading') {
|
|
76
|
+
const capabilities = await api.hosted.capabilities(gameId);
|
|
77
|
+
if (!capabilities.reservationOffer) throw new Error('Hosted build uploads are not available.');
|
|
78
|
+
const created = await api.hosted.createBuild(gameId, {
|
|
79
|
+
...manifest, reservation: { amountCents: capabilities.reservationOffer.amountCents, version: capabilities.reservationOffer.version }
|
|
80
|
+
}, `pack-${createHash('sha256').update(`${gameId}:${built.sha256}`).digest('hex').slice(0, 40)}`);
|
|
81
|
+
createdIds.push(created.build);
|
|
82
|
+
log(`Uploading ${label}: ${(built.bytes / 1e6).toFixed(1)} MB. Holding ${money(capabilities.reservationOffer.amountCents)} of the price as its deposit.`);
|
|
83
|
+
const put = await fetchImpl(created.upload.url, { method: created.upload.method || 'PUT', headers: created.upload.headers, body: built.zip });
|
|
84
|
+
if (!put.ok) throw new Error(`The upload of ${label} was refused (${put.status}).`);
|
|
85
|
+
await api.hosted.completeBuild(gameId, created.build.buildId);
|
|
86
|
+
build = await find() || created.build;
|
|
87
|
+
}
|
|
88
|
+
const deadline = clock() + BUILD_WAIT_MS;
|
|
89
|
+
while (build.state !== 'ready') {
|
|
90
|
+
if (['failed', 'rejected', 'deleting'].includes(build.state)) throw new Error(`The hosted check refused the ${label} build (${build.state}).`);
|
|
91
|
+
if (clock() >= deadline) throw new Error(`The ${label} build was not checked in time. Run this again to pick it up.`);
|
|
92
|
+
await sleep(BUILD_POLL_MS);
|
|
93
|
+
// By id, not by bytes: a refused build has to be seen as refused, not as still waiting.
|
|
94
|
+
build = (await api.hosted.builds(gameId)).builds.find(row => row.buildId === build.buildId) || { ...build, state: 'deleting' };
|
|
95
|
+
}
|
|
96
|
+
return build;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} [head] the branch to compare, default HEAD
|
|
101
|
+
* @param {Object} flags
|
|
102
|
+
* @param {Object} [injected] test seam
|
|
103
|
+
* @returns {Promise<number>} an exit code
|
|
104
|
+
*/
|
|
105
|
+
export async function validateHostedCommand(head, flags = {}, injected = {}) {
|
|
106
|
+
const deps = injected.deps || (await getDeps());
|
|
107
|
+
const log = injected.log || (deps.ui?.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
108
|
+
const sleep = injected.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
109
|
+
const clock = injected.clock || Date.now;
|
|
110
|
+
if (flags.alpha3d) {
|
|
111
|
+
log('--hosted plays 2D Godot projects, so it cannot be used with --alpha-3d. Nothing was charged.');
|
|
112
|
+
return EXIT.ENVIRONMENT;
|
|
113
|
+
}
|
|
114
|
+
const setup = await (injected.resolveSetup || resolveSetup)(deps, { apiUrl: flags.apiUrl, repo: flags.repoRoot, game: flags.game, yes: flags.yes, godotPath: flags.godotPath }, log);
|
|
115
|
+
if (setup.code !== undefined) return setup.code;
|
|
116
|
+
const { context, api, gameId, repoRoot } = setup;
|
|
117
|
+
|
|
118
|
+
const games = Number(flags.games ?? DEFAULT_GAMES);
|
|
119
|
+
const steps = Number(flags.steps ?? DEFAULT_STEPS);
|
|
120
|
+
if (!Number.isInteger(games) || games < 1 || games > MAX_GAMES) { log(`--games must be a whole number from 1 to ${MAX_GAMES}. Nothing was charged.`); return EXIT.ENVIRONMENT; }
|
|
121
|
+
if (!Number.isInteger(steps) || steps < 1 || steps > MAX_STEPS) { log(`--steps must be a whole number from 1 to ${MAX_STEPS}. Nothing was charged.`); return EXIT.ENVIRONMENT; }
|
|
122
|
+
|
|
123
|
+
let labs = null;
|
|
124
|
+
try { labs = await api.labs.get(gameId); } catch { labs = null; }
|
|
125
|
+
if (!capabilityLive(labs, 'validate')) {
|
|
126
|
+
log('Validate changes is part of Ravensight Labs (beta), and it is not on for this game. Turn Labs on from the Labs page. Nothing was charged.');
|
|
127
|
+
return EXIT.ENVIRONMENT;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Before any debit: the comparison's own switch, the funding rule and the price.
|
|
131
|
+
let offer, capabilities;
|
|
132
|
+
try {
|
|
133
|
+
capabilities = await api.hosted.capabilities(gameId);
|
|
134
|
+
offer = capabilities.hostedValidate;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log(`${String(error.message).slice(0, 500)} Nothing was charged.`);
|
|
137
|
+
return EXIT.ENVIRONMENT;
|
|
138
|
+
}
|
|
139
|
+
if (offer?.reason === 'hosted_comparison_capacity') {
|
|
140
|
+
log('Hosted comparison is not available: it needs room for two runs at once and this service is set up for one. Waiting will not change that. Use the local comparison without --hosted. Nothing was charged.');
|
|
141
|
+
return EXIT.ENVIRONMENT;
|
|
142
|
+
}
|
|
143
|
+
if (!offer?.available || !capabilities.reservationOffer) {
|
|
144
|
+
log('Hosted comparison is not on for this game. Use the local comparison without --hosted. Nothing was charged.');
|
|
145
|
+
return EXIT.ENVIRONMENT;
|
|
146
|
+
}
|
|
147
|
+
if (capabilities.funding && capabilities.funding.initialFundingMet === false) {
|
|
148
|
+
log(`Hosted runs need ${money(capabilities.funding.minimumFundingCents)} of verified top-ups first, and ${money(capabilities.funding.requiredTopUpCents)} more would meet it. Nothing was charged.`);
|
|
149
|
+
return EXIT.ENVIRONMENT;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A price nobody can confirm is not a price anyone accepted.
|
|
153
|
+
const confirm = injected.confirm || deps.ui?.confirm;
|
|
154
|
+
if (!flags.yes && !confirm) {
|
|
155
|
+
log('This spends prepaid balance and there is no way to ask you here. Pass --yes to accept the price. Nothing was charged.');
|
|
156
|
+
return EXIT.ENVIRONMENT;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const project = path.resolve(repoRoot, flags.godotProject || context.config?.godot_project || '.');
|
|
160
|
+
const subdir = path.relative(repoRoot, project);
|
|
161
|
+
if (subdir.startsWith('..')) { log('The Godot project has to be inside this repository so its commits can be extracted. Nothing was charged.'); return EXIT.ENVIRONMENT; }
|
|
162
|
+
|
|
163
|
+
const checkout = injected.checkoutRef || checkoutRef;
|
|
164
|
+
const pack = injected.buildPack || buildPack;
|
|
165
|
+
const detect = injected.detectGodot || detectGodotBinary;
|
|
166
|
+
const baseRef = flags.base || await defaultBase(repoRoot);
|
|
167
|
+
const headRef = head || 'HEAD';
|
|
168
|
+
let godot;
|
|
169
|
+
try {
|
|
170
|
+
godot = await detect({ explicitPath: flags.godotPath, env: process.env, projectDir: project });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
log(`${error.message} Nothing was charged.`);
|
|
173
|
+
return EXIT.ENVIRONMENT;
|
|
174
|
+
}
|
|
175
|
+
// A pack from another engine does not load on the one in the hosted image.
|
|
176
|
+
if (majorMinor(godot.version) !== (offer.engine || HOSTED_GODOT)) {
|
|
177
|
+
log(`Hosted comparison plays on Godot ${offer.engine || HOSTED_GODOT}, and this is Godot ${godot.version}. Use --godot-path with a ${offer.engine || HOSTED_GODOT} editor, or use the local comparison without --hosted. Nothing was charged.`);
|
|
178
|
+
return EXIT.ENVIRONMENT;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const sides = {};
|
|
182
|
+
try {
|
|
183
|
+
for (const [name, ref] of [['base', baseRef], ['head', headRef]]) {
|
|
184
|
+
const checked = await checkout({ repoRoot, ref, projectSubdir: subdir, godotPath: godot.path });
|
|
185
|
+
try {
|
|
186
|
+
// Measured on 2D only, so a 3D project is refused here, free, before any export or deposit.
|
|
187
|
+
const { dimension } = await (injected.dimensionGate || dimensionGate)({ projectDir: checked.projectDir, alpha3d: false, name: 'Validate changes' });
|
|
188
|
+
if (dimension === DIMENSION_3D) throw new Error(`${ref}: --hosted plays 2D Godot projects, and this main scene has a 3D root. Leave off --hosted to validate it on this machine with --alpha-3d.`);
|
|
189
|
+
log(`Exporting ${name} ${ref} (${checked.sha.slice(0, 12)}).`);
|
|
190
|
+
sides[name] = { ref, sha: checked.sha, built: await pack({ projectDir: checked.projectDir, godotPath: godot.path }) };
|
|
191
|
+
} finally { await checked.cleanup?.(); }
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
log(`${String(error.message).slice(0, 500)} Nothing was charged.`);
|
|
195
|
+
return EXIT.ENVIRONMENT;
|
|
196
|
+
}
|
|
197
|
+
if (sides.base.sha === sides.head.sha) { log('The base and the head are the same commit, so there is nothing to compare. Nothing was charged.'); return EXIT.ENVIRONMENT; }
|
|
198
|
+
if (sides.base.built.sha256 === sides.head.built.sha256) { log('The base and the head export to the same game pack, so they play the same. Nothing was charged.'); return EXIT.OK; }
|
|
199
|
+
|
|
200
|
+
const seed = flags.seed || `validate-${sides.base.sha.slice(0, 12)}-${sides.head.sha.slice(0, 12)}`;
|
|
201
|
+
const created = [];
|
|
202
|
+
let quote, pair;
|
|
203
|
+
// A deposit taken in this run comes back when its build is deleted; say which way it went.
|
|
204
|
+
const release = async () => {
|
|
205
|
+
if (!created.length) return;
|
|
206
|
+
const dropped = [], kept = [];
|
|
207
|
+
for (const build of created) {
|
|
208
|
+
try { await api.hosted.deleteBuild(gameId, build.buildId); dropped.push(build); } catch { kept.push(build); }
|
|
209
|
+
}
|
|
210
|
+
const each = capabilities.reservationOffer.amountCents;
|
|
211
|
+
if (dropped.length) log(`The ${money(each * dropped.length)} held for this run comes back once its upload is cleaned up, within minutes.`);
|
|
212
|
+
for (const build of kept) log(`${money(each)} stays held on build ${build.buildId} until it expires${build.expiresAt ? ` (${build.expiresAt})` : ''}, then comes back.`);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const known = (await api.hosted.builds(gameId)).builds;
|
|
216
|
+
const fresh = [sides.base.built, sides.head.built].filter(built => !known.some(build => build.sha256 === built.sha256 && ['ready', 'awaiting_validation'].includes(build.state))).length;
|
|
217
|
+
const deposit = fresh * capabilities.reservationOffer.amountCents;
|
|
218
|
+
log(`This comparison is ${money(offer.totalPriceCents)}: ${money(offer.computePriceCents)} for two machines and ${money(offer.comparisonPriceCents)} for the comparison.`);
|
|
219
|
+
log(`${deposit ? `Uploading takes ${money(deposit)} now as deposits toward that` : 'Nothing is taken by uploading'}, and the rest is taken when it starts.`);
|
|
220
|
+
log(`If a build fails to run: its machine is ${offer.customerFailure === 'refund' ? 'refunded' : 'kept'}. If you cancel after a machine started: it is ${offer.cancelAfterStart === 'refund' ? 'refunded' : 'kept'}. A machine failing on our side is refunded.`);
|
|
221
|
+
if (offer.timeLimit) log(offer.timeLimit === 'refund' ? 'If a game reaches the time limit: its machine and the comparison are refunded.' : 'If a game reaches the time limit and left results: its machine is kept and the comparison is refunded. With no results, both are refunded.');
|
|
222
|
+
if (Number.isFinite(offer.minimumBalanceCents)) log(`A balance of ${money(offer.minimumBalanceCents)} is needed to start, counting any deposits. Only the price is charged.`);
|
|
223
|
+
if (!flags.yes && !await confirm(`Upload and start this comparison for ${money(offer.totalPriceCents)}?`, { yes: flags.yes })) { log('Stopped. Nothing was charged.'); return EXIT.OK; }
|
|
224
|
+
const fetchImpl = injected.fetch || fetch;
|
|
225
|
+
const base = await ensureBuild({ api, gameId, built: sides.base.built, sleep, fetchImpl, log, label: 'base', clock, created });
|
|
226
|
+
const headBuild = await ensureBuild({ api, gameId, built: sides.head.built, sleep, fetchImpl, log, label: 'head', clock, created });
|
|
227
|
+
quote = await api.hosted.quotePair(gameId, { baseBuildId: base.buildId, headBuildId: headBuild.buildId, simulation: simulationFor({ seed, games, steps }) });
|
|
228
|
+
if (quote.funding && quote.funding.eligible === false) {
|
|
229
|
+
log(`Add ${money(quote.funding.requiredTopUpCents)} of prepaid balance to start.${Number.isFinite(quote.funding.minimumBalanceCents) ? ` This comparison needs ${money(quote.funding.minimumBalanceCents)} available to start, though only its price is charged.` : ''}`);
|
|
230
|
+
await release();
|
|
231
|
+
return EXIT.ENVIRONMENT;
|
|
232
|
+
}
|
|
233
|
+
// The price the prompt named is the price, or the person is asked again.
|
|
234
|
+
if (quote.totalPriceCents !== offer.totalPriceCents && !flags.yes && !await confirm(`The price is now ${money(quote.totalPriceCents)}. Start it?`, { yes: flags.yes })) {
|
|
235
|
+
log('Stopped.');
|
|
236
|
+
await release();
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
// One pair of quotes is one key, so a retry reaches a pair the server already committed.
|
|
240
|
+
const pairKey = `pair-${createHash('sha256').update(`${gameId}:${quote.baseQuoteId}:${quote.headQuoteId}`).digest('hex').slice(0, 40)}`;
|
|
241
|
+
const body = {
|
|
242
|
+
baseQuoteId: quote.baseQuoteId, headQuoteId: quote.headQuoteId, totalPriceCents: quote.totalPriceCents, dueNowCents: quote.dueNowCents,
|
|
243
|
+
priceVersion: quote.priceVersion, policyVersion: quote.policyVersion,
|
|
244
|
+
refs: { base: { ref: sides.base.ref, sha: sides.base.sha }, head: { ref: sides.head.ref, sha: sides.head.sha } }
|
|
245
|
+
};
|
|
246
|
+
let unanswered = null;
|
|
247
|
+
for (let attempt = 0; attempt < PAIR_ATTEMPTS && !pair; attempt += 1) {
|
|
248
|
+
try {
|
|
249
|
+
pair = await api.hosted.createPair(gameId, body, pairKey);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// A 4xx is the server saying no, and only that proves nothing was committed, along
|
|
252
|
+
// with the one 503 that says a pair can never fit, which is decided before any write.
|
|
253
|
+
if ((error.status >= 400 && error.status < 500) || error.code === 'hosted_comparison_capacity') throw error;
|
|
254
|
+
unanswered = error;
|
|
255
|
+
if (attempt + 1 < PAIR_ATTEMPTS) await sleep(BUILD_POLL_MS);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!pair) {
|
|
259
|
+
log(`The server did not answer (${String(unanswered.message).slice(0, 200)}), so this comparison may have started. Your builds and deposits are left as they are. Check the jobs page before trying again.`);
|
|
260
|
+
return EXIT.ENVIRONMENT;
|
|
261
|
+
}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
log(`${String(error.message).slice(0, 500)}`);
|
|
264
|
+
await release();
|
|
265
|
+
return EXIT.ENVIRONMENT;
|
|
266
|
+
}
|
|
267
|
+
log(`Comparison ${pair.pairId} started.`);
|
|
268
|
+
const link = jobLink(gameId, pair.base.jobId, setup.apiUrl);
|
|
269
|
+
if (link) log(`Follow it at ${link}`);
|
|
270
|
+
if (flags.wait === false) {
|
|
271
|
+
if (flags.json) console.log(JSON.stringify(pair));
|
|
272
|
+
return EXIT.OK;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const deadline = clock() + WAIT_MS;
|
|
276
|
+
while (pair.state !== 'settled' && !pair.underReview && clock() < deadline) {
|
|
277
|
+
await sleep(POLL_MS);
|
|
278
|
+
try { pair = await api.hosted.readPair(gameId, pair.pairId); } catch (error) { if (error.status === 404) throw error; }
|
|
279
|
+
}
|
|
280
|
+
if (flags.json) console.log(JSON.stringify(pair));
|
|
281
|
+
if (pair.state !== 'settled' && pair.underReview) {
|
|
282
|
+
log(`Comparison ${pair.pairId} is under review: a machine ended without a result, so a person decides what it cost. The money stays where it is until then, and the job page shows the outcome.`);
|
|
283
|
+
return EXIT.OK;
|
|
284
|
+
}
|
|
285
|
+
if (pair.state !== 'settled') {
|
|
286
|
+
log(`Comparison ${pair.pairId} is still running. The job page shows it when it finishes.`);
|
|
287
|
+
return EXIT.OK;
|
|
288
|
+
}
|
|
289
|
+
for (const line of comparisonLines(pair)) log(line);
|
|
290
|
+
if (flags.failOnRegression && pair.report?.verdict !== 'matched') return EXIT.FAILED;
|
|
291
|
+
return pair.report ? EXIT.OK : EXIT.FAILED;
|
|
292
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { chmod, cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The scaffold `ravensight-playtest create` writes: a versioned Godot 4 2D template
|
|
10
|
+
* from this package, with this game's name, its design beside it and its development
|
|
11
|
+
* key on this machine only. Nothing is generated: the code is the template's and the
|
|
12
|
+
* art is placeholder primitives, because a studio's own assets arrive later.
|
|
13
|
+
*
|
|
14
|
+
* The SDK in the template is the public release, byte for byte, as a customer would
|
|
15
|
+
* download it. `template.json` pins its tag and digest, and `readTemplate` refuses a
|
|
16
|
+
* package where that no longer holds.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
export const TEMPLATE_DIR = fileURLToPath(new URL('../../templates/godot-2d/', import.meta.url));
|
|
22
|
+
/** The 3D Alpha's template. `create` never uses it: it starts from `template godot-3d`. */
|
|
23
|
+
export const TEMPLATE_3D_DIR = fileURLToPath(new URL('../../templates/godot-3d/', import.meta.url));
|
|
24
|
+
export const KEY_FILE = 'ravensight_key.cfg';
|
|
25
|
+
const NAME_TOKEN = /__GAME_NAME__/g;
|
|
26
|
+
/** Files that name the game. Nothing else is rewritten. */
|
|
27
|
+
const NAMED_FILES = Object.freeze(['project.godot', 'README.md']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The template's manifest, with its pinned SDK proved.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [dir]
|
|
33
|
+
* @returns {Promise<Object>}
|
|
34
|
+
*/
|
|
35
|
+
export async function readTemplate(dir = TEMPLATE_DIR) {
|
|
36
|
+
const manifest = JSON.parse(await readFile(path.join(dir, 'template.json'), 'utf8'));
|
|
37
|
+
const sdk = await readFile(path.join(dir, manifest.sdk.path));
|
|
38
|
+
const digest = createHash('sha256').update(sdk).digest('hex');
|
|
39
|
+
if (digest !== manifest.sdk.sha256) throw new Error(`The template's SDK does not match its pinned digest (${manifest.sdk.tag}).`);
|
|
40
|
+
return manifest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A string safe inside a quoted Godot value, on one line. */
|
|
44
|
+
export function godotString(value) {
|
|
45
|
+
return String(value).replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The key file: this game's own key, and the API it talks to. Mode 600 and ignored
|
|
50
|
+
* by git. The key is only ever written here, and never printed.
|
|
51
|
+
*
|
|
52
|
+
* @param {{dir: string, ingestKey: string, apiUrl: string}} input
|
|
53
|
+
*/
|
|
54
|
+
export async function writeKeyFile({ dir, ingestKey, apiUrl }) {
|
|
55
|
+
const file = path.join(dir, KEY_FILE);
|
|
56
|
+
await writeFile(file, `[ravensight]\ningest_key="${godotString(ingestKey)}"\napi_url="${godotString(apiUrl)}"\n`, { mode: 0o600 });
|
|
57
|
+
await chmod(file, 0o600);
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The approved design, as the file a developer and the model both read. */
|
|
62
|
+
export function designMarkdown(design, { declared = null } = {}) {
|
|
63
|
+
const out = [
|
|
64
|
+
`# ${design.title}`,
|
|
65
|
+
'',
|
|
66
|
+
design.pitch,
|
|
67
|
+
'',
|
|
68
|
+
'## Core loop',
|
|
69
|
+
'',
|
|
70
|
+
design.core_loop.summary,
|
|
71
|
+
'',
|
|
72
|
+
...design.core_loop.player_steps.map((step, index) => `${index + 1}. ${step}`),
|
|
73
|
+
'',
|
|
74
|
+
`Done when: ${design.core_loop.done_when}`,
|
|
75
|
+
'',
|
|
76
|
+
'Call `GameState.complete_core_loop()` once for each pass of it.',
|
|
77
|
+
'',
|
|
78
|
+
'## Controls',
|
|
79
|
+
'',
|
|
80
|
+
...design.controls.map(control => `- \`${control.action}\`: ${control.meaning}`),
|
|
81
|
+
'',
|
|
82
|
+
`How to start: ${design.how_to_start}`,
|
|
83
|
+
'',
|
|
84
|
+
`Success: ${design.success_criteria}`,
|
|
85
|
+
'',
|
|
86
|
+
'## Milestones',
|
|
87
|
+
''
|
|
88
|
+
];
|
|
89
|
+
design.milestones.forEach((milestone, index) => {
|
|
90
|
+
const past = declared !== null && index >= declared ? ' (past the declared count, so not bought)' : '';
|
|
91
|
+
out.push(`${index + 1}. **${milestone.title}**${past}: ${milestone.summary}`);
|
|
92
|
+
});
|
|
93
|
+
out.push('', '## Placeholders', '', ...(design.placeholders.length ? design.placeholders.map(item => `- ${item}`) : ['None named.']), '');
|
|
94
|
+
return out.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function countFiles(dir) {
|
|
98
|
+
let total = 0;
|
|
99
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
100
|
+
if (entry.name === '.git' || entry.name === '.ravensight' || entry.name === '.godot') continue;
|
|
101
|
+
total += entry.isDirectory() ? await countFiles(path.join(dir, entry.name)) : 1;
|
|
102
|
+
}
|
|
103
|
+
return total;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function git(dir, args) {
|
|
107
|
+
return execFileAsync('git', ['-C', dir, ...args], { maxBuffer: 16 * 1024 * 1024 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Make `dir` a repository whose ignore file exists before anything else does, so the
|
|
112
|
+
* key that is already there can never be staged.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} dir
|
|
115
|
+
* @param {{templateDir?: string}} [options]
|
|
116
|
+
*/
|
|
117
|
+
export async function initRepository(dir, { templateDir = TEMPLATE_DIR } = {}) {
|
|
118
|
+
await mkdir(dir, { recursive: true });
|
|
119
|
+
await cp(path.join(templateDir, 'gitignore'), path.join(dir, '.gitignore'));
|
|
120
|
+
await git(dir, ['init', '-q', '-b', 'main']);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The author on a commit this command makes, when the machine has none set. */
|
|
124
|
+
async function identityArgs(dir) {
|
|
125
|
+
const named = await git(dir, ['config', 'user.email']).then(answer => answer.stdout.trim()).catch(() => '');
|
|
126
|
+
return named ? [] : ['-c', 'user.name=Ravensight create', '-c', 'user.email=create@ravensight.invalid'];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Commit everything that is not ignored, here, on this branch. Never pushes. */
|
|
130
|
+
export async function commitAll(dir, message) {
|
|
131
|
+
await git(dir, ['add', '-A']);
|
|
132
|
+
const { stdout } = await git(dir, ['status', '--porcelain']);
|
|
133
|
+
if (!stdout.trim()) return null;
|
|
134
|
+
await git(dir, [...await identityArgs(dir), 'commit', '-q', '-m', message]);
|
|
135
|
+
return (await git(dir, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Back to main with this branch's commit on it, only ever as a fast-forward. */
|
|
139
|
+
export async function landOnMain(dir, branch) {
|
|
140
|
+
await git(dir, ['checkout', '-q', 'main']);
|
|
141
|
+
await git(dir, ['merge', '-q', '--ff-only', branch]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function checkoutMain(dir) {
|
|
145
|
+
await git(dir, ['checkout', '-q', 'main']);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function currentBranch(dir) {
|
|
149
|
+
return (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function hasCommit(dir) {
|
|
153
|
+
return git(dir, ['rev-parse', '--verify', '-q', 'HEAD']).then(() => true).catch(() => false);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Copy the template in, name it, write the design beside it, and commit. Only ever
|
|
158
|
+
* on an empty repository: a scaffold never lands on top of somebody's work, and the
|
|
159
|
+
* caller's key file is left alone because it was written first and is ignored.
|
|
160
|
+
*
|
|
161
|
+
* @param {{dir: string, name: string, design?: Object|null, declared?: number, templateDir?: string}} input
|
|
162
|
+
* @returns {Promise<{manifest: Object, files: number, commit: string}>}
|
|
163
|
+
*/
|
|
164
|
+
export async function writeScaffold({ dir, name, design, declared, templateDir = TEMPLATE_DIR }) {
|
|
165
|
+
const manifest = await readTemplate(templateDir);
|
|
166
|
+
await cp(templateDir, dir, {
|
|
167
|
+
recursive: true,
|
|
168
|
+
force: true,
|
|
169
|
+
filter: source => {
|
|
170
|
+
const relative = path.relative(templateDir, source);
|
|
171
|
+
return relative !== 'gitignore' && relative !== 'template.json';
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
for (const file of NAMED_FILES) {
|
|
175
|
+
const full = path.join(dir, file);
|
|
176
|
+
const safe = file === 'project.godot' ? godotString(name) : String(name).replace(/[\r\n]+/g, ' ');
|
|
177
|
+
await writeFile(full, (await readFile(full, 'utf8')).replace(NAME_TOKEN, () => safe));
|
|
178
|
+
}
|
|
179
|
+
if (design) await writeFile(path.join(dir, 'DESIGN.md'), designMarkdown(design, { declared }));
|
|
180
|
+
// Which template this came from, for a later reader.
|
|
181
|
+
await writeFile(path.join(dir, '.ravensight-template.json'), `${JSON.stringify({ template: manifest.name, version: manifest.version, sdk: manifest.sdk.version }, null, 2)}\n`);
|
|
182
|
+
const commit = await commitAll(dir, `Scaffold ${String(name).replace(/[\r\n]+/g, ' ')} from the ${manifest.name} template ${manifest.version}`);
|
|
183
|
+
return { manifest, files: await countFiles(dir), commit };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Removes a directory this command created and nothing else has used. */
|
|
187
|
+
export async function discard(dir) {
|
|
188
|
+
await rm(dir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
export default { readTemplate, writeKeyFile, designMarkdown, initRepository, commitAll, landOnMain, checkoutMain, currentBranch, hasCommit, writeScaffold, godotString, TEMPLATE_DIR, TEMPLATE_3D_DIR, KEY_FILE };
|