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
@@ -6,7 +6,7 @@ import { callStepWithRetry, STEPS } from './model.js';
6
6
  import { JOB_FILES } from './paths.js';
7
7
  import { parseJsonObject } from './json.js';
8
8
  import { createRepoTools, PROFILE_TOOLS, READ_ONLY_TOOLS } from './repoTools.js';
9
- import { assertNoSecrets } from './secretScan.js';
9
+ import { assertNoSecrets, redactSecrets } from './secretScan.js';
10
10
 
11
11
  /**
12
12
  * The `game_profile` module: read the developer's repository and answer what
@@ -38,6 +38,12 @@ export const MAX_TRIAGE_TURNS = 40;
38
38
  export const TRIAGE_MAX_TOKENS = 1024;
39
39
  export const SYNTHESIS_MAX_TOKENS = 4096;
40
40
 
41
+ /** Files the synthesis step may still read before it has to answer. */
42
+ export const MAX_SYNTHESIS_TOOL_TURNS = 3;
43
+
44
+ /** Where a failed synthesis leaves its raw answers, in the job directory. */
45
+ export const PROFILE_FAILURE_FILE = 'capability-report.failed.txt';
46
+
41
47
  /**
42
48
  * @param {Array<{name: string, content: Object}>} schemas
43
49
  */
@@ -159,7 +165,10 @@ export async function runGameProfile({
159
165
 
160
166
  let report = null;
161
167
  let lint = [];
162
- for (let attempt = 1; attempt <= 2; attempt += 1) {
168
+ // Every synthesis answer, kept so a failed profile leaves something to read.
169
+ const rawAnswers = [];
170
+ let toolTurns = 0;
171
+ for (let attempt = 1; attempt <= 2;) {
163
172
  const answer = await callStepWithRetry({
164
173
  client,
165
174
  jobId,
@@ -171,12 +180,36 @@ export async function runGameProfile({
171
180
  maxTokens: SYNTHESIS_MAX_TOKENS
172
181
  });
173
182
  if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
183
+ rawAnswers.push({ attempt, stopReason: answer.stopReason || null, text: answer.text || '', tools: answer.toolUses.map(toolUse => toolUse.name) });
184
+
185
+ // The step is given read_file, so it may use it. An answer that reads a
186
+ // file instead of writing the report used to be judged as a report with
187
+ // no JSON in it, twice. Read it, hand the file back, and ask again, a
188
+ // bounded number of times, without spending the repair.
189
+ if (answer.toolUses.length > 0 && toolTurns < MAX_SYNTHESIS_TOOL_TURNS && !parseJsonObject(answer.text)) {
190
+ toolTurns += 1;
191
+ messages.push({
192
+ role: 'assistant',
193
+ content: [
194
+ ...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
195
+ ...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
196
+ ]
197
+ });
198
+ const results = [];
199
+ for (const toolUse of answer.toolUses) results.push((await repo.execute(toolUse)).result);
200
+ messages.push({
201
+ role: 'user',
202
+ content: [...results, { type: 'text', text: toolTurns >= MAX_SYNTHESIS_TOOL_TURNS ? 'That was the last file. Answer now with the capability report JSON object only.' : 'Answer with the capability report JSON object only when you have what you need.' }]
203
+ });
204
+ continue;
205
+ }
174
206
 
175
207
  const parsed = parseJsonObject(answer.text);
176
208
  const candidate = parsed ? { ...parsed, schema_version: parsed.schema_version || '1.0', job_id: jobId } : null;
209
+ const cutOff = answer.stopReason === 'max_tokens';
177
210
  const validation = candidate
178
211
  ? validator.validate(candidate)
179
- : { ok: false, details: [{ instancePath: '/', message: 'the answer was not a JSON object' }] };
212
+ : { ok: false, details: [{ instancePath: '/', message: cutOff ? `the answer was cut off at the output limit (${SYNTHESIS_MAX_TOKENS} tokens) before the JSON object closed` : 'the answer was not a JSON object' }] };
180
213
 
181
214
  if (validation.ok) {
182
215
  report = candidate;
@@ -184,13 +217,20 @@ export async function runGameProfile({
184
217
  break;
185
218
  }
186
219
  lint = validation.details.map(detail => `${detail.instancePath}: ${detail.message}`);
187
- if (attempt === 2) throw new Error(`the capability report did not validate after one repair: ${lint.join('; ')}`);
220
+ if (attempt === 2) {
221
+ const error = new Error(`the capability report did not validate after one repair: ${lint.join('; ')}`);
222
+ error.rawAnswers = rawAnswers;
223
+ throw error;
224
+ }
188
225
 
189
- messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text.slice(0, 2000) }] });
226
+ messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text.slice(0, 2000) || '(no text)' }] });
190
227
  messages.push({
191
228
  role: 'user',
192
- content: [{ type: 'text', text: `That did not match capability-report.schema.json. Fix exactly these and answer with the JSON only:\n${lint.join('\n')}` }]
229
+ content: [{ type: 'text', text: cutOff
230
+ ? `That answer was cut off at the output limit. Answer again with the JSON only, shorter: keep every required field and cut reasons and warnings to one sentence each.\n${lint.join('\n')}`
231
+ : `That did not match capability-report.schema.json. Fix exactly these and answer with the JSON only:\n${lint.join('\n')}` }]
193
232
  });
