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
package/src/api/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApiError, codeRetryLimit, isRetryable, parseRetryAfter } from './errors.js';
1
+ import { ApiError, codeRetryLimit, isRetryable, nothingMoved, parseRetryAfter } from './errors.js';
2
2
 
3
3
  export const DEFAULT_RETRIES = 4;
4
4
  export const DEFAULT_TIMEOUT_MS = 30000;
@@ -42,12 +42,18 @@ export function buildUrl(apiUrl, path, query) {
42
42
  return url.toString();
43
43
  }
44
44
 
45
+ /** Carries a capture hook's own error past the request loop's network catch. */
46
+ class CaptureFailure {
47
+ constructor(error) { this.error = error; }
48
+ }
49
+
45
50
  /**
46
51
  * Read a response body without throwing for body or JSON decoding errors. A 502 from a load balancer is
47
52
  * HTML, a 204 is empty, and a JSON parse failure on the error path would
48
- * replace a useful status code with a useless SyntaxError.
53
+ * replace a useful status code with a useless SyntaxError. `cut` says the connection
54
+ * went before the body finished, which is not the same as an empty body.
49
55
  * @param {Response} response
50
- * @returns {Promise<{json: unknown, text: string}>}
56
+ * @returns {Promise<{json: unknown, text: string, cut?: Error}>}
51
57
  */
52
58
  async function readBody(response, onResponseRaw) {
53
59
  let text = '';
@@ -57,12 +63,22 @@ async function readBody(response, onResponseRaw) {
57
63
  catch { /* Preserve a read-failure observation, never invent complete bytes. */ }
58
64
  // Opt-in capture runs on every HTTP status BEFORE JSON parsing or an
59
65
  // ApiError. Capture failures propagate and cannot trigger a request retry.
60
- await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
66
+ try {
67
+ await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
68
+ } catch (error) {
69
+ throw new CaptureFailure(error);
70
+ }
71
+ // A capture caller owns an incomplete body: the observation says so, and it
72
+ // must decide from that record (an admission never POSTs again on an unknown
73
+ // outcome), so this is not turned into a retryable network error.
61
74
  if (!bodyComplete) return { json: null, text: '' };
62
75
  text = new TextDecoder().decode(bytes);
63
76
  } else {
64
- try { text = await response.text(); }
65
- catch { return { json: null, text: '' }; }
77
+ try {
78
+ text = await response.text();
79
+ } catch (cause) {
80
+ return { json: null, text: '', cut: cause };
81
+ }
66
82
  }
67
83
  if (!text) return { json: null, text: '' };
68
84
  try {
@@ -95,7 +111,8 @@ function errorFrom(response, parsed, { method, path }) {
95
111
  * Retries cover a network failure, a timeout, 408, 425, 429 and 5xx, plus the
96
112
  * two error codes whose status alone gives the wrong answer (see
97
113
  * `codeRetryLimit`): `charge_in_progress` is retried on its own `Retry-After`
98
- * although it is a 409, and `too_many_active_jobs` is not retried although it
114
+ * although it is a 409, `settle_contended` likewise and on any method because it
115
+ * promises nothing moved, and `too_many_active_jobs` is not retried although it
99
116
  * is a 429. A mutation is only ever retried when the caller says
100
117
  * `idempotent: true`, which in practice means a GET, a heartbeat, or a POST
101
118
  * carrying an `Idempotency-Key` the server uses to replay rather than repeat.
@@ -150,7 +167,16 @@ export async function requestWithRetry(options) {
150
167
  for (let attempt = 1; ; attempt += 1) {
151
168
  const controller = new AbortController();
152
169
  const timer = setTimeout(() => controller.abort(), timeoutMs);
170
+ const unreachable = cause => new ApiError({
171
+ status: 0,
172
+ code: 'network_error',
173
+ message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
174
+ method,
175
+ path,
176
+ cause
177
+ });
153
178
  let response = null;
179
+ let parsed = null;
154
180
  try {
155
181
  response = await fetchImpl(url, {
156
182
  method,
@@ -158,21 +184,21 @@ export async function requestWithRetry(options) {
158
184
  body: bodyText === undefined ? undefined : bodyText,
159
185
  signal: controller.signal
160
186
  });
187
+ // The time limit covers the body too, and a success whose body never finished
188
+ // is no answer: reading it as an empty 200 lost the job id of a job just charged.
189
+ parsed = await readBody(response, onResponseRaw);
190
+ if (parsed.cut) {
191
+ lastError = unreachable(parsed.cut);
192
+ response = null;
193
+ }
161
194
  } catch (cause) {
162
- lastError = new ApiError({
163
- status: 0,
164
- code: 'network_error',
165
- message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
166
- method,
167
- path,
168
- cause
169
- });
195
+ if (cause instanceof CaptureFailure) throw cause.error;
196
+ lastError = unreachable(cause);
170
197
  } finally {
171
198
  clearTimeout(timer);
172
199
  }
173
200
 
174
201
  if (response) {
175
- const parsed = await readBody(response, onResponseRaw);
176
202
  if (response.ok || response.status === 304) {
177
203
  return {
178
204
  status: response.status,
@@ -183,7 +209,7 @@ export async function requestWithRetry(options) {
183
209
  lastError = errorFrom(response, parsed, { method, path });
184
210
  }
185
211
 
186
- if (!retryable) throw lastError;
212
+ if (!retryable && !nothingMoved(lastError)) throw lastError;
187
213
 
188
214
  const limit = codeRetryLimit(lastError);
189
215
  let canRetry;
@@ -0,0 +1,267 @@
1
+ import { inflateSync } from 'node:zlib';
2
+
3
+ /**
4
+ * Plain parsers for the three formats the inventory measures closely: PNG, WAV
5
+ * and Ogg. No dependency, because this runs inside a CLI that ships with few, and
6
+ * none of these needs more than a header and, for PNG, one inflate.
7
+ *
8
+ * Each parser answers what it read and `problem` when the file is damaged, and
9
+ * never throws on hostile bytes: a studio's folder can hold anything.
10
+ */
11
+
12
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
+
14
+ /** Above this the pixels are not decoded: the header still answers the size. */
15
+ export const MAX_DECODED_PIXELS = 64 * 1024 * 1024;
16
+
17
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
18
+ const VALID_DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
19
+
20
+ /**
21
+ * The PNG's chunks up to IEND, without decoding the pixels.
22
+ *
23
+ * @param {Buffer} data
24
+ * @returns {{width: number, height: number, bitDepth: number, colorType: number, interlaced: boolean, palette: Buffer|null, transparency: Buffer|null, idat: Buffer[], problem: string|null}|null} null when it is not a PNG at all
25
+ */
26
+ export function readPngHeader(data) {
27
+ if (data.length < 33 || !data.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
28
+ const out = { width: 0, height: 0, bitDepth: 0, colorType: 0, interlaced: false, palette: null, transparency: null, idat: [], problem: null };
29
+ let at = 8;
30
+ let sawHeader = false;
31
+ let sawEnd = false;
32
+ while (at + 8 <= data.length) {
33
+ const length = data.readUInt32BE(at);
34
+ const type = data.toString('latin1', at + 4, at + 8);
35
+ const start = at + 8;
36
+ if (start + length > data.length) {
37
+ out.problem = `the ${type} chunk runs past the end of the file`;
38
+ break;
39
+ }
40
+ const body = data.subarray(start, start + length);
41
+ if (type === 'IHDR') {
42
+ if (length < 13) return { ...out, problem: 'the image header is too short' };
43
+ out.width = body.readUInt32BE(0);
44
+ out.height = body.readUInt32BE(4);
45
+ out.bitDepth = body[8];
46
+ out.colorType = body[9];
47
+ out.interlaced = body[12] === 1;
48
+ sawHeader = true;
49
+ } else if (type === 'PLTE') out.palette = body;
50
+ else if (type === 'tRNS') out.transparency = body;
51
+ else if (type === 'IDAT') out.idat.push(body);
52
+ else if (type === 'IEND') { sawEnd = true; break; }
53
+ at = start + length + 4;
54
+ }
55
+ if (!sawHeader) return { ...out, problem: out.problem || 'there is no image header' };
56
+ if (!(VALID_DEPTHS[out.colorType] || []).includes(out.bitDepth)) return { ...out, problem: 'the colour type and bit depth are not valid together' };
57
+ if (!sawEnd && !out.problem) out.problem = 'the file ends before the image does';
58
+ return out;
59
+ }
60
+
61
+ /** The Paeth predictor, from the PNG specification. */
62
+ function paeth(a, b, c) {
63
+ const p = a + b - c;
64
+ const pa = Math.abs(p - a);
65
+ const pb = Math.abs(p - b);
66
+ const pc = Math.abs(p - c);
67
+ if (pa <= pb && pa <= pc) return a;
68
+ return pb <= pc ? b : c;
69
+ }
70
+
71
+ /**
72
+ * Decode a non-interlaced PNG to 8-bit RGBA.
73
+ *
74
+ * @param {ReturnType<typeof readPngHeader>} header
75
+ * @returns {{width: number, height: number, rgba: Uint8Array}|{problem: string}|null} null for what is
76
+ * not decoded on purpose: interlaced, or over MAX_DECODED_PIXELS
77
+ */
78
+ export function decodePng(header) {
79
+ const { width, height, bitDepth, colorType } = header;
80
+ if (header.interlaced || width * height > MAX_DECODED_PIXELS || width * height === 0) return null;
81
+ const channels = CHANNELS[colorType];
82
+ const stride = Math.ceil((width * channels * bitDepth) / 8);
83
+ const bytesPerPixel = Math.max(1, (channels * bitDepth) >> 3);
84
+ const expected = (stride + 1) * height;
85
+ let raw;
86
+ try {
87
+ raw = inflateSync(Buffer.concat(header.idat), { maxOutputLength: expected + 1024 });
88
+ } catch {
89
+ return { problem: 'the pixel data does not decompress' };
90
+ }
91
+ if (raw.length < expected) return { problem: 'the pixel data is shorter than the image' };
92
+
93
+ const line = new Uint8Array(stride);
94
+ const previous = new Uint8Array(stride);
95
+ const rgba = new Uint8Array(width * height * 4);
96
+ const palette = header.palette;
97
+ const alphas = header.transparency;
98
+ const keyed = colorType !== 3 && alphas && alphas.length >= (colorType === 0 ? 2 : 6) ? alphas : null;
99
+ for (let y = 0; y < height; y += 1) {
100
+ const offset = y * (stride + 1);
101
+ const filter = raw[offset];
102
+ if (filter > 4) return { problem: 'a row of pixels has an unknown filter' };
103
+ for (let x = 0; x < stride; x += 1) {
104
+ const value = raw[offset + 1 + x];
105
+ const a = x >= bytesPerPixel ? line[x - bytesPerPixel] : 0;
106
+ const b = previous[x];
107
+ const c = x >= bytesPerPixel ? previous[x - bytesPerPixel] : 0;
108
+ let out = value;
109
+ if (filter === 1) out = value + a;
110
+ else if (filter === 2) out = value + b;
111
+ else if (filter === 3) out = value + ((a + b) >> 1);
112
+ else if (filter === 4) out = value + paeth(a, b, c);
113
+ line[x] = out & 0xff;
114
+ }
115
+ for (let x = 0; x < width; x += 1) {
116
+ const to = (y * width + x) * 4;
117
+ if (colorType === 3 || (colorType === 0 && bitDepth < 8)) {
118
+ const bitAt = x * bitDepth;
119
+ const sample = (line[bitAt >> 3] >> (8 - bitDepth - (bitAt & 7))) & ((1 << bitDepth) - 1);
120
+ if (colorType === 3) {
121
+ rgba[to] = palette ? palette[sample * 3] : 0;
122
+ rgba[to + 1] = palette ? palette[sample * 3 + 1] : 0;
123
+ rgba[to + 2] = palette ? palette[sample * 3 + 2] : 0;
124
+ rgba[to + 3] = alphas && sample < alphas.length ? alphas[sample] : 255;
125
+ } else {
126
+ const grey = Math.round((sample * 255) / ((1 << bitDepth) - 1));
127
+ rgba[to] = grey; rgba[to + 1] = grey; rgba[to + 2] = grey;
128
+ rgba[to + 3] = keyed && keyed.readUInt16BE(0) === sample ? 0 : 255;
129
+ }
130
+ continue;
131
+ }
132
+ // At 16 bits the high byte of each sample is what a screen shows.
133
+ const step = bitDepth === 16 ? 2 : 1;
134
+ const from = x * channels * step;
135
+ if (colorType === 0 || colorType === 4) {
136
+ const grey = line[from];
137
+ rgba[to] = grey; rgba[to + 1] = grey; rgba[to + 2] = grey;
138
+ rgba[to + 3] = colorType === 4 ? line[from + step]
139
+ : (keyed && (bitDepth === 16 ? (line[from] << 8) | line[from + 1] : line[from]) === keyed.readUInt16BE(0) ? 0 : 255);
140
+ } else {
141
+ rgba[to] = line[from];
142
+ rgba[to + 1] = line[from + step];
143
+ rgba[to + 2] = line[from + step * 2];
144
+ if (colorType === 6) rgba[to + 3] = line[from + step * 3];
145
+ else {
146
+ const sample = i => (bitDepth === 16 ? (line[from + i * 2] << 8) | line[from + i * 2 + 1] : line[from + i]);
147
+ rgba[to + 3] = keyed && sample(0) === keyed.readUInt16BE(0) && sample(1) === keyed.readUInt16BE(2) && sample(2) === keyed.readUInt16BE(4) ? 0 : 255;
148
+ }
149
+ }
150
+ }
151
+ previous.set(line);
152
+ }
153
+ return { width, height, rgba };
154
+ }
155
+
156
+ /** Whether a PNG can carry transparency at all, from its header. */
157
+ export function pngHasAlphaChannel(header) {
158
+ return header.colorType === 4 || header.colorType === 6 || Boolean(header.transparency);
159
+ }
160
+
161
+ const WAV_FORMATS = { 1: 'pcm', 3: 'float', 6: 'a-law', 7: 'mu-law' };
162
+
163
+ /**
164
+ * A WAV's format and length, from its `fmt ` and `data` chunks.
165
+ *
166
+ * @param {Buffer} data
167
+ * @returns {{codec: string, extensible: boolean, channels: number, sampleRate: number, bitsPerSample: number, durationSeconds: number|null, problem: string|null}|null} null when it is not a WAV
168
+ */
169
+ export function parseWav(data) {
170
+ if (data.length < 12 || data.toString('latin1', 0, 4) !== 'RIFF' || data.toString('latin1', 8, 12) !== 'WAVE') return null;
171
+ let at = 12;
172
+ let format = null;
173
+ let dataBytes = null;
174
+ let problem = null;
175
+ while (at + 8 <= data.length) {
176
+ const id = data.toString('latin1', at, at + 4);
177
+ const length = data.readUInt32LE(at + 4);
178
+ const start = at + 8;
179
+ if (id === 'fmt ' && start + 16 <= data.length) {
180
+ const tag = data.readUInt16LE(start);
181
+ // An extensible header names the real format in the first bytes of its subformat.
182
+ const extended = tag === 0xfffe && start + 26 <= data.length ? data.readUInt16LE(start + 24) : null;
183
+ format = {
184
+ tag: extended ?? tag,
185
+ extensible: tag === 0xfffe,
186
+ channels: data.readUInt16LE(start + 2),
187
+ sampleRate: data.readUInt32LE(start + 4),
188
+ byteRate: data.readUInt32LE(start + 8),
189
+ bitsPerSample: data.readUInt16LE(start + 14)
190
+ };
191
+ } else if (id === 'data') {
192
+ dataBytes = length;
193
+ if (start + length > data.length) {
194
+ dataBytes = data.length - start;
195
+ problem = 'the sound data runs past the end of the file';
196
+ }
197
+ break;
198
+ }
199
+ at = start + length + (length & 1);
200
+ }
201
+ if (!format) return { codec: 'unknown', extensible: false, channels: 0, sampleRate: 0, bitsPerSample: 0, durationSeconds: null, problem: 'there is no format chunk' };
202
+ if (dataBytes === null) problem = problem || 'there is no sound data';
203
+ return {
204
+ codec: WAV_FORMATS[format.tag] || `format ${format.tag}`,
205
+ extensible: format.extensible,
206
+ channels: format.channels,
207
+ sampleRate: format.sampleRate,
208
+ bitsPerSample: format.bitsPerSample,
209
+ durationSeconds: dataBytes !== null && format.byteRate > 0 ? dataBytes / format.byteRate : null,
210
+ problem
211
+ };
212
+ }
213
+
214
+ /** One Ogg page: its header fields and where its body sits. */
215
+ function oggPageAt(data, at) {
216
+ if (at + 27 > data.length || data.toString('latin1', at, at + 4) !== 'OggS') return null;
217
+ const segments = data[at + 26];
218
+ if (at + 27 + segments > data.length) return null;
219
+ let size = 0;
220
+ for (let i = 0; i < segments; i += 1) size += data[at + 27 + i];
221
+ return { granule: data.readBigInt64LE(at + 6), serial: data.readUInt32LE(at + 14), bodyStart: at + 27 + segments, next: at + 27 + segments + size };
222
+ }
223
+
224
+ /**
225
+ * An Ogg's codec and length: the first page names the codec, and the last page's
226
+ * granule position is how far the stream got. Vorbis counts samples; Opus counts
227
+ * at 48 kHz, less its pre-skip.
228
+ *
229
+ * @param {Buffer} data
230
+ * @returns {{codec: string, channels: number, sampleRate: number, bitsPerSample: null, durationSeconds: number|null, problem: string|null}|null} null when it is not an Ogg
231
+ */
232
+ export function parseOgg(data) {
233
+ const first = oggPageAt(data, 0);
234
+ if (!first) return null;
235
+ const body = data.subarray(first.bodyStart, first.next);
236
+ const out = { codec: 'unknown', channels: 0, sampleRate: 0, bitsPerSample: null, durationSeconds: null, problem: null };
237
+ let ticksPerSecond = 0;
238
+ let skip = 0;
239
+ if (body.length >= 30 && body[0] === 1 && body.toString('latin1', 1, 7) === 'vorbis') {
240
+ out.codec = 'vorbis';
241
+ out.channels = body[11];
242
+ out.sampleRate = body.readUInt32LE(12);
243
+ ticksPerSecond = out.sampleRate;
244
+ } else if (body.length >= 19 && body.toString('latin1', 0, 8) === 'OpusHead') {
245
+ out.codec = 'opus';
246
+ out.channels = body[9];
247
+ out.sampleRate = body.readUInt32LE(12);
248
+ ticksPerSecond = 48000;
249
+ skip = body.readUInt16LE(10);
250
+ } else {
251
+ out.problem = 'the first page does not name a codec this reads';
252
+ return out;
253
+ }
254
+ let at = 0;
255
+ let lastGranule = -1n;
256
+ for (;;) {
257
+ const page = oggPageAt(data, at);
258
+ if (!page || page.next > data.length) break;
259
+ if (page.serial === first.serial && page.granule >= 0n) lastGranule = page.granule;
260
+ at = page.next;
261
+ }
262
+ if (at < data.length) out.problem = 'the file ends part way through a page';
263
+ if (lastGranule >= 0n && ticksPerSecond > 0) out.durationSeconds = Math.max(0, Number(lastGranule) - skip) / ticksPerSecond;
264
+ return out;
265
+ }
266
+
267
+ export default { readPngHeader, decodePng, pngHasAlphaChannel, parseWav, parseOgg, MAX_DECODED_PIXELS };
@@ -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;