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.
Files changed (99) hide show
  1. package/README.md +319 -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 +120 -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 +233 -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 +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,734 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { prepareAssets } from '../assets/index.js';
6
+ import { countsLine, DEFAULT_ASSETS_DIR } from '../assets/inventory.js';
7
+ import { saveConfig, defaultConfig } from '../config.js';
8
+ import { dashboardUrl } from '../dashboard.js';
9
+ import {
10
+ checkoutMain, commitAll, currentBranch, hasCommit, initRepository, landOnMain, writeKeyFile, writeScaffold
11
+ } from '../create/scaffold.js';
12
+ import { getDeps } from '../run/deps.js';
13
+ import EXIT from '../run/exit.js';
14
+ import { GodotDriver } from '../run/drivers/godot.js';
15
+ import { inputControls } from '../run/drivers/godot-project.js';
16
+ import { scenarioBrief } from '../run/featureLoop.js';
17
+ import { createHeartbeat } from '../run/heartbeat.js';
18
+ import { parseJsonObject } from '../run/json.js';
19
+ import { stopRunningChecks } from '../run/checks.js';
20
+ import { claimSignal, dieOf } from '../run/liveWork.js';
21
+ import { callStepWithRetry, configureSpendHold, createModelClient, STEPS } from '../run/model.js';
22
+ import { jobDir, runDir as runDirFor } from '../run/paths.js';
23
+ import { briefAsksFor3d, CREATE_REFUSES_3D } from '../run/dimension.js';
24
+ import * as stateFile from '../run/state.js';
25
+ import { parseTrace, seedFrom, TRACE_FILE } from '../run/trace.js';
26
+ import { unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
27
+ import { loadLocalBrief } from '../briefLocal.js';
28
+ import { buildFeature } from './feature.js';
29
+ import { exitCodeFor, explainRegisterRefusal, formatCents, playJob, resolvePersonas, resolveSetup, startBalanceLine, startBalanceShort } from './run.js';
30
+
31
+ /**
32
+ * `ravensight-playtest create`: Create a game (Labs, Experimental), on this machine.
33
+ *
34
+ * From a brief, it confirms the whole declared budget, makes a new game through the
35
+ * org's own create-game route, buys the prototype job, asks the model for a design
36
+ * and stops: a person reads and approves that in the dashboard. Given the plan's job
37
+ * id, it writes the scaffold, proves in real Godot that it boots, and then builds one
38
+ * milestone at a time, each an ordinary feature job with its own charge and
39
+ * settlement, moving on only when the last one validated, and stopping at the count
40
+ * that was declared. A persona then plays the finished game for the playable check.
41
+ *
42
+ * WHAT IT NEVER DOES. It never pushes. It generates no art or audio and names no tool
43
+ * for it. A model never decides whether a milestone validated or a game is playable:
44
+ * those come from the server's own run rows and from the game's own state.
45
+ *
46
+ * STOPPING. Ctrl-C closes the plan: what finished stays paid for, what never started
47
+ * comes back, and a milestone not yet bought was never charged. A process that dies
48
+ * without that leaves the plan open: run the same command again to carry on, and the
49
+ * plan closes by itself when its window ends. A milestone that does not validate
50
+ * stops here too, leaving the plan open so a retry can use another declared slot.
51
+ */
52
+
53
+ export const JOURNAL_FILE = 'create.json';
54
+ export const CAPABILITY = 'prototype';
55
+ const JOB_ID = /^pt_job_[A-Za-z0-9_-]+$/;
56
+ const PACK_MODULES = Object.freeze(['prototype', 'persona_playtest']);
57
+ export const DESIGN_REPAIRS = 1;
58
+ const DESIGN_MAX_TOKENS = 32000;
59
+ const EXPERIMENTAL = 'Create a game is Experimental: it may not produce a game you want, so read the design before you approve it, and read the code before you ship it.';
60
+
61
+ const DECLINE_TOOL = {
62
+ name: 'decline_game',
63
+ description: 'Decline a brief this cannot honestly deliver: it needs 3D, it is too large for any number of bounded milestones, or it is not a game or recreates an existing one. Declining costs the studio nothing.',
64
+ input_schema: {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ required: ['reason', 'explanation'],
68
+ properties: {
69
+ reason: { enum: ['too_large', 'out_of_scope', 'not_2d'] },
70
+ explanation: { type: 'string', maxLength: 600 }
71
+ }
72
+ }
73
+ };
74
+
75
+ // One string and not the schema itself: real models flattened and stringified a
76
+ // nested tool input, on most designs, and came back worse when told so.
77
+ const DESIGN_TOOL = {
78
+ name: 'write_game_design',
79
+ description: 'Write the game design a person will approve.',
80
+ input_schema: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ required: ['design_json'],
84
+ properties: {
85
+ design_json: { type: 'string', description: 'The whole design as one JSON object that matches design_schema.' }
86
+ }
87
+ }
88
+ };
89
+
90
+ function slugOf(text, fallback = 'new-game') {
91
+ return String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || fallback;
92
+ }
93
+
94
+ function journalPath(dir) {
95
+ return path.join(dir, '.ravensight', JOURNAL_FILE);
96
+ }
97
+
98
+ async function readJournal(dir) {
99
+ try {
100
+ return JSON.parse(await readFile(journalPath(dir), 'utf8'));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async function writeJournal(dir, journal) {
107
+ await mkdir(path.dirname(journalPath(dir)), { recursive: true });
108
+ await writeFile(journalPath(dir), `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
109
+ }
110
+
111
+ /** The limit on getting the first charge back, in the server's figures. Nothing from an older server. */
112
+ export function refundLimitLines(limit) {
113
+ if (!limit) return [];
114
+ return [`Once the model has been used, this account can get the first charge back ${limit.limit} times in any ${limit.window_days} days, and it has ${limit.left} left. Past that, a brief that is declined, a design you reject and a plan that stops before its scaffold boots all keep it.`];
115
+ }
116
+
117
+ /** What rejecting a design does to this account's money, right now. */
118
+ export function rejectLine(limit) {
119
+ if (limit && limit.left <= 0) return 'Read it and approve or reject it in the dashboard. This account has used its refunds, so rejecting it keeps the first charge.';
120
+ return `Read it and approve or reject it in the dashboard. Rejecting it gives the whole charge back${limit ? `, which uses one of your ${limit.left} refund${limit.left === 1 ? '' : 's'}` : ''}.`;
121
+ }
122
+
123
+ /** Lines stating a declared plan. Every figure is the server's. */
124
+ export function planLines(quote) {
125
+ const units = quote.breakdown.map(line => `${line.count} x ${line.unit} at ${formatCents(line.unit_cents)}`).join(', ');
126
+ return [
127
+ `Create a game (${quote.label}): up to ${quote.milestones} milestone${quote.milestones === 1 ? '' : 's'}, each at most ${quote.size}.`,
128
+ `The whole plan is at most ${formatCents(quote.estimate_cents)} (${units}).`,
129
+ `${formatCents(quote.charged_at_register_cents)} is charged now for the design, the scaffold and the persona run that checks the game is playable. Each milestone is at most ${formatCents(quote.milestone_cents)}, charged when it starts.`,
130
+ 'The run stops at the declared count. Each charge is refunded by its own rules when nothing was delivered, and a milestone that never starts is never charged.',
131
+ ...(startBalanceLine(quote) ? [startBalanceLine(quote)] : []),
132
+ ...refundLimitLines(quote.refund_limit)
133
+ ];
134
+ }
135
+
136
+ /**
137
+ * @param {string} target a brief, or a plan's job id
138
+ * @param {Object} flags
139
+ * @param {Object} [injected] test seam
140
+ * @returns {Promise<number>} an exit code
141
+ */
142
+ export async function createCommand(target, flags = {}, injected = {}) {
143
+ const deps = injected.deps || (await getDeps());
144
+ const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
145
+ const signals = createStopFlag(injected);
146
+ configureSpendHold({ log, shouldStop: () => signals.stopped });
147
+ try {
148
+ const value = String(target || '').trim();
149
+ if (!value) {
150
+ log('Say what game to make, in quotes, or name a plan\'s job id.');
151
+ return EXIT.ENVIRONMENT;
152
+ }
153
+ if (JOB_ID.test(value)) return await carryOn(value, flags, injected, deps, log, signals);
154
+ if (flags.template === 'godot-3d' || briefAsksFor3d(value)) {
155
+ log(CREATE_REFUSES_3D);
156
+ return EXIT.ENVIRONMENT;
157
+ }
158
+ return await start(value, flags, injected, deps, log, signals);
159
+ } catch (error) {
160
+ const code = exitCodeFor(error);
161
+ if (code === EXIT.FAILED) throw error;
162
+ log(String(error && error.message ? error.message : error));
163
+ return code;
164
+ } finally {
165
+ signals.release();
166
+ configureSpendHold({ shouldStop: () => false });
167
+ }
168
+ }
169
+
170
+ /** This command's own view of Ctrl-C. Handlers further in have theirs too. */
171
+ function createStopFlag(injected) {
172
+ const target = injected.process || process;
173
+ const names = injected.signals || ['SIGINT', 'SIGTERM'];
174
+ const state = { stopped: false };
175
+ // A flag alone would swallow every Ctrl-C after the first, and nothing could end a
176
+ // scaffold that hangs. A second one means now.
177
+ const onSignal = (signal) => {
178
+ if (state.stopped && target === process) dieOf(signal);
179
+ state.stopped = true;
180
+ stopRunningChecks();
181
+ if (target === process) claimSignal();
182
+ };
183
+ for (const name of names) target.on(name, onSignal);
184
+ return {
185
+ get stopped() { return state.stopped; },
186
+ release() { for (const name of names) target.removeListener(name, onSignal); }
187
+ };
188
+ }
189
+
190
+ /** The first half: price, game, job, design. A person approves what comes back. */
191
+ async function start(brief, flags, injected, deps, log, signals) {
192
+ const milestones = Number.isInteger(flags.milestones) ? flags.milestones : 3;
193
+ const size = flags.size || 'medium';
194
+ const title = flags.name || brief.split(/[.!?\n]/)[0].slice(0, 60);
195
+ const dir = path.resolve(flags.dir || slugOf(title));
196
+ const existing = await readdir(dir).catch(() => null);
197
+ if (existing && existing.length > 0) {
198
+ log(`${dir} is not empty, and a scaffold only goes in an empty directory. Pass --dir with a new one. Nothing was charged.`);
199
+ return EXIT.ENVIRONMENT;
200
+ }
201
+ if (flags.assetsDir && path.relative(dir, path.resolve(dir, flags.assetsDir)).split(path.sep)[0] === '..') {
202
+ log(`The assets folder has to be inside the project, and ${flags.assetsDir} is not. Nothing was charged.`);
203
+ return EXIT.ENVIRONMENT;
204
+ }
205
+
206
+ const context = await deps.createContext({ apiUrl: flags.apiUrl, repoRoot: path.dirname(dir), requireAuth: true }).catch(error => {
207
+ log(`You are not signed in. Run "ravensight-playtest login" first. (${String(error && error.message)})`);
208
+ return null;
209
+ });
210
+ if (!context) return EXIT.AUTH;
211
+ const { api, apiUrl } = context;
212
+
213
+ let quote;
214
+ try {
215
+ quote = await api.labs.prototypes.estimate({ milestones, size });
216
+ } catch (error) {
217
+ if (error && error.status === 404) {
218
+ log('This server does not offer Create a game. Nothing was charged.');
219
+ return EXIT.ENVIRONMENT;
220
+ }
221
+ if (error && error.status === 400) {
222
+ log(`${String(error.message)} Nothing was charged.`);
223
+ return EXIT.ENVIRONMENT;
224
+ }
225
+ throw error;
226
+ }
227
+ if (!quote.capability_live) {
228
+ log('Create a game is part of Ravensight Labs (Experimental), and it is not available yet. Nothing was charged.');
229
+ return EXIT.ENVIRONMENT;
230
+ }
231
+ if (!quote.can_manage) {
232
+ log('Only an owner or admin can start Create a game. Nothing was charged.');
233
+ return EXIT.ENVIRONMENT;
234
+ }
235
+ log(EXPERIMENTAL);
236
+ for (const line of planLines(quote)) log(line);
237
+ if (!quote.funded) {
238
+ log(`Create a game needs an account that has added at least $10.00 in top-ups. Add funds at ${quote.topup_url} and run this again. Nothing was charged.`);
239
+ return EXIT.ENVIRONMENT;
240
+ }
241
+ if (quote.ok === false && startBalanceLine(quote)) {
242
+ log(`${startBalanceShort(quote)} Declaring fewer milestones needs less.`);
243
+ return EXIT.ENVIRONMENT;
244
+ }
245
+ if (quote.ok === false) {
246
+ log(`The balance is ${formatCents(quote.balance_cents)}, and it has to cover the whole plan. Add funds at ${quote.topup_url}, or declare fewer milestones, and run this again. Nothing was charged.`);
247
+ return EXIT.ENVIRONMENT;
248
+ }
249
+ const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
250
+ if (!flags.yes && confirm && !await confirm(`Start this plan, charging ${formatCents(quote.charged_at_register_cents)} now?`, { yes: flags.yes })) {
251
+ log('Nothing was charged and nothing was created.');
252
+ return EXIT.CANCELED;
253
+ }
254
+
255
+ const gameId = `${slugOf(title).slice(0, 48)}-${randomBytes(3).toString('hex')}`;
256
+ const made = await api.labs.prototypes.createGame({ gameId, name: title.slice(0, 80) });
257
+ await initRepository(dir);
258
+ await writeKeyFile({ dir, ingestKey: made.ingestKey, apiUrl });
259
+ // A named folder is kept with the project, so every milestone finds it the way a feature does.
260
+ await saveConfig(defaultConfig(gameId, { driver: 'godot_driver', godot_project: '.', build: { kind: 'godot_project', ref: '.' }, ...(flags.assetsDir ? { assets_dir: flags.assetsDir } : {}) }), { repoRoot: dir });
261
+ log(`game ${gameId} made, with its key in ${path.join(dir, 'ravensight_key.cfg')}, which git ignores.`);
262
+
263
+ const pack = await deps.packs.get({ api, gameId, modules: [...PACK_MODULES] });
264
+ const skill = (pack.skills || []).find(entry => entry.module === 'prototype');
265
+ if (!skill) {
266
+ log('The content pack does not have this module. Update the server and CLI together. Nothing was charged.');
267
+ return EXIT.ENVIRONMENT;
268
+ }
269
+ const personaSlugs = resolvePersonas({ personas: flags.personas }, null, pack).slice(0, 1);
270
+ const unknown = unknownPersonas(personaSlugs, pack.personas || []);
271
+ if (unknown.length > 0) {
272
+ log(`${unknownPersonaMessage(unknown, pack.personas || [])} Nothing was charged.`);
273
+ return EXIT.ENVIRONMENT;
274
+ }
275
+
276
+ const journal = {
277
+ job_id: null, game_id: gameId, title, brief, milestones, size,
278
+ plan_cents: quote.estimate_cents, job_cents: quote.charged_at_register_cents,
279
+ persona: personaSlugs[0], idempotency_key: randomUUID(), api_url: apiUrl
280
+ };
281
+ await writeJournal(dir, journal);
282
+
283
+ let registered;
284
+ try {
285
+ registered = await registerPlan(api, journal, deps);
286
+ } catch (error) {
287
+ if (error && ['labs_not_enabled', 'funding_required', 'forbidden'].includes(error.code)) {
288
+ log(String(error.message));
289
+ return EXIT.ENVIRONMENT;
290
+ }
291
+ const handled = explainRegisterRefusal(error, log);
292
+ if (handled !== null) return handled;
293
+ throw error;
294
+ }
295
+ journal.job_id = registered.job_id;
296
+ await writeJournal(dir, journal);
297
+ await mkdir(jobDir(dir, registered.job_id), { recursive: true });
298
+ log(`job ${registered.job_id}: ${formatCents(registered.price.price_cents)} charged.`);
299
+
300
+ const unit = (registered.runs || []).find(entry => entry.module === 'prototype');
301
+ const createClient = injected.createClient || createModelClient;
302
+ const client = createClient({ apiUrl, token: injected.modelToken || api.token });
303
+ const outcome = await writeDesign({ api, gameId, jobId: registered.job_id, runId: unit.run_id, brief, journal, pack, skill, client, log, signals });
304
+
305
+ if (outcome.stop) {
306
+ await closeUnit(api, gameId, registered.job_id, unit.run_id, outcome.stop, log);
307
+ const closed = await stopPlan(api, gameId, registered.job_id, log);
308
+ log(moneyLine(closed));
309
+ return outcome.stop === 'declined' ? EXIT.ENVIRONMENT : (signals.stopped ? EXIT.CANCELED : EXIT.FAILED);
310
+ }
311
+
312
+ const design = outcome.plan.design.spec;
313
+ log(`The design: ${design.title}. ${design.pitch}`);
314
+ log(`Core loop: ${design.core_loop.summary}`);
315
+ outcome.plan.milestones.forEach(row => log(` ${row.index + 1}. ${row.title} (${row.size})${row.declared ? '' : ', past the declared count'}`));
316
+ log(rejectLine(outcome.plan.refund_limit));
317
+ log(` ${dashboardUrl(apiUrl)}/labs/prototypes/${registered.job_id}?game=${encodeURIComponent(gameId)}`);
318
+ log(`Your own art and audio go in ${path.join(dir, flags.assetsDir || DEFAULT_ASSETS_DIR)}. Put them there before you carry on, and each milestone can use them. Anything missing stays a placeholder.`);
319
+ log('Then carry on with:');
320
+ log(` cd ${dir} && npx ravensight-playtest create ${registered.job_id}`);
321
+ if (flags.json) (deps.ui && deps.ui.json ? deps.ui.json : answer => console.log(JSON.stringify(answer)))({ job_id: registered.job_id, game_id: gameId, dir, status: outcome.plan.status });
322
+ return EXIT.OK;
323
+ }
324
+
325
+ /** The register body, the same every time, so a replay hands back the same job and fresh run tokens. */
326
+ function registerPlan(api, journal, deps) {
327
+ return api.jobs.register(journal.game_id, {
328
+ modules: [CAPABILITY],
329
+ personas: [journal.persona],
330
+ driver: 'godot_driver',
331
+ cli_version: deps.cliVersion || api.cliVersion || '0.0.0',
332
+ confirm_price_cents: journal.job_cents,
333
+ build: { kind: 'godot_project', ref: '.' },
334
+ repo: { commit: '', dirty: false, branch: 'main' },
335
+ pack_versions: {},
336
+ prototype: { brief: journal.brief, milestones: journal.milestones, size: journal.size, confirm_plan_cents: journal.plan_cents }
337
+ }, { idempotencyKey: journal.idempotency_key });
338
+ }
339
+
340
+ /**
341
+ * The model's design, held to the server's schema, with one repair if it is refused.
342
+ *
343
+ * @returns {Promise<{plan?: Object, stop?: string}>} the stored plan, or the reason
344
+ * the unit's run closes
345
+ */
346
+ async function writeDesign({ api, gameId, jobId, runId, brief, journal, pack, skill, client, log, signals }) {
347
+ const heartbeat = createHeartbeat({ api, gameId, jobId, runId, onCancel: () => {}, onUsage: () => {}, onError: () => {} });
348
+ try {
349
+ await api.runs.transition(gameId, jobId, runId, 'launching', { reason: '' });
350
+ await api.runs.transition(gameId, jobId, runId, 'playing', { reason: '' });
351
+ heartbeat.start();
352
+ const shape = (await api.labs.prototypes.get(gameId, jobId)).prototype.design_schema;
353
+ const tools = [DESIGN_TOOL, DECLINE_TOOL];
354
+ const system = [{ type: 'text', text: skill.content, cache_control: { type: 'ephemeral' } }];
355
+ const messages = [{ role: 'user', content: [{ type: 'text', text: designPrompt({ brief, journal, shape }) }] }];
356
+ log('Writing the design.');
357
+ for (let attempt = 0; attempt <= DESIGN_REPAIRS; attempt += 1) {
358
+ if (signals.stopped) return { stop: 'design_failed' };
359
+ let answer;
360
+ try {
361
+ answer = await callStepWithRetry({ client, jobId, runId, step: STEPS.prototypeDesign, system, messages, tools, maxTokens: DESIGN_MAX_TOKENS });
362
+ } catch (error) {
363
+ log(`The design could not be written: ${String(error && error.message)}`);
364
+ return { stop: 'design_failed' };
365
+ }
366
+ const call = (answer.toolUses || [])[0];
367
+ if (call && call.name === DECLINE_TOOL.name && call.input) {
368
+ log(`Declined: ${String(call.input.explanation || call.input.reason).slice(0, 600)}`);
369
+ return { stop: 'declined' };
370
+ }
371
+ const design = designFrom(call && call.name === DESIGN_TOOL.name ? call.input : parseJsonObject(answer.text || ''), shape);
372
+ if (!design) {
373
+ log('The answer held no design.');
374
+ messages.push({ role: 'assistant', content: answer.text || '(no design)' }, { role: 'user', content: 'Call write_game_design or decline_game.' });
375
+ continue;
376
+ }
377
+ try {
378
+ const { prototype } = await api.labs.prototypes.submitDesign(gameId, jobId, design);
379
+ return { plan: prototype };
380
+ } catch (error) {
381
+ if (!(error && error.code === 'invalid_game_design')) throw error;
382
+ const details = ((error.body && error.body.details) || []).slice(0, 12).map(detail => `${detail.instancePath}: ${detail.message}`).join('\n');
383
+ log(`The design was refused by its schema${attempt < DESIGN_REPAIRS ? ', so the model gets one more try' : ''}:\n${details}`);
384
+ // The model has no memory of a design unless it is sent back, and from nothing it writes a different shape.
385
+ messages.push(
386
+ { role: 'assistant', content: JSON.stringify(design) },
387
+ { role: 'user', content: `That design was refused:\n${details}\nCall write_game_design again with this same whole design, changing only what the refusal names.` }
388
+ );
389
+ }
390
+ }
391
+ return { stop: 'design_failed' };
392
+ } finally {
393
+ heartbeat.stop();
394
+ }
395
+ }
396
+
397
+ /**
398
+ * The design inside a model's answer. A real model can answer `{ design: {...} }` for
399
+ * all the schema says otherwise, and the design is no less the design. The schema
400
+ * version is a constant the model has no way to know better, so it is ours to set.
401
+ */
402
+ export function designFrom(answer, shape) {
403
+ const unwrapped = answer && typeof answer.design_json === 'string' ? parseJsonObject(answer.design_json) : answer;
404
+ const keys = unwrapped && typeof unwrapped === 'object' ? Object.keys(unwrapped) : [];
405
+ const design = keys.length === 1 && keys[0] === 'design' && unwrapped.design && typeof unwrapped.design === 'object' ? unwrapped.design : unwrapped;
406
+ const version = shape && shape.properties && shape.properties.schema_version && shape.properties.schema_version.const;
407
+ return design && typeof design === 'object' && version ? { ...design, schema_version: version } : design;
408
+ }
409
+
410
+ function designPrompt({ brief, journal, shape }) {
411
+ return [
412
+ `<brief>\n${brief}\n</brief>`,
413
+ `<declared_milestones>${journal.milestones}</declared_milestones>`,
414
+ `<size>${journal.size}</size>`,
415
+ '<template>',
416
+ 'A Godot 4 2D project with: scenes/main.tscn (a Node2D root with a placeholder player, a HUD label and a settings overlay), scripts/main.gd, scripts/player.gd, scripts/state_hook.gd (the node a playtest reads), and these autoloads: GameState (score, level, deaths, complete_core_loop(), add_score(), record_death()), Telemetry (track(name, data, kind)), GameConfig and Ravensight. Input actions: move_left, move_right, move_up, move_down, action, pause. Pause opens settings, so a game must not take it over.',
417
+ '</template>',
418
+ `<design_schema>\n${JSON.stringify(shape)}\n</design_schema>`
419
+ ].join('\n');
420
+ }
421
+
422
+ async function closeUnit(api, gameId, jobId, runId, reason, log) {
423
+ try {
424
+ await api.runs.complete(gameId, jobId, runId, { state: 'failed', reason });
425
+ } catch (error) {
426
+ log(`the run could not be closed (${String(error && error.message)}); the plan's sweep will settle it.`);
427
+ }
428
+ }
429
+
430
+ async function stopPlan(api, gameId, jobId, log) {
431
+ try {
432
+ const { prototype } = await api.labs.prototypes.stop(gameId, jobId);
433
+ return prototype;
434
+ } catch (error) {
435
+ log(`the plan could not be closed now (${String(error && error.message)}). Run this command again with --stop, or it closes itself when its window ends.`);
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /** What a plan cost, all of it the server's figures. */
441
+ export function moneyLine(plan) {
442
+ if (!plan) return 'The server could not say what this plan cost. It is on the job page.';
443
+ const money = plan.money;
444
+ const limited = plan.refund_limited_note ? ` ${plan.refund_limited_note}` : '';
445
+ return `Declared ${formatCents(money.declared_cents)}. Charged ${formatCents(money.charged_cents)}, refunded ${formatCents(money.refunded_cents)}${money.refund_reason ? ` (${money.refund_reason})` : ''}. ${formatCents(money.never_charged_cents)} was never charged.${limited}`;
446
+ }
447
+
448
+ /** The second half. Everything is read back from the server, so it can resume anywhere. */
449
+ async function carryOn(jobId, flags, injected, deps, log, signals) {
450
+ const dir = path.resolve(flags.repoRoot || process.cwd());
451
+ const journal = await readJournal(dir);
452
+ if (!journal || journal.job_id !== jobId) {
453
+ log(`This directory is not that plan's project, because ${journalPath(dir)} does not name it. Run this from the project, or pass --repo. Nothing was charged.`);
454
+ return EXIT.ENVIRONMENT;
455
+ }
456
+ const options = {
457
+ apiUrl: flags.apiUrl, repo: dir, game: journal.game_id, yes: true, headless: flags.headless === true,
458
+ godotPath: flags.godotPath, personas: flags.personas || [journal.persona], uploadTrace: true, skipAggregate: true, concurrency: 1, verbose: flags.verbose
459
+ };
460
+ const setup = await resolveSetup(deps, options, log);
461
+ if (setup.code !== undefined) return setup.code;
462
+ const { api, gameId, apiUrl } = setup;
463
+
464
+ let plan;
465
+ try {
466
+ ({ prototype: plan } = await api.labs.prototypes.get(gameId, jobId));
467
+ } catch (error) {
468
+ if (error && error.status === 404) {
469
+ log(`There is no plan ${jobId} for this game. Nothing was charged.`);
470
+ return EXIT.ENVIRONMENT;
471
+ }
472
+ throw error;
473
+ }
474
+
475
+ if (flags.stop) {
476
+ const closed = await stopPlan(api, gameId, jobId, log);
477
+ if (!closed) return EXIT.FAILED;
478
+ log(`The plan is closed. ${moneyLine(closed)}`);
479
+ return EXIT.OK;
480
+ }
481
+ if (plan.closed) {
482
+ log(`This plan has closed (${plan.state}). ${moneyLine(plan)}`);
483
+ return EXIT.OK;
484
+ }
485
+ if (plan.status === 'designing') {
486
+ log('This plan has no design, so there is nothing to carry on from. Close it with --stop to get the charge back, within this account\'s refund limit.');
487
+ return EXIT.ENVIRONMENT;
488
+ }
489
+ if (plan.status === 'rejected') {
490
+ log(`This design was rejected. ${moneyLine(plan)}`);
491
+ return EXIT.OK;
492
+ }
493
+ if (plan.status !== 'approved') {
494
+ log('A person has not approved this design yet. Read it and approve or reject it in the dashboard. Nothing more has been charged.');
495
+ log(` ${dashboardUrl(apiUrl)}/labs/prototypes/${jobId}?game=${encodeURIComponent(gameId)}`);
496
+ return EXIT.ENVIRONMENT;
497
+ }
498
+
499
+ // Free, before anything is written or bought: a folder that was named has to be usable.
500
+ const assets = await prepareAssets({ repoRoot: dir, projectDir: dir, folder: flags.assetsDir || setup.context?.config?.assets_dir });
501
+ if (!assets.ok) {
502
+ log(`${assets.reason} Nothing more was charged.`);
503
+ return EXIT.ENVIRONMENT;
504
+ }
505
+ if (assets.inventory.counts.files > 0) log(`Your art and audio in ${assets.inventory.folder}: ${countsLine(assets.inventory.counts)}.`);
506
+
507
+ // A replay of the register, for the run tokens: the same key answers the same job.
508
+ const registered = await registerPlan(api, journal, deps);
509
+ const unit = registered.runs.find(entry => entry.module === 'prototype');
510
+ const gate = registered.runs.find(entry => entry.prototype_gate);
511
+ const design = plan.design.spec;
512
+
513
+ if (!plan.scaffolded_at) {
514
+ const scaffolded = await scaffold({ api, gameId, jobId, unit, dir, journal, design, flags, injected, log });
515
+ if (!scaffolded) {
516
+ const closed = await stopPlan(api, gameId, jobId, log);
517
+ log(moneyLine(closed));
518
+ return EXIT.FAILED;
519
+ }
520
+ } else {
521
+ log('The scaffold is already in place.');
522
+ if (await hasCommit(dir) && await currentBranch(dir) !== 'main') {
523
+ await commitAll(dir, 'Work left from an interrupted run, not validated');
524
+ await checkoutMain(dir);
525
+ }
526
+ }
527
+ if (signals.stopped) return closeForStop({ api, gameId, jobId, log });
528
+
529
+ // The loop. Each milestone is one feature job, and the next starts only when the
530
+ // last one validated by the server's own answer.
531
+ let refreshed = (await api.labs.prototypes.get(gameId, jobId)).prototype;
532
+ const declared = refreshed.milestones.filter(row => row.declared);
533
+ for (const row of declared) {
534
+ if (row.status === 'validated') {
535
+ log(`Milestone ${row.index + 1}, ${row.title}: already validated.`);
536
+ continue;
537
+ }
538
+ log(`Milestone ${row.index + 1} of ${declared.length}: ${row.title}.`);
539
+ const branch = `ravensight/milestone-${row.index + 1}-${slugOf(row.title).slice(0, 30)}-${randomBytes(2).toString('hex')}`;
540
+ const code = await (injected.buildFeature || buildFeature)(row.spec_id, {
541
+ ...flags, yes: true, game: gameId, repoRoot: dir, personas: options.personas, branch, godotProject: '.', json: false,
542
+ prototype: { job_id: jobId, milestone: row.index },
543
+ shouldStop: () => signals.stopped
544
+ }, { ...options, yes: true }, setup, injected, deps, log);
545
+
546
+ refreshed = (await api.labs.prototypes.get(gameId, jobId)).prototype;
547
+ const after = refreshed.milestones[row.index];
548
+ const onBranch = await currentBranch(dir) === branch;
549
+ if (after.status === 'validated') {
550
+ if (onBranch) {
551
+ await commitAll(dir, `Milestone ${row.index + 1}: ${row.title}`);
552
+ await landOnMain(dir, branch);
553
+ }
554
+ log(`Milestone ${row.index + 1} validated, and it is on main.`);
555
+ } else {
556
+ if (onBranch) {
557
+ await commitAll(dir, `Milestone ${row.index + 1} attempt, not validated: ${row.title}`);
558
+ await checkoutMain(dir);
559
+ }
560
+ if (signals.stopped) return closeForStop({ api, gameId, jobId, log });
561
+ const latest = after.attempts.at(-1);
562
+ log(`Milestone ${row.index + 1} did not validate${latest ? ` (${latest.outcome}, ${latest.verification.status.replace('_', ' ')})` : ''}, so the next one was not bought.${onBranch ? ` The attempt is on ${branch}.` : ''}`);
563
+ log(`Run "create ${jobId}" to try it again, which uses another declared milestone if this one kept its charge, or "create ${jobId} --stop" to close the plan. ${moneyLine(refreshed)}`);
564
+ return EXIT.FAILED;
565
+ }
566
+ if (signals.stopped) return closeForStop({ api, gameId, jobId, log });
567
+ }
568
+
569
+ const past = refreshed.milestones.filter(row => !row.declared);
570
+ if (past.length > 0) log(`Stopped at the declared count of ${declared.length}. ${past.length} more in the design ${past.length === 1 ? 'was' : 'were'} not bought: ${past.map(row => row.title).join(', ')}.`);
571
+ else log(`All ${declared.length} declared milestone${declared.length === 1 ? '' : 's'} validated.`);
572
+
573
+ // A persona plays the finished game. The server judges the run on its own; the
574
+ // number that says the core loop was completed is the game's own.
575
+ const played = await playableCheck({ api, apiUrl, gameId, jobId, gate, dir, design, options, journal, setup, flags, injected, deps, log });
576
+ if (played === EXIT.CANCELED || signals.stopped) return closeForStop({ api, gameId, jobId, log });
577
+
578
+ let finished = null;
579
+ try {
580
+ finished = await api.jobs.finish(gameId, jobId, { reason: '' });
581
+ } catch (error) {
582
+ log(`The plan could not be settled now; the sweep will settle it: ${String(error && error.message)}`);
583
+ }
584
+ const closed = (await api.labs.prototypes.get(gameId, jobId)).prototype;
585
+ const playable = closed.playable;
586
+ if (playable && playable.passed) log(`Playable: a persona completed the core loop ${playable.core_loops_completed} time${playable.core_loops_completed === 1 ? '' : 's'}, with no blocker or major finding. A persona is a model playing the game, not a player. Play it yourself before you ship it.`);
587
+ else log(`Not playable yet: ${playable ? playable.reason : 'the check did not finish'}.`);
588
+ log(moneyLine(closed));
589
+ log('Nothing was pushed. Every placeholder is yours to replace with your own art and audio.');
590
+ if (flags.json) (deps.ui && deps.ui.json ? deps.ui.json : answer => console.log(JSON.stringify(answer)))({ job_id: jobId, game_id: gameId, playable, money: closed.money, settled: Boolean(finished) });
591
+ return playable && playable.passed ? EXIT.OK : EXIT.FAILED;
592
+ }
593
+
594
+ async function closeForStop({ api, gameId, jobId, log }) {
595
+ const closed = await stopPlan(api, gameId, jobId, log);
596
+ log(`Stopped. What finished stays yours, and what never started comes back. ${moneyLine(closed)}`);
597
+ return EXIT.CANCELED;
598
+ }
599
+
600
+ /**
601
+ * Write the template in, prove it in real Godot, and report names and counts.
602
+ *
603
+ * @returns {Promise<boolean>} whether the scaffold was accepted
604
+ */
605
+ async function scaffold({ api, gameId, jobId, unit, dir, journal, design, flags, injected, log }) {
606
+ const fail = async message => {
607
+ log(`The scaffold did not boot: ${message}. The plan closes, and the charge comes back within this account's refund limit.`);
608
+ await closeUnit(api, gameId, jobId, unit.run_id, 'scaffold_failed', log);
609
+ return false;
610
+ };
611
+ const moveUnit = async state => api.runs.transition(gameId, jobId, unit.run_id, state, { reason: '' });
612
+ // The run waited on a person, so the sweep may have read it as interrupted.
613
+ const current = (await api.runs.list(gameId, jobId)).runs.find(run => run.run_id === unit.run_id);
614
+ if (current && current.state === 'interrupted') await moveUnit('playing');
615
+ else if (current && current.state === 'queued') { await moveUnit('launching'); await moveUnit('playing'); }
616
+
617
+ let written;
618
+ try {
619
+ written = await writeScaffold({ dir, name: flags.name || design.title || journal.title, design, declared: journal.milestones });
620
+ } catch (error) {
621
+ return fail(String(error && error.message));
622
+ }
623
+ log(`Scaffold written from ${written.manifest.name} ${written.manifest.version}, with the Ravensight SDK ${written.manifest.sdk.version} from its public release. Checking that it imports and starts in Godot.`);
624
+
625
+ const makeDriver = injected.makeDriver || (driverOptions => new GodotDriver(driverOptions));
626
+ const driver = makeDriver({ projectDir: dir, godotPath: flags.godotPath, headless: true, lockstep: true, seed: 1, recordTrace: false });
627
+ let engine = '';
628
+ let hooked = false;
629
+ try {
630
+ await driver.launch({});
631
+ const states = (driver.lastDigest && driver.lastDigest.game_state) || {};
632
+ hooked = Object.values(states).some(state => state && Object.prototype.hasOwnProperty.call(state, 'core_loops_completed'));
633
+ engine = String((driver.ping && driver.ping.godot_version) || '').slice(0, 80);
634
+ } catch (error) {
635
+ return fail(String(error && error.message).slice(0, 400));
636
+ } finally {
637
+ await driver.stop({ reason: 'preflight' }).catch(() => {});
638
+ }
639
+ if (!hooked) return fail('the game started, and its state node did not answer');
640
+ // Starting it imported any art and audio, and those settings are the project's. A
641
+ // milestone only starts from a clean tree.
642
+ const imported = await commitAll(dir, 'Import settings for the art and audio in this project');
643
+
644
+ await moveUnit('reporting');
645
+ await moveUnit('uploading');
646
+ await api.runs.complete(gameId, jobId, unit.run_id, {
647
+ state: 'succeeded',
648
+ scaffold: {
649
+ schema_version: '1.0',
650
+ template: written.manifest.name,
651
+ template_version: written.manifest.version,
652
+ sdk_version: written.manifest.sdk.version,
653
+ files: written.files,
654
+ commit: imported || written.commit,
655
+ boot: { imported: true, started: true, state_hook: true, engine }
656
+ }
657
+ });
658
+ log(`The scaffold boots. It is on main at ${(imported || written.commit).slice(0, 8)}.`);
659
+ return true;
660
+ }
661
+
662
+ /**
663
+ * The newest `core_loops_completed` the game's own hook reported during a run, read
664
+ * from the run's trace. Zero when the trace has none: this can only ever say a game
665
+ * did less than a persona claimed.
666
+ *
667
+ * @param {string} text the trace
668
+ * @returns {number}
669
+ */
670
+ export function coreLoopsInTrace(text) {
671
+ let trace;
672
+ try {
673
+ trace = parseTrace(text);
674
+ } catch {
675
+ return 0;
676
+ }
677
+ let most = 0;
678
+ for (const step of trace.steps) {
679
+ const states = step.digest && step.digest.game_state ? step.digest.game_state : (step.state && step.state.game_state);
680
+ if (!states) continue;
681
+ for (const state of Object.values(states)) {
682
+ const value = state && state.core_loops_completed;
683
+ if (Number.isInteger(value) && value > most) most = value;
684
+ }
685
+ }
686
+ return most;
687
+ }
688
+
689
+ async function playableCheck({ api, apiUrl, gameId, jobId, gate, dir, design, options, journal, setup, flags, injected, deps, log }) {
690
+ const current = (await api.runs.list(gameId, jobId)).runs.find(run => run.run_id === gate.run_id);
691
+ if (current && ['succeeded', 'failed', 'skipped', 'canceled', 'budget_exceeded'].includes(current.state)) {
692
+ log('The playable check has already run.');
693
+ return EXIT.OK;
694
+ }
695
+ log(`A persona plays the finished game: ${design.core_loop.done_when}`);
696
+ const pack = await deps.packs.get({ api, gameId, modules: ['persona_playtest'] });
697
+ const personas = options.personas.map(slug => (pack.personas || []).find(persona => persona.slug === slug)).filter(Boolean);
698
+ const brief = await deps.brief.get({ api, gameId });
699
+ const task = { title: 'Complete the core loop', steps: design.core_loop.player_steps, expected: design.core_loop.done_when };
700
+ let localBrief = {};
701
+ try { localBrief = await loadLocalBrief({ repoRoot: dir }); } catch { localBrief = {}; }
702
+ const entry = {
703
+ ...gate,
704
+ target: dir,
705
+ brief: scenarioBrief(brief, task, { title: design.title, summary: design.pitch }, await inputControls(dir)),
706
+ seed: seedFrom(`${jobId}:playable`),
707
+ trace_header: { side: 'head', pair: 0 }
708
+ };
709
+ const state = stateFile.newState({
710
+ jobId, gameId, idempotencyKey: journal.idempotency_key, cliVersion: setup.cliVersion, packVersion: pack.pack_version,
711
+ briefVersion: brief ? brief.version ?? null : null, modules: [CAPABILITY], driver: 'godot_driver', target: '.', priceCents: journal.job_cents,
712
+ options: { labs: CAPABILITY, uploadTrace: true }
713
+ });
714
+ state.runs[gate.run_id] = stateFile.newRunEntry({ runId: gate.run_id, module: gate.module, persona: gate.persona });
715
+ await mkdir(jobDir(dir, jobId), { recursive: true });
716
+ await stateFile.save(dir, state);
717
+
718
+ const code = await (injected.playJob || playJob)({
719
+ deps, api, apiUrl, gameId, jobId, repoRoot: dir, runs: [entry], pack, personas,
720
+ driverName: 'godot_driver', target: dir, options, journal: state, config: setup.context.config, brief, localBrief, injected, log, finish: false
721
+ });
722
+ if (code === EXIT.CANCELED) return code;
723
+
724
+ const text = await readFile(path.join(runDirFor(dir, jobId, gate.run_id), TRACE_FILE), 'utf8').catch(() => '');
725
+ const loops = coreLoopsInTrace(text);
726
+ try {
727
+ await api.labs.prototypes.playable(gameId, jobId, { run_id: gate.run_id, core_loops_completed: loops });
728
+ } catch (error) {
729
+ log(`The playable check could not be recorded: ${String(error && error.message)}`);
730
+ }
731
+ return code;
732
+ }
733
+
734
+ export default createCommand;