233
+ attempt += 1;
194
234
  }
195
235
 
196
236
  return { report, lint, turns };
@@ -209,6 +249,27 @@ export async function writeCapabilityReport(jobDirectory, report) {
209
249
  return target;
210
250
  }
211
251
 
252
+ /**
253
+ * Write a failed synthesis's raw answers next to the job, secrets redacted,
254
+ * so a profile that was charged and refunded still says what went wrong.
255
+ *
256
+ * @param {string} jobDirectory
257
+ * @param {Array<{attempt: number, stopReason: string|null, text: string}>} rawAnswers
258
+ * @param {string} reason
259
+ * @returns {Promise<string>} the file written
260
+ */
261
+ export async function writeProfileFailure(jobDirectory, rawAnswers, reason) {
262
+ const lines = [`profile failed: ${reason}`, ''];
263
+ (rawAnswers || []).forEach((answer, index) => {
264
+ lines.push(`--- synthesis answer ${index + 1} (attempt ${answer.attempt}, stop reason ${answer.stopReason || 'unknown'}${answer.tools && answer.tools.length > 0 ? `, tools ${answer.tools.join(', ')}` : ''}) ---`);
265
+ lines.push(answer.text || '(no text)');
266
+ lines.push('');
267
+ });
268
+ const target = path.join(jobDirectory, PROFILE_FAILURE_FILE);
269
+ await writeFile(target, redactSecrets(lines.join('\n')), { encoding: 'utf8', mode: 0o600 });
270
+ return target;
271
+ }
272
+
212
273
  export { parseJsonObject };
213
274
 
