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.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -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: ${formatCents(body.shortfall_cents)} short. Top up at ${body.topup_url || 'ravensight.io'} and run this again.`);
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
- await aggregatePhase({
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 api.jobs.finish(gameId, jobId, {
563
- state: budgetHit && !anySucceeded ? 'budget_exceeded' : undefined,
564
- reason: canceled ? 'cancelled by the developer' : ''
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
- journal.finished = true;
567
- await stateFile.save(repoRoot, journal);
568
- log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
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
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier: options.keyModifier }));
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
- brief: brief || pack.brief || null,
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
- // Hand the signal back to the default behaviour, so a second press kills
1181
- // the process rather than being swallowed by a handler that is now done.
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: path.resolve(context.repoRoot || options.repo || process.cwd()),
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') return options.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
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;