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/run.js
CHANGED
|
@@ -3,19 +3,26 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { buildAggregate, writeAggregate } from '../run/aggregate.js';
|
|
6
|
+
import { reportPrOutcomes } from '../run/labsOutcome.js';
|
|
7
|
+
import { finishJob } from '../run/settle.js';
|
|
6
8
|
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
7
9
|
import { controllerPlan, quoteMatchesPlan, runsMatchPlan } from '../run/controllers.js';
|
|
8
10
|
import { getDeps } from '../run/deps.js';
|
|
9
11
|
import { createDriver } from '../run/drivers/index.js';
|
|
10
12
|
import EXIT from '../run/exit.js';
|
|
13
|
+
import { stopRunningChecks } from '../run/checks.js';
|
|
14
|
+
import { detectGodotBinary, measureProject } from '../run/drivers/godot-project.js';
|
|
15
|
+
import { claimSignal } from '../run/liveWork.js';
|
|
11
16
|
import { createHeartbeat } from '../run/heartbeat.js';
|
|
12
17
|
import { createUsageLedger } from '../run/usage.js';
|
|
13
|
-
import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
|
|
18
|
+
import { configureSpendHold, createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
|
|
14
19
|
import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
|
|
15
20
|
import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
16
21
|
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
17
22
|
import { automationChecks } from './check.js';
|
|
18
23
|
import { runPersona } from '../run/personaLoop.js';
|
|
24
|
+
import { tracesWanted } from '../run/acceptance.js';
|
|
25
|
+
import { cleanCommit } from '../run/replay.js';
|
|
19
26
|
import { loadLocalBrief, localBriefProblems } from '../briefLocal.js';
|
|
20
27
|
import { DEFAULT_PERSONAS as RUN_DEFAULT_PERSONAS, RUN_MODULES, SIGN_UP_ACTIONS, defaultPersonasFor, describeBreakdown, personaCount, unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
|
|
21
28
|
import { bold } from '../ui/index.js';
|
|
@@ -103,12 +110,18 @@ async function run(flags, injected, deps, log) {
|
|
|
103
110
|
if (keyModifier.error) { log(keyModifier.error); return EXIT.ENVIRONMENT; }
|
|
104
111
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
105
112
|
if (controllers?.length > 1 && !['playwright_web', 'godot_driver'].includes(driverName)) { log('Comparisons currently require a web or Godot build. Nothing was charged.'); return EXIT.ENVIRONMENT; }
|
|
106
|
-
const target = resolveTarget(options, context.config, driverName);
|
|
113
|
+
const target = resolveTarget(options, context.config, driverName, repoRoot);
|
|
107
114
|
if (!target) {
|
|
108
115
|
log('Point the run at a build: --build-url for a web build, --godot-project for Godot, or --driver-config for a native or text bridge.');
|
|
109
116
|
return EXIT.ENVIRONMENT;
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
const noGodot = await godotRefusal({ driverName, godotPath: options.godotPath, projectDir: target, injected });
|
|
120
|
+
if (noGodot) {
|
|
121
|
+
log(noGodot);
|
|
122
|
+
return EXIT.ENVIRONMENT;
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
// `aggregate_report` is asked for explicitly: it is what buys the run the
|
|
113
126
|
// `aggregate.*` steps are spendable from, and it is free (the price list has
|
|
114
127
|
// no unit for it), so asking costs nothing and not asking costs the judged
|
|
@@ -247,6 +260,11 @@ async function run(flags, injected, deps, log) {
|
|
|
247
260
|
}
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
// A Godot run always writes its trace locally. It goes up only for a game with
|
|
264
|
+
// Labs on, where a replay or a fix's verify can read it back from another
|
|
265
|
+
// machine; an older server or a failed read leaves it on this one.
|
|
266
|
+
if (driverName === 'godot_driver') options.uploadTrace = await tracesWanted(api, gameId);
|
|
267
|
+
|
|
250
268
|
const idempotencyKey = options.idempotencyKey || randomUUID();
|
|
251
269
|
let registered;
|
|
252
270
|
try {
|
|
@@ -308,6 +326,10 @@ async function run(flags, injected, deps, log) {
|
|
|
308
326
|
}
|
|
309
327
|
log(`job ${jobId}${registered.replayed ? ' (replayed)' : ''}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${directory}`);
|
|
310
328
|
|
|
329
|
+
// Which commit a trace was recorded on, so `replay --baseline` can check it
|
|
330
|
+
// there. Null on a dirty tree: that code is not in any commit to go back to.
|
|
331
|
+
const commit = driverName === 'godot_driver' ? await cleanCommit(repoRoot) : null;
|
|
332
|
+
|
|
311
333
|
return playJob({
|
|
312
334
|
deps,
|
|
313
335
|
api,
|
|
@@ -315,7 +337,7 @@ async function run(flags, injected, deps, log) {
|
|
|
315
337
|
gameId,
|
|
316
338
|
jobId,
|
|
317
339
|
repoRoot,
|
|
318
|
-
runs: registered.runs || [],
|
|
340
|
+
runs: (registered.runs || []).map(entry => (commit ? { ...entry, trace_header: { commit } } : entry)),
|
|
319
341
|
pack,
|
|
320
342
|
personas,
|
|
321
343
|
driverName,
|
|
@@ -339,6 +361,32 @@ async function run(flags, injected, deps, log) {
|
|
|
339
361
|
* @param {(message: string) => void} log
|
|
340
362
|
* @returns {number|null} an exit code, or null when the error is not one of these
|
|
341
363
|
*/
|
|
364
|
+
/**
|
|
365
|
+
* The line that names a job's start balance before it is confirmed, or null for a
|
|
366
|
+
* job with none. The floor is the server's figure, and it is never charged.
|
|
367
|
+
*
|
|
368
|
+
* @param {{estimate_cents?: number, minimum_balance_cents?: number}} quote
|
|
369
|
+
* @returns {string|null}
|
|
370
|
+
*/
|
|
371
|
+
export function startBalanceLine(quote) {
|
|
372
|
+
if (!Number.isFinite(quote?.minimum_balance_cents)) return null;
|
|
373
|
+
return `A balance of ${formatCents(quote.minimum_balance_cents)} is needed to start. Only the price, ${formatCents(quote.estimate_cents)}, is charged.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The refusal when a balance is short of a job's start balance. A role that may not
|
|
378
|
+
* see the balance is told the floor, and no figure that could reveal it.
|
|
379
|
+
*
|
|
380
|
+
* @param {{estimate_cents: number, minimum_balance_cents: number, balance_cents?: number|null, shortfall_cents?: number|null, topup_url?: string}} quote
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
export function startBalanceShort(quote) {
|
|
384
|
+
const shortfall = Number.isFinite(quote.shortfall_cents) ? quote.shortfall_cents
|
|
385
|
+
: Number.isFinite(quote.balance_cents) ? quote.minimum_balance_cents - quote.balance_cents : null;
|
|
386
|
+
const more = shortfall !== null && shortfall > 0 ? `, ${formatCents(shortfall)} more than this account has` : '';
|
|
387
|
+
return `This job needs a balance of ${formatCents(quote.minimum_balance_cents)} to start${more}. Only its price of ${formatCents(quote.estimate_cents)} is charged, and the rest stays in the balance. Top up at ${quote.topup_url || 'ravensight.io'} and run this again. Nothing was charged.`;
|
|
388
|
+
}
|
|
389
|
+
|
|
342
390
|
export function explainRegisterRefusal(error, log) {
|
|
343
391
|
const code = error && error.code;
|
|
344
392
|
const body = (error && error.body) || {};
|
|
@@ -346,8 +394,12 @@ export function explainRegisterRefusal(error, log) {
|
|
|
346
394
|
log('The server refused this job because the game has no complete expectations brief. Run "ravensight-playtest brief" and try again.');
|
|
347
395
|
return EXIT.ENVIRONMENT;
|
|
348
396
|
}
|
|
397
|
+
if (code === 'insufficient_balance' && Number.isFinite(body.minimum_balance_cents)) {
|
|
398
|
+
log(startBalanceShort(body));
|
|
399
|
+
return EXIT.ENVIRONMENT;
|
|
400
|
+
}
|
|
349
401
|
if (code === 'insufficient_balance') {
|
|
350
|
-
log(`That is more than the balance covers
|
|
402
|
+
log(`That is more than the balance covers${Number.isFinite(body.shortfall_cents) ? `: ${formatCents(body.shortfall_cents)} short` : ''}. Top up at ${body.topup_url || 'ravensight.io'} and run this again.`);
|
|
351
403
|
return EXIT.ENVIRONMENT;
|
|
352
404
|
}
|
|
353
405
|
if (code === 'price_changed') {
|
|
@@ -526,7 +578,8 @@ export async function playJob({
|
|
|
526
578
|
}
|
|
527
579
|
}
|
|
528
580
|
|
|
529
|
-
|
|
581
|
+
// A validate job has no aggregate: its comparison is computed by the server.
|
|
582
|
+
if (!options.skipAggregate) await aggregatePhase({
|
|
530
583
|
deps,
|
|
531
584
|
api,
|
|
532
585
|
client,
|
|
@@ -558,20 +611,31 @@ export async function playJob({
|
|
|
558
611
|
const anySucceeded = results.some(result => result.state === 'succeeded' && !result.reportRefused);
|
|
559
612
|
const reportRefused = results.some(result => result.reportRefused);
|
|
560
613
|
|
|
614
|
+
let settledState = null;
|
|
561
615
|
if (finish) {
|
|
562
|
-
const finished = await
|
|
563
|
-
|
|
564
|
-
|
|
616
|
+
const finished = await finishJob({
|
|
617
|
+
api, gameId, jobId, log,
|
|
618
|
+
body: {
|
|
619
|
+
state: budgetHit && !anySucceeded ? 'budget_exceeded' : undefined,
|
|
620
|
+
reason: canceled ? 'cancelled by the developer' : ''
|
|
621
|
+
}
|
|
565
622
|
});
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
623
|
+
if (finished) {
|
|
624
|
+
journal.finished = true;
|
|
625
|
+
await stateFile.save(repoRoot, journal);
|
|
626
|
+
settledState = finished.job ? finished.job.state : null;
|
|
627
|
+
log(`job ${jobId} ${settledState || 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
|
|
628
|
+
}
|
|
569
629
|
}
|
|
570
630
|
log(`Reports: ${directory}`);
|
|
571
631
|
|
|
572
632
|
if (canceled) return EXIT.CANCELED;
|
|
573
633
|
if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
|
|
574
634
|
if (playBudgetHit) return EXIT.BUDGET;
|
|
635
|
+
// The server's settlement is the verdict on the job. A job it settled as
|
|
636
|
+
// failed (every run refunded, nothing kept) exited 0 in 0.5.0, so a script
|
|
637
|
+
// could not tell it from a success.
|
|
638
|
+
if (settledState === 'failed' || settledState === 'stalled') return EXIT.FAILED;
|
|
575
639
|
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
576
640
|
}
|
|
577
641
|
|
|
@@ -612,7 +676,9 @@ export async function playPersonaRun({
|
|
|
612
676
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
613
677
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
614
678
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
615
|
-
|
|
679
|
+
// A validate run plays its own side's checkout and its pair's seed.
|
|
680
|
+
const runTarget = entry.target || target;
|
|
681
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target: runTarget, directory, entry, jobId, keyModifier: options.keyModifier, godotPath: options.godotPath, realtime: options.realtime }));
|
|
616
682
|
// The ledger is created here rather than inside the loop so the heartbeat
|
|
617
683
|
// can feed it the server's own figure for what this run has spent. Prices
|
|
618
684
|
// are server side only, so that answer is the only real one.
|
|
@@ -659,12 +725,13 @@ export async function playPersonaRun({
|
|
|
659
725
|
persona,
|
|
660
726
|
driver,
|
|
661
727
|
driverName,
|
|
662
|
-
target,
|
|
728
|
+
target: runTarget,
|
|
663
729
|
runDirectory: directory,
|
|
664
730
|
skill,
|
|
665
731
|
// The brief this command fetched and checked is complete, not the
|
|
666
732
|
// pack's copy, which a pack bundle does not always carry.
|
|
667
|
-
|
|
733
|
+
// A feature scenario brings its own, built from the frozen specification.
|
|
734
|
+
brief: entry.brief || brief || pack.brief || null,
|
|
668
735
|
localBrief: usableLocalBrief(localBrief),
|
|
669
736
|
intentNotes: pack.intent_notes || [],
|
|
670
737
|
schemas: pack.schemas || [],
|
|
@@ -762,7 +829,7 @@ export async function playPersonaRun({
|
|
|
762
829
|
});
|
|
763
830
|
|
|
764
831
|
const delivered = {};
|
|
765
|
-
await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered });
|
|
832
|
+
await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered, extraBody: { ...validationBody(entry, outcome), ...(Number.isInteger(entry.report_scenario) ? { feature_scenario: entry.report_scenario } : {}) } });
|
|
766
833
|
|
|
767
834
|
if (delivered.reportInvalid) {
|
|
768
835
|
// No synthetic telemetry for findings the server does not hold, and no
|
|
@@ -791,6 +858,30 @@ export async function playPersonaRun({
|
|
|
791
858
|
return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
|
|
792
859
|
}
|
|
793
860
|
|
|
861
|
+
/**
|
|
862
|
+
* What a validate run measured, in the shape its `complete` carries. Nothing for
|
|
863
|
+
* any other run, or a run whose driver measured nothing. A death count that is
|
|
864
|
+
* not a whole number is not one the game meant as a count, so it reads unknown.
|
|
865
|
+
*
|
|
866
|
+
* @param {Object} entry
|
|
867
|
+
* @param {Object} outcome
|
|
868
|
+
* @returns {{validation: Object}|null}
|
|
869
|
+
*/
|
|
870
|
+
export function validationBody(entry, outcome) {
|
|
871
|
+
const metrics = outcome && outcome.playMetrics;
|
|
872
|
+
if (!entry || !entry.validation || !metrics) return null;
|
|
873
|
+
return {
|
|
874
|
+
validation: {
|
|
875
|
+
seed: entry.seed,
|
|
876
|
+
ticks: metrics.ticks,
|
|
877
|
+
game_seconds: metrics.game_seconds,
|
|
878
|
+
deaths: Number.isInteger(metrics.deaths) && metrics.deaths >= 0 ? metrics.deaths : null,
|
|
879
|
+
errors: metrics.errors,
|
|
880
|
+
replayable: metrics.replayable
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
794
885
|
/**
|
|
795
886
|
* A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
|
|
796
887
|
* each a number or null.
|
|
@@ -819,12 +910,13 @@ export function durationBody(duration) {
|
|
|
819
910
|
* uploaded and completed in exactly this order, including the `uploading`
|
|
820
911
|
* transition the server's table allows from `reporting` and nowhere else.
|
|
821
912
|
*/
|
|
822
|
-
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null }) {
|
|
913
|
+
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null, extraBody = null }) {
|
|
823
914
|
const body = {
|
|
824
915
|
state: outcome.state,
|
|
825
916
|
reason: outcome.quitDetail || '',
|
|
826
917
|
actions_taken: outcome.actionsTaken,
|
|
827
|
-
quit_reason: outcome.quitReason
|
|
918
|
+
quit_reason: outcome.quitReason,
|
|
919
|
+
...(extraBody || {})
|
|
828
920
|
};
|
|
829
921
|
// The run card's duration. The server lane accepts `duration`; an older
|
|
830
922
|
// server ignores a key it does not know.
|
|
@@ -867,6 +959,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
867
959
|
runId,
|
|
868
960
|
includeVideo: Boolean(options.uploadVideo),
|
|
869
961
|
includeTranscript: Boolean(options.uploadTranscript),
|
|
962
|
+
includeTrace: Boolean(options.uploadTrace),
|
|
870
963
|
body
|
|
871
964
|
});
|
|
872
965
|
} catch (error) {
|
|
@@ -1176,13 +1269,20 @@ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal,
|
|
|
1176
1269
|
state.signalled = true;
|
|
1177
1270
|
state.signal = signal;
|
|
1178
1271
|
onInterrupt(signal);
|
|
1272
|
+
// A check can run for minutes, and nothing it could say now is wanted.
|
|
1273
|
+
stopRunningChecks();
|
|
1274
|
+
if (target === process) claimSignal();
|
|
1179
1275
|
log(`\n${signal}: ${message || `finishing the turn in flight, then stopping. Nothing is lost; "ravensight-playtest resume ${jobId}" carries on.`}`);
|
|
1180
|
-
|
|
1181
|
-
// the
|
|
1276
|
+
log(`Signal again to stop at once: job ${jobId} then stays open until the server's hourly sweep settles it, and what did not happen comes back then.`);
|
|
1277
|
+
// Hand the signal back, so a second press kills the process rather than being
|
|
1278
|
+
// swallowed by a handler that is now done. Godot and its temp copy are not left
|
|
1279
|
+
// behind: ./liveWork.js hears that press and tidies up first.
|
|
1182
1280
|
for (const name of signals) target.removeListener(name, onSignal);
|
|
1183
1281
|
};
|
|
1184
1282
|
|
|
1185
1283
|
for (const name of signals) target.on(name, onSignal);
|
|
1284
|
+
// The pause for the hourly spend limit tells the developer Ctrl-C stops the job.
|
|
1285
|
+
configureSpendHold({ shouldStop: () => state.signalled });
|
|
1186
1286
|
|
|
1187
1287
|
return {
|
|
1188
1288
|
get signalled() {
|
|
@@ -1213,10 +1313,39 @@ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal,
|
|
|
1213
1313
|
|
|
1214
1314
|
release() {
|
|
1215
1315
|
for (const name of signals) target.removeListener(name, onSignal);
|
|
1316
|
+
configureSpendHold({ shouldStop: () => false });
|
|
1216
1317
|
}
|
|
1217
1318
|
};
|
|
1218
1319
|
}
|
|
1219
1320
|
|
|
1321
|
+
/**
|
|
1322
|
+
* Why a Godot job cannot start on this machine, or null. Asked before any price: a job
|
|
1323
|
+
* charged and refunded over a missing binary is a worse answer than saying so for free.
|
|
1324
|
+
*
|
|
1325
|
+
* @param {{driverName: string, godotPath?: string, projectDir?: string, injected?: Object}} input
|
|
1326
|
+
* @returns {Promise<string|null>}
|
|
1327
|
+
*/
|
|
1328
|
+
export async function godotRefusal({ driverName, godotPath, projectDir, injected = {} }) {
|
|
1329
|
+
if (driverName !== 'godot_driver') return null;
|
|
1330
|
+
// A test with its own driver has no use for a real binary.
|
|
1331
|
+
const detect = injected.detectGodot || (injected.driverFactory ? null : detectGodotBinary);
|
|
1332
|
+
if (!detect) return null;
|
|
1333
|
+
try {
|
|
1334
|
+
await detect({ explicitPath: godotPath || undefined, projectDir, env: process.env });
|
|
1335
|
+
if (projectDir) {
|
|
1336
|
+
// A Godot 3 project is config_version 4. Godot 4 would quietly convert the copy
|
|
1337
|
+
// and the run would answer about something the developer does not have.
|
|
1338
|
+
const settings = await readFile(path.join(projectDir, 'project.godot'), 'utf8').catch(() => '');
|
|
1339
|
+
const version = /^config_version\s*=\s*(\d+)/m.exec(settings);
|
|
1340
|
+
if (version && Number(version[1]) < 5) return 'This project is from before Godot 4 (config_version ' + version[1] + '), and only Godot 4.2 and newer are supported. Nothing was charged.';
|
|
1341
|
+
await measureProject(projectDir, { excludes: ['.git', '.ravensight', 'node_modules', '.DS_Store'] });
|
|
1342
|
+
}
|
|
1343
|
+
return null;
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
return `${String(error && error.message)}. Nothing was charged.`;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1220
1349
|
/**
|
|
1221
1350
|
* Resolve the context, the game and the credential, or answer with an exit
|
|
1222
1351
|
* code. Shared with `profile`.
|
|
@@ -1250,12 +1379,16 @@ export async function resolveSetup(deps, options, log) {
|
|
|
1250
1379
|
return { code: EXIT.ENVIRONMENT };
|
|
1251
1380
|
}
|
|
1252
1381
|
|
|
1382
|
+
const repoRoot = path.resolve(context.repoRoot || options.repo || process.cwd());
|
|
1383
|
+
// In the background on purpose: what became of an earlier --open-pr is feedback,
|
|
1384
|
+
// and nothing this command does waits for it or can be failed by it.
|
|
1385
|
+
void reportPrOutcomes({ api: context.api, gameId, repoRoot, ...(deps.execGh ? { exec: deps.execGh } : {}) });
|
|
1253
1386
|
return {
|
|
1254
1387
|
context,
|
|
1255
1388
|
api: context.api,
|
|
1256
1389
|
gameId,
|
|
1257
1390
|
apiUrl: context.apiUrl,
|
|
1258
|
-
repoRoot
|
|
1391
|
+
repoRoot,
|
|
1259
1392
|
cliVersion: deps.cliVersion || context.api.cliVersion || '0.0.0'
|
|
1260
1393
|
};
|
|
1261
1394
|
}
|
|
@@ -1269,12 +1402,18 @@ export async function resolveSetup(deps, options, log) {
|
|
|
1269
1402
|
*
|
|
1270
1403
|
* @returns {Object}
|
|
1271
1404
|
*/
|
|
1272
|
-
export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier }) {
|
|
1405
|
+
export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier, godotPath, realtime }) {
|
|
1273
1406
|
// Only the browser driver sends key chords with a modifier.
|
|
1274
1407
|
const modifier = driverName === 'playwright_web' ? resolveKeyModifier(keyModifier).modifier : null;
|
|
1408
|
+
const godot = driverName === 'godot_driver'
|
|
1409
|
+
// Lockstep unless the game has to run in real time: every persona run on
|
|
1410
|
+
// Godot is seeded, on fixed ticks, and leaves a trace.jsonl to replay.
|
|
1411
|
+
? { pauseBetweenActions: realtime !== true, lockstep: realtime !== true, ...(Number.isInteger(entry.seed) ? { seed: entry.seed } : {}), ...(entry.trace_header ? { traceHeader: entry.trace_header } : {}), ...(godotPath ? { godotPath } : {}) }
|
|
1412
|
+
: {};
|
|
1275
1413
|
return {
|
|
1276
1414
|
headless,
|
|
1277
1415
|
...(modifier ? { modifier } : {}),
|
|
1416
|
+
...godot,
|
|
1278
1417
|
playtest: {
|
|
1279
1418
|
token: entry.playtest_token || null,
|
|
1280
1419
|
runId: entry.run_id,
|
|
@@ -1322,7 +1461,7 @@ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = nul
|
|
|
1322
1461
|
}
|
|
1323
1462
|
const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
|
|
1324
1463
|
try {
|
|
1325
|
-
const report = JSON.parse(await readFile(file, 'utf8'));
|
|
1464
|
+
const report = withoutRunnerEvidence(JSON.parse(await readFile(file, 'utf8')), await readRunnerEvidence(path.dirname(file)));
|
|
1326
1465
|
out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report });
|
|
1327
1466
|
} catch {
|
|
1328
1467
|
out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: 'failed', report: null });
|
|
@@ -1331,6 +1470,38 @@ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = nul
|
|
|
1331
1470
|
return out;
|
|
1332
1471
|
}
|
|
1333
1472
|
|
|
1473
|
+
async function readRunnerEvidence(directory) {
|
|
1474
|
+
try {
|
|
1475
|
+
const parsed = JSON.parse(await readFile(path.join(directory, RUN_FILES.runnerEvidence), 'utf8'));
|
|
1476
|
+
return Array.isArray(parsed && parsed.attached) ? parsed.attached : [];
|
|
1477
|
+
} catch {
|
|
1478
|
+
return [];
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* The report as the persona wrote it, for the aggregate: the screenshot the
|
|
1484
|
+
* runner attached to a finding so the server keeps it is evidence for the
|
|
1485
|
+
* server, not a sign that two findings sharing it are one issue.
|
|
1486
|
+
*
|
|
1487
|
+
* @param {Object} report
|
|
1488
|
+
* @param {Array<{id: string, ref: string}>} attached
|
|
1489
|
+
* @returns {Object}
|
|
1490
|
+
*/
|
|
1491
|
+
export function withoutRunnerEvidence(report, attached = []) {
|
|
1492
|
+
if (!report || !Array.isArray(report.findings) || attached.length === 0) return report;
|
|
1493
|
+
const byId = new Map(attached.map(entry => [entry.id, entry.ref]));
|
|
1494
|
+
return {
|
|
1495
|
+
...report,
|
|
1496
|
+
findings: report.findings.map(finding => {
|
|
1497
|
+
if (!byId.has(finding.id) || !Array.isArray(finding.evidence)) return finding;
|
|
1498
|
+
const ref = byId.get(finding.id);
|
|
1499
|
+
const index = finding.evidence.findLastIndex(item => item && item.type === 'screenshot' && item.ref === ref);
|
|
1500
|
+
return index === -1 ? finding : { ...finding, evidence: finding.evidence.filter((_, i) => i !== index) };
|
|
1501
|
+
})
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1334
1505
|
async function listUploadable(directory, options) {
|
|
1335
1506
|
const names = [RUN_FILES.reportMd, RUN_FILES.reportJson, RUN_FILES.usageJson];
|
|
1336
1507
|
if (options.uploadTranscript) names.push(RUN_FILES.transcript);
|
|
@@ -1542,11 +1713,16 @@ export function resolveMaxActions(options, persona, pack, run = null) {
|
|
|
1542
1713
|
return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_MAX_ACTIONS * factor;
|
|
1543
1714
|
}
|
|
1544
1715
|
|
|
1545
|
-
export function resolveTarget(options, config, driverName) {
|
|
1716
|
+
export function resolveTarget(options, config, driverName, repoRoot = null) {
|
|
1546
1717
|
if (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') return options.driverConfig || (config?.build?.kind === 'driver_config' ? config.build.ref : null);
|
|
1547
1718
|
const build = (config && config.build) || {};
|
|
1548
1719
|
if (driverName === 'playwright_web') return options.buildUrl || (build.kind === 'url' ? build.ref : null) || null;
|
|
1549
|
-
if (driverName === 'godot_driver')
|
|
1720
|
+
if (driverName === 'godot_driver') {
|
|
1721
|
+
const project = options.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
|
|
1722
|
+
// `init` writes "." for a repository that is itself the Godot project;
|
|
1723
|
+
// that means the repository, wherever the command was started from.
|
|
1724
|
+
return project && repoRoot ? path.resolve(repoRoot, project) : project;
|
|
1725
|
+
}
|
|
1550
1726
|
return options.cli || null;
|
|
1551
1727
|
}
|
|
1552
1728
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { capabilityLive } from '../run/acceptance.js';
|
|
5
|
+
import { defaultConfig, saveConfig } from '../config.js';
|
|
6
|
+
import { initRepository, TEMPLATE_3D_DIR, writeScaffold } from '../create/scaffold.js';
|
|
7
|
+
import { getDeps } from '../run/deps.js';
|
|
8
|
+
import EXIT from '../run/exit.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `ravensight-playtest template godot-3d`: the 3D Alpha's starting point.
|
|
12
|
+
*
|
|
13
|
+
* It copies a small placeholder project from this package into a new directory, as
|
|
14
|
+
* its own git repository, linked to a game that already exists. No model is involved,
|
|
15
|
+
* no design is written and nothing is charged, which is the whole difference from
|
|
16
|
+
* `create`: nothing here makes a game from a brief.
|
|
17
|
+
*
|
|
18
|
+
* It answers only for a game with Labs on and Build a feature or Validate changes
|
|
19
|
+
* live on it, because those are the only things the template is for. There is no
|
|
20
|
+
* Alpha switch of its own.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TEMPLATES = Object.freeze({ 'godot-3d': TEMPLATE_3D_DIR });
|
|
24
|
+
const OFF = 'The 3D template is part of Ravensight Labs (Alpha), and it needs Build a feature or Validate changes on for this game. Turn Labs on from the Labs page in the dashboard. Nothing was written.';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} name
|
|
28
|
+
* @param {Object} flags
|
|
29
|
+
* @param {Object} [injected] test seam
|
|
30
|
+
* @returns {Promise<number>} an exit code
|
|
31
|
+
*/
|
|
32
|
+
export async function templateCommand(name, flags = {}, injected = {}) {
|
|
33
|
+
const deps = injected.deps || (await getDeps());
|
|
34
|
+
const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
35
|
+
const templateDir = Object.hasOwn(TEMPLATES, name) ? (injected.templateDir || TEMPLATES[name]) : null;
|
|
36
|
+
if (!templateDir) {
|
|
37
|
+
log(`There is no template ${name}. This command writes ${Object.keys(TEMPLATES).join(', ')}; Create a game writes its own 2D project.`);
|
|
38
|
+
return EXIT.ENVIRONMENT;
|
|
39
|
+
}
|
|
40
|
+
const title = String(flags.name || 'New 3D project').slice(0, 80);
|
|
41
|
+
const dir = path.resolve(flags.dir || name);
|
|
42
|
+
const existing = await readdir(dir).catch(() => null);
|
|
43
|
+
if (existing && existing.length > 0) {
|
|
44
|
+
log(`${dir} is not empty, and a template only goes in an empty directory. Pass --dir with a new one. Nothing was written.`);
|
|
45
|
+
return EXIT.ENVIRONMENT;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const context = await deps.createContext({ apiUrl: flags.apiUrl, repoRoot: path.dirname(dir), requireAuth: true }).catch(error => {
|
|
49
|
+
log(`You are not signed in. Run "ravensight-playtest login" first. (${String(error && error.message)})`);
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
if (!context) return EXIT.AUTH;
|
|
53
|
+
const labs = await context.api.labs.get(flags.game).catch(() => null);
|
|
54
|
+
if (!capabilityLive(labs, 'feature') && !capabilityLive(labs, 'validate')) {
|
|
55
|
+
log(OFF);
|
|
56
|
+
return EXIT.ENVIRONMENT;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await initRepository(dir, { templateDir });
|
|
60
|
+
await saveConfig(defaultConfig(flags.game, { driver: 'godot_driver', godot_project: '.', build: { kind: 'godot_project', ref: '.' } }), { repoRoot: dir });
|
|
61
|
+
const { manifest, commit } = await writeScaffold({ dir, name: title, design: null, templateDir });
|
|
62
|
+
log(`3D Alpha: ${dir} holds the ${manifest.name} template ${manifest.version} on main (${String(commit).slice(0, 8)}). Nothing was charged. It is a proof of concept with placeholder primitives, and no key: README.md says where this game's key goes.`);
|
|
63
|
+
log('Then, from that directory:');
|
|
64
|
+
log(' npx ravensight-playtest feature "<brief>" --alpha-3d');
|
|
65
|
+
log(' npx ravensight-playtest validate <branch> --base main --alpha-3d');
|
|
66
|
+
if (flags.json) (deps.ui && deps.ui.json ? deps.ui.json : answer => console.log(JSON.stringify(answer)))({ dir, template: manifest.name, version: manifest.version, commit });
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default templateCommand;
|