214
- export default { runGameProfile, writeCapabilityReport, createCapabilityValidator, parseJsonObject };
275
+ export default { runGameProfile, writeCapabilityReport, writeProfileFailure, createCapabilityValidator, parseJsonObject };
@@ -0,0 +1,236 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ import { DriverRpcError } from './drivers/godot-rpc.js';
8
+ import { allowlistedEnv, GodotDriver } from './drivers/godot.js';
9
+ import { detectGodotBinary, importProjectCopy } from './drivers/godot-project.js';
10
+ import { parseTrace } from './trace.js';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ /**
15
+ * Plays a recorded trace against a build, with no model anywhere in the path,
16
+ * and says where it first stopped matching.
17
+ *
18
+ * The answer has the same shape as `simulation-replay`, so one reader handles
19
+ * both: `matched`, `matched_prefix`, `fully_replayed`, `failure_reproduced`, and
20
+ * `first_divergence` as `{ step, expected, actual }`, `{ step, reason }` or
21
+ * `{ step, actual_error }`.
22
+ *
23
+ * A match says this seed and these inputs produced the same recorded state. A
24
+ * divergence on a changed branch is only evidence about the change if the trace
25
+ * also matches on the code it was recorded on, which is what `--baseline` checks
26
+ * and what the report says when it was not checked.
27
+ */
28
+
29
+ /** The compact state a divergence names, in the simulation format's spelling. */
30
+ function stateRecord(digest) {
31
+ if (!digest) return null;
32
+ return {
33
+ done: false,
34
+ state_hash: digest.state_hash,
35
+ scene: digest.scene,
36
+ nodes: digest.nodes,
37
+ ...(digest.game_state ? { game_state: digest.game_state } : {}),
38
+ };
39
+ }
40
+
41
+ function differingKeys(left = {}, right = {}, limit = 20) {
42
+ const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort();
43
+ return keys.filter((key) => left[key] !== right[key]).slice(0, limit);
44
+ }
45
+
46
+ /** Which keys of the game's own state hook differ, with both values. */
47
+ function gameStateDiff(expected, actual) {
48
+ const out = {};
49
+ const hooks = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]);
50
+ for (const hook of hooks) {
51
+ const left = expected?.[hook] ?? {};
52
+ const right = actual?.[hook] ?? {};
53
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
54
+ if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) {
55
+ out[key] = { expected: left[key] ?? null, actual: right[key] ?? null };
56
+ }
57
+ if (Object.keys(out).length >= 20) return out;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function divergence(step, frame, expected, actual) {
64
+ const diff = gameStateDiff(expected?.game_state, actual?.game_state);
65
+ return {
66
+ step,
67
+ frame,
68
+ expected: stateRecord(expected),
69
+ actual: stateRecord(actual),
70
+ differing_subtrees: differingKeys(expected?.subtrees, actual?.subtrees),
71
+ ...(Object.keys(diff).length ? { game_state_diff: diff } : {}),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {Object} input
77
+ * @param {string} input.text - the trace file's contents
78
+ * @param {string} input.projectDir - the project to replay against
79
+ * @param {string} [input.godotPath]
80
+ * @param {boolean} [input.headless] - default the mode it was recorded in
81
+ * @param {string} [input.runDir] - where godot.log goes
82
+ * @param {AbortSignal} [input.signal]
83
+ * @param {(options: Object) => Object} [input.makeDriver] - test seam
84
+ * @returns {Promise<Object>} the simulation replay's result shape, plus counts
85
+ */
86
+ export async function replayTrace({ text, projectDir, godotPath, headless, runDir, signal, env, makeDriver = options => new GodotDriver(options) }) {
87
+ const trace = parseTrace(text);
88
+ const { header, boot, steps } = trace;
89
+ const driver = makeDriver({
90
+ projectDir,
91
+ runDir,
92
+ godotPath,
93
+ headless: headless ?? Boolean(header.headless),
94
+ viewport: header.viewport || undefined,
95
+ lockstep: true,
96
+ seed: header.seed,
97
+ recordTrace: false,
98
+ ...(env ? { env } : {}),
99
+ });
100
+ const result = {
101
+ matched: true,
102
+ matched_prefix: false,
103
+ fully_replayed: false,
104
+ failure_reproduced: null,
105
+ first_divergence: null,
106
+ seed: header.seed,
107
+ steps_total: steps.length,
108
+ steps_compared: 0,
109
+ ticks: 0,
110
+ trace_truncated: trace.truncated,
111
+ stopped: null,
112
+ };
113
+ try {
114
+ await driver.launch({ signal });
115
+ } catch (error) {
116
+ result.matched = false;
117
+ result.first_divergence = { step: 0, frame: 0, actual_error: String(error?.message ?? error).slice(0, 500) };
118
+ result.boot_failed = true;
119
+ return result;
120
+ }
121
+ try {
122
+ if (driver.ticksPerSecond !== header.ticks_per_second) {
123
+ result.matched = false;
124
+ result.first_divergence = { step: 0, frame: 0, reason: `The project runs at ${driver.ticksPerSecond} ticks a second; this trace was recorded at ${header.ticks_per_second}.` };
125
+ return result;
126
+ }
127
+ if (boot && boot.state_hash !== driver.bootDigest?.state_hash) {
128
+ result.matched = false;
129
+ result.first_divergence = divergence('boot', 0, boot, driver.bootDigest);
130
+ return result;
131
+ }
132
+ let compared = 0;
133
+ for (const step of steps) {
134
+ if (signal?.aborted) throw new Error('Replay canceled.');
135
+ if (step.redacted) {
136
+ result.stopped = 'redacted_input';
137
+ break;
138
+ }
139
+ let answer;
140
+ try {
141
+ answer = await driver.step(step.events, step.ticks, { digest: Boolean(step.state) });
142
+ } catch (error) {
143
+ result.matched = false;
144
+ result.first_divergence = error instanceof DriverRpcError
145
+ ? { step: step.i, frame: step.frame, reason: 'Recorded action is no longer legal.', actual_error: error.rpcMessage }
146
+ : { step: step.i, frame: step.frame, actual_error: String(error?.message ?? error).slice(0, 500) };
147
+ break;
148
+ }
149
+ result.ticks = answer.tick;
150
+ if (answer.tick !== step.frame + step.ticks) {
151
+ result.matched = false;
152
+ result.first_divergence = { step: step.i, frame: step.frame, reason: `The game was at tick ${answer.tick} where the trace was at ${step.frame + step.ticks}.` };
153
+ break;
154
+ }
155
+ if (step.state) {
156
+ compared += 1;
157
+ if (step.state.state_hash !== answer.digest?.state_hash) {
158
+ result.matched = false;
159
+ result.first_divergence = divergence(step.i, step.frame, step.state, answer.digest);
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ result.steps_compared = compared;
165
+ result.errors = driver.errorCount;
166
+ // A held key the desktop dropped and the driver put back: this replay may be a
167
+ // tick off for a reason that has nothing to do with the game.
168
+ result.inputs_reasserted = driver.reasserted ?? 0;
169
+ if (result.matched) {
170
+ result.matched_prefix = true;
171
+ result.fully_replayed = !trace.truncated && !result.stopped;
172
+ }
173
+ return result;
174
+ } finally {
175
+ await driver.stop({ reason: 'replay' }).catch(() => {});
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The checkout's commit when its tree is clean, else null.
181
+ *
182
+ * @param {string} repoRoot
183
+ * @returns {Promise<string|null>}
184
+ */
185
+ export async function cleanCommit(repoRoot) {
186
+ try {
187
+ const { stdout: status } = await execFileAsync('git', ['-C', repoRoot, 'status', '--porcelain', '--', '.', ':(exclude).ravensight']);
188
+ if (status.trim()) return null;
189
+ const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
190
+ return stdout.trim();
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Extract one commit to a temp directory without touching the developer's
198
+ * checkout, index or worktree list: `git archive`, so nothing is written to
199
+ * their repository at all. Untracked and ignored files are not in it, which
200
+ * includes `.godot/` (imported here, once) and any ignored key file.
201
+ *
202
+ * @param {{repoRoot: string, ref: string, projectSubdir?: string, godotPath?: string, importProject?: Function, env?: Object}} input
203
+ * @returns {Promise<{sha: string, ref: string, root: string, projectDir: string, imported: Object, cleanup: () => Promise<void>}>}
204
+ */
205
+ export async function checkoutRef({ repoRoot, ref, projectSubdir = '', godotPath, importProject = importProjectCopy, env = process.env }) {
206
+ let sha;
207
+ try {
208
+ ({ stdout: sha } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', '--verify', `${ref}^{commit}`]));
209
+ } catch {
210
+ throw new Error(`There is no commit called "${ref}" in this repository.`);
211
+ }
212
+ sha = sha.trim();
213
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ravensight-ref-'));
214
+ const cleanup = () => fs.rm(root, { recursive: true, force: true }).catch(() => {});
215
+ try {
216
+ await new Promise((resolve, reject) => {
217
+ const archive = spawn('git', ['-C', repoRoot, 'archive', '--format=tar', sha], { stdio: ['ignore', 'pipe', 'ignore'] });
218
+ const tar = spawn('tar', ['-x', '-C', root], { stdio: ['pipe', 'ignore', 'ignore'] });
219
+ archive.stdout.pipe(tar.stdin);
220
+ archive.on('error', reject);
221
+ tar.on('error', reject);
222
+ tar.on('exit', code => (code === 0 ? resolve() : reject(new Error(`Extracting ${ref} failed.`))));
223
+ });
224
+ const projectDir = path.join(root, projectSubdir);
225
+ await fs.access(path.join(projectDir, 'project.godot')).catch(() => {
226
+ throw new Error(`${ref} has no Godot project at ${projectSubdir || 'the repository root'}.`);
227
+ });
228
+ const godot = await detectGodotBinary({ explicitPath: godotPath, env, projectDir });
229
+ // A branch's editor plugins run during this import. They get what a game gets.
230
+ const imported = await importProject({ godotPath: godot.path, projectCopy: projectDir, env: allowlistedEnv(env), logPath: null });
231
+ return { sha, ref, root, projectDir, imported, cleanup };
232
+ } catch (error) {
233
+ await cleanup();
234
+ throw error;
235
+ }
236
+ }