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,260 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { checkResultText, RUN_CHECK_TOOL } from '../run/checks.js';
5
+ import { ASSET_CHECK_NAMES, createAssetChecks, scanUsage } from './integration.js';
6
+ import { buildInventory, countsLine, DEFAULT_ASSETS_DIR, describeFile, isMediaPath, manifestText, warningKinds } from './inventory.js';
7
+
8
+ /**
9
+ * Bring your own assets: the one seam the feature and create loops call.
10
+ *
11
+ * const assets = await prepareAssets({ repoRoot, projectDir, folder, grid });
12
+ * if (!assets.ok) refuse with assets.reason, before any charge
13
+ *
14
+ * plan prompt: ...assets.planLines
15
+ * apply prompt: ...assets.instructions
16
+ * repo tools: assets.guardRepo(createRepoTools(...)) plan and apply
17
+ * after checks: const bound = assets.bindChecks({ checks, changedFiles, ... })
18
+ * apply tools: bound.withChecks(withRunCheck(guardedRepo, checks))
19
+ * gate: const gate = await bound.gate(await runGateChecks(checks, log), log)
20
+ * a failure joins gate.failed; gate.assetsBlock blocks the upload
21
+ * placeholders: assets.placeholders(plan, spec)
22
+ * report: const summary = await assets.report({ runDir, changed, placeholders, log })
23
+ * summary.reportLines go in the uploaded report
24
+ *
25
+ * The studio's files stay where they are. The server stores none of them, there is
26
+ * no price of its own, and nothing is generated. The manifest reaches the model
27
+ * through the same proxy every read does; the report that uploads carries counts
28
+ * and kinds of warning only, and the file-by-file list stays in the run directory.
29
+ */
30
+
31
+ /** The path the model reads the manifest at. It is answered from memory: no file is written there. */
32
+ export const MANIFEST_PATH = '.ravensight/assets.json';
33
+
34
+ /** Written beside a run. Neither is on the upload allowlist, so neither can leave. */
35
+ export const LOCAL_REPORT_FILE = 'assets-report.md';
36
+ export const LOCAL_MANIFEST_FILE = 'assets.json';
37
+
38
+ const REFUSED_CONTENT = file => `Refused: ${file} is an art or audio file. Its content is never read or sent. Its measurements are in ${MANIFEST_PATH}.`;
39
+ const REFUSED_WRITE = file => `Refused: ${file} is the studio's art or audio. You cannot make or change those: use what ${MANIFEST_PATH} lists, or a placeholder primitive named in placeholders.`;
40
+
41
+ function refusal(id, text) {
42
+ return { result: { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], is_error: true } };
43
+ }
44
+
45
+ function inFolder(file, folder) {
46
+ // Case blind: on macOS and Windows `ART/hero.tres` is `art/hero.tres`.
47
+ const normal = path.posix.normalize(String(file).split(path.sep).join('/')).replace(/^\.\//, '').toLowerCase();
48
+ const declared = folder ? folder.toLowerCase() : folder;
49
+ return declared && (normal === declared || normal.startsWith(`${declared}/`));
50
+ }
51
+
52
+ /**
53
+ * Repository tools that know about assets. The manifest can be read, art and audio
54
+ * cannot be read or quoted, and nothing can be written over them or into the
55
+ * declared folder, except the `.import` settings beside a file.
56
+ *
57
+ * @param {Object} repo a `createRepoTools` surface
58
+ * @param {{manifest: string, folder: string, prefix?: string}} input `prefix` is the project's path inside the repository
59
+ * @returns {Object}
60
+ */
61
+ export function guardRepoTools(repo, { manifest, folder, prefix = '' }) {
62
+ const declared = prefix ? path.posix.join(prefix, folder) : folder;
63
+ return {
64
+ tools: repo.tools,
65
+ get written() {
66
+ return repo.written;
67
+ },
68
+ async execute(toolUse) {
69
+ const input = toolUse.input || {};
70
+ const target = String(input.path || '');
71
+ if (toolUse.name === 'read_file') {
72
+ if (path.posix.normalize(target) === MANIFEST_PATH) return manifestAnswer(toolUse, manifest, input);
73
+ if (isMediaPath(target)) return refusal(toolUse.id, REFUSED_CONTENT(target));
74
+ }
75
+ if (toolUse.name === 'write_file' || toolUse.name === 'edit_file') {
76
+ if (isMediaPath(target) || (inFolder(target, declared) && !target.endsWith('.import'))) return refusal(toolUse.id, REFUSED_WRITE(target));
77
+ }
78
+ const answer = await repo.execute(toolUse);
79
+ if (toolUse.name === 'grep') return withoutMediaLines(answer);
80
+ return answer;
81
+ }
82
+ };
83
+ }
84
+
85
+ function manifestAnswer(toolUse, manifest, input) {
86
+ const lines = manifest.split('\n');
87
+ const start = Math.max(1, Number(input.start_line) || 1);
88
+ const limit = Number(input.limit) || lines.length;
89
+ const text = lines.slice(start - 1, start - 1 + limit).join('\n');
90
+ return { result: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text }] } };
91
+ }
92
+
93
+ /** A grep result quotes the line it found, and a line of a PNG is file content. */
94
+ function withoutMediaLines(answer) {
95
+ const block = answer && answer.result && answer.result.content && answer.result.content[0];
96
+ if (!block || typeof block.text !== 'string') return answer;
97
+ const kept = block.text.split('\n').filter(line => {
98
+ const match = /^(.+?):\d+: /.exec(line);
99
+ return !(match && isMediaPath(match[1]));
100
+ });
101
+ block.text = kept.join('\n') || '(no matches)';
102
+ return answer;
103
+ }
104
+
105
+ /** The run_check tool, with the asset checks in its list. */
106
+ function assetRunCheckTool(tool) {
107
+ return {
108
+ ...tool,
109
+ description: `${tool.description} asset_references says whether every art or audio path you named exists; asset_import imports the project in Godot and reads what it made for each of them; pixel_art_settings says whether pixel art you named has the nearest filter and clean import settings.`,
110
+ input_schema: {
111
+ ...tool.input_schema,
112
+ properties: { check: { type: 'string', enum: [...tool.input_schema.properties.check.enum, ...ASSET_CHECK_NAMES] } }
113
+ }
114
+ };
115
+ }
116
+
117
+ /**
118
+ * The tool surface from `withRunCheck`, with the asset checks answerable too.
119
+ *
120
+ * @param {Object} surface
121
+ * @param {{run: Function}} assetChecks
122
+ * @param {{maxRuns?: number}} [options]
123
+ */
124
+ export function withAssetChecks(surface, assetChecks, { maxRuns = 6 } = {}) {
125
+ let runs = 0;
126
+ return {
127
+ tools: surface.tools.map(tool => (tool.name === RUN_CHECK_TOOL.name ? assetRunCheckTool(tool) : tool)),
128
+ get written() {
129
+ return surface.written;
130
+ },
131
+ async execute(toolUse) {
132
+ const name = toolUse.input && toolUse.input.check;
133
+ if (toolUse.name !== RUN_CHECK_TOOL.name || !ASSET_CHECK_NAMES.includes(name)) return surface.execute(toolUse);
134
+ const answer = (text, isError = false) => ({ result: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) } });
135
+ if (runs >= maxRuns) return answer(`refused: ${maxRuns} asset checks have run this turn set, which is the limit`, true);
136
+ runs += 1;
137
+ const result = await assetChecks.run(name);
138
+ return answer(checkResultText(result), result.status === 'failed');
139
+ }
140
+ };
141
+ }
142
+
143
+ function planLinesFor(inventory) {
144
+ return [
145
+ '',
146
+ `<assets>The studio's own art and audio are listed in ${MANIFEST_PATH} (${countsLine(inventory.counts)}). Read that file with read_file before you plan any art or audio. List in "placeholders" only what the specification needs and that file does not cover; your list is final and replaces the specification's expected placeholders.</assets>`
147
+ ];
148
+ }
149
+
150
+ function instructionsFor(dimension) {
151
+ const primitives = dimension === '3d' ? 'a MeshInstance3D with a BoxMesh, a SphereMesh or a CapsuleMesh, or a Label' : 'a ColorRect, a Polygon2D or a Label';
152
+ return [
153
+ `Art and audio belong to the studio. Read ${MANIFEST_PATH} with read_file: it lists every file you may use, with its res_path and what was measured about it. Use those files by their res_path, and never invent a path, because a file not listed there does not exist. Do not try to read the files themselves; you cannot.`,
154
+ 'Believe the measurements over a file\'s name or any size the specification assumes: size nodes from the width and height listed, and use the listed cell size for a sheet. A row\'s warnings say what will go wrong with that file, so work around them or say so in one line.',
155
+ `If something the feature needs is missing, build it with a placeholder primitive such as ${primitives}, and name it plainly in your final message and in the plan's placeholders. Never draw, write or fetch art or audio, and never suggest a tool or service for them.`,
156
+ 'For a file with pixel_art true, set texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST on the node that draws it, unless the manifest says the project default is already nearest. Before you finish, call run_check with asset_references, then asset_import.'
157
+ ];
158
+ }
159
+
160
+ /**
161
+ * The uploaded section of the report: counts, and kinds of warning with how many.
162
+ * It names no file, on purpose. Test this before changing it.
163
+ */
164
+ export function reportLines({ inventory, usage, placeholderCount, localPath }) {
165
+ const lines = ['### Assets', ''];
166
+ if (inventory.counts.files === 0) {
167
+ lines.push(`The project has no files in its declared asset folder, so anything the feature needed is a placeholder (${placeholderCount}).`, '');
168
+ return lines;
169
+ }
170
+ lines.push(
171
+ `The declared asset folder holds ${countsLine(inventory.counts)}. This change wired in ${usage.wired.length}, the project now uses ${usage.used.length} and ${usage.unused.length} are unused. ${placeholderCount} placeholder(s) remain, named below.`,
172
+ '',
173
+ `Which files is on your machine only, in \`${localPath}\`. No file name from that list and no file content is uploaded.`,
174
+ ''
175
+ );
176
+ const kinds = warningKinds(inventory);
177
+ if (kinds.length > 0) {
178
+ lines.push('Warnings from the inventory:', '', ...kinds.map(item => `- ${item.count} x ${item.summary}`), '');
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ function localReport({ inventory, usage, placeholders }) {
184
+ const list = (title, items, empty) => ['', `## ${title}`, '', ...(items.length ? items.map(item => `- ${item}`) : [empty])];
185
+ const warned = inventory.files.filter(file => file.warnings.length > 0);
186
+ const wired = new Set(usage.wired);
187
+ return [
188
+ '# Assets in this run',
189
+ '',
190
+ `Read from \`${inventory.folder}\`: ${countsLine(inventory.counts)}. This file stays on this machine.`,
191
+ ...list('Used', usage.used.map(name => `\`${name}\`${wired.has(name) ? ' (wired in by this change)' : ''}${usage.built_in_code.includes(name) ? ' (a path built in code, so a likely match)' : ''}`), 'None.'),
192
+ ...list('Unused', usage.unused.map(name => `\`${name}\``), 'None.'),
193
+ ...list('Placeholders still to supply', placeholders, 'None.'),
194
+ ...list('Warnings', [
195
+ ...inventory.warnings.map(item => item.text),
196
+ ...warned.flatMap(file => file.warnings.map(item => `\`${file.path}\`: ${item.text}`))
197
+ ], 'None.'),
198
+ ''
199
+ ].join('\n');
200
+ }
201
+
202
+ /**
203
+ * Inventory the declared folder and hand back everything a loop needs.
204
+ *
205
+ * A folder that was named and cannot be used is a refusal. The default folder being
206
+ * absent is not: that is a project with no assets yet, and the loop uses placeholders.
207
+ *
208
+ * @param {Object} input
209
+ * @param {string} input.repoRoot
210
+ * @param {string} input.projectDir
211
+ * @param {string} [input.folder] default `assets`
212
+ * @param {number|null} [input.grid] the grid the studio expects, for a warning
213
+ * @returns {Promise<Object>}
214
+ */
215
+ export async function prepareAssets({ repoRoot, projectDir, folder, grid = null, dimension = '2d' }) {
216
+ const named = Boolean(folder);
217
+ const inventory = await buildInventory({ projectDir, folder: folder || DEFAULT_ASSETS_DIR, grid });
218
+ if (!inventory.ok) return { ok: false, reason: inventory.reason };
219
+ if (named && !inventory.exists) return { ok: false, reason: `There is no folder ${folder} in the project.` };
220
+
221
+ const prefix = path.relative(repoRoot, projectDir).split(path.sep).join('/');
222
+ const manifest = manifestText(inventory, prefix);
223
+ return {
224
+ ok: true,
225
+ inventory,
226
+ manifest,
227
+ planLines: planLinesFor(inventory),
228
+ instructions: instructionsFor(dimension),
229
+ guardRepo: repo => guardRepoTools(repo, { manifest, folder: inventory.folder, prefix }),
230
+ bindChecks: options => {
231
+ const bound = createAssetChecks({ projectDir, inventory, ...options });
232
+ return { ...bound, withChecks: surface => withAssetChecks(surface, bound) };
233
+ },
234
+ /** The plan read the manifest, so its list is the one that stands when it gave one. */
235
+ placeholders: (plan, spec) => {
236
+ const planned = plan && Array.isArray(plan.placeholders) ? plan.placeholders : null;
237
+ const expected = (spec && spec.placeholders) || [];
238
+ return [...new Set(planned || expected)].slice(0, 40);
239
+ },
240
+ /**
241
+ * Write the local files, print the names on this terminal, and answer the lines
242
+ * for the uploaded report.
243
+ */
244
+ report: async ({ runDir, changed = [], placeholders = [], log = () => {} }) => {
245
+ const usage = await scanUsage({ projectDir, inventory, changed });
246
+ await mkdir(runDir, { recursive: true });
247
+ await writeFile(path.join(runDir, LOCAL_MANIFEST_FILE), manifest, 'utf8');
248
+ await writeFile(path.join(runDir, LOCAL_REPORT_FILE), localReport({ inventory, usage, placeholders }), 'utf8');
249
+ const localPath = path.relative(repoRoot, path.join(runDir, LOCAL_REPORT_FILE)).split(path.sep).join('/');
250
+ if (inventory.counts.files > 0) log(`assets: ${usage.wired.length} wired in by this change, ${usage.used.length} used, ${usage.unused.length} unused. ${localPath}`);
251
+ for (const name of usage.wired) log(` wired in: ${name}`);
252
+ return { usage, localPath, reportLines: reportLines({ inventory, usage, placeholderCount: placeholders.length, localPath }) };
253
+ }
254
+ };
255
+ }
256
+
257
+ export { buildInventory, describeFile, manifestText, warningKinds } from './inventory.js';
258
+ export { ASSET_CHECK_NAMES, createAssetChecks, scanUsage } from './integration.js';
259
+
260
+ export default prepareAssets;