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,392 @@
1
+ /**
2
+ * What the pixels of one decoded image say: its transparency, its real pixel
3
+ * pitch and a guess at how it is laid out. Everything here is a measurement a
4
+ * person could repeat, and the layout is called a guess because that is what it is.
5
+ *
6
+ * The lessons behind these came from wiring real art into a real game:
7
+ * - An image described as "a 32 grid" measured 100 to 167 art pixels across, so
8
+ * the pitch is measured from the pixels and nothing else is believed.
9
+ * - Faint alpha across the whole canvas made a trim keep the whole canvas, so
10
+ * the box is reported both ways.
11
+ * - Tiles with their own frame draw a grid across a floor, so the edge band of a
12
+ * tile is compared with its middle.
13
+ */
14
+
15
+ /** Channel difference that still counts as the same colour when measuring runs. */
16
+ export const RUN_TOLERANCE = 16;
17
+
18
+ /** A run of 1 or 2 is an anti-aliased sliver, not an art pixel. */
19
+ const MIN_RUN = 3;
20
+ const MAX_RUN = 64;
21
+
22
+ /** Below this there are too few runs for a near match to mean anything. */
23
+ const MIN_SIDE_FOR_NEAR_PITCH = 64;
24
+
25
+ /** Alpha at or above this counts as seen, which is half way. */
26
+ export const ALPHA_THRESHOLD = 128;
27
+
28
+ /** An image this small is treated as pixel art whatever its colours. */
29
+ const SMALL_SIDE = 64;
30
+
31
+ const MAX_COUNTED_COLOURS = 256;
32
+
33
+ function emptyBox() {
34
+ return { minX: Infinity, minY: Infinity, maxX: -1, maxY: -1 };
35
+ }
36
+
37
+ function grow(box, x, y) {
38
+ if (x < box.minX) box.minX = x;
39
+ if (x > box.maxX) box.maxX = x;
40
+ if (y < box.minY) box.minY = y;
41
+ if (y > box.maxY) box.maxY = y;
42
+ }
43
+
44
+ function finishBox(box) {
45
+ return box.maxX < 0 ? null : { x: box.minX, y: box.minY, width: box.maxX - box.minX + 1, height: box.maxY - box.minY + 1 };
46
+ }
47
+
48
+ /**
49
+ * How transparent an image is, and the box a trim would keep two ways.
50
+ *
51
+ * @param {{width: number, height: number, rgba: Uint8Array}} image
52
+ * @returns {{opaque_share: number, transparent_share: number, partial_share: number, box_any: Object|null, box_threshold: Object|null}}
53
+ */
54
+ export function measureAlpha({ width, height, rgba }) {
55
+ let opaque = 0;
56
+ let transparent = 0;
57
+ const any = emptyBox();
58
+ const seen = emptyBox();
59
+ for (let y = 0; y < height; y += 1) {
60
+ for (let x = 0; x < width; x += 1) {
61
+ const alpha = rgba[(y * width + x) * 4 + 3];
62
+ if (alpha === 255) opaque += 1;
63
+ else if (alpha === 0) transparent += 1;
64
+ if (alpha > 0) grow(any, x, y);
65
+ if (alpha >= ALPHA_THRESHOLD) grow(seen, x, y);
66
+ }
67
+ }
68
+ const total = width * height;
69
+ return {
70
+ opaque_share: round(opaque / total),
71
+ transparent_share: round(transparent / total),
72
+ partial_share: round((total - opaque - transparent) / total),
73
+ box_any: finishBox(any),
74
+ box_threshold: finishBox(seen)
75
+ };
76
+ }
77
+
78
+ function round(value, places = 4) {
79
+ const scale = 10 ** places;
80
+ return Math.round(value * scale) / scale;
81
+ }
82
+
83
+ function greatestCommonDivisor(a, b) {
84
+ let x = a;
85
+ let y = b;
86
+ while (y) [x, y] = [y, x % y];
87
+ return x;
88
+ }
89
+
90
+ /**
91
+ * One direction's colour runs: the exact ones and the near-identical ones, seen
92
+ * pixels only. `lineStep` skips lines on a large image, which is plenty.
93
+ */
94
+ function collectRuns({ width, height, rgba }, horizontal, lineStep, exact, near) {
95
+ const lines = horizontal ? height : width;
96
+ const length = horizontal ? width : height;
97
+ const stride = horizontal ? 4 : width * 4;
98
+ for (let line = 0; line < lines; line += lineStep) {
99
+ let at = horizontal ? line * width * 4 : line * 4;
100
+ let exactRun = 0;
101
+ let nearRun = 0;
102
+ let previous = -1;
103
+ for (let i = 0; i < length; i += 1, at += stride) {
104
+ if (rgba[at + 3] < ALPHA_THRESHOLD) {
105
+ if (exactRun) exact.set(exactRun, (exact.get(exactRun) || 0) + 1);
106
+ if (nearRun) near.set(nearRun, (near.get(nearRun) || 0) + 1);
107
+ exactRun = 0;
108
+ nearRun = 0;
109
+ previous = -1;
110
+ continue;
111
+ }
112
+ const hasPrevious = previous >= 0;
113
+ if (hasPrevious && rgba[at] === rgba[previous] && rgba[at + 1] === rgba[previous + 1] && rgba[at + 2] === rgba[previous + 2]) {
114
+ exactRun += 1;
115
+ } else {
116
+ if (exactRun) exact.set(exactRun, (exact.get(exactRun) || 0) + 1);
117
+ exactRun = 1;
118
+ }
119
+ if (hasPrevious
120
+ && Math.abs(rgba[at] - rgba[previous]) <= RUN_TOLERANCE
121
+ && Math.abs(rgba[at + 1] - rgba[previous + 1]) <= RUN_TOLERANCE
122
+ && Math.abs(rgba[at + 2] - rgba[previous + 2]) <= RUN_TOLERANCE) {
123
+ nearRun += 1;
124
+ } else {
125
+ if (nearRun) near.set(nearRun, (near.get(nearRun) || 0) + 1);
126
+ nearRun = 1;
127
+ }
128
+ previous = at;
129
+ }
130
+ if (exactRun) exact.set(exactRun, (exact.get(exactRun) || 0) + 1);
131
+ if (nearRun) near.set(nearRun, (near.get(nearRun) || 0) + 1);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The size of one art pixel in file pixels.
137
+ *
138
+ * An image upscaled cleanly has every exact colour run a multiple of one number,
139
+ * and that number is the answer (`exact`). Otherwise the most common run of
140
+ * near-identical colour is, ignoring slivers, as long as it is a real peak and
141
+ * leaves at least 8 art pixels across. Anything else answers 1: drawn at file
142
+ * resolution, or painted with no grid.
143
+ *
144
+ * @param {{width: number, height: number, rgba: Uint8Array}} image
145
+ * @returns {{value: number, exact: boolean, art_width: number, art_height: number}}
146
+ */
147
+ export function measurePixelPitch(image) {
148
+ const { width, height } = image;
149
+ const lineStep = Math.max(1, Math.floor(Math.max(width, height) / 512));
150
+ const exact = new Map();
151
+ const near = new Map();
152
+ collectRuns(image, true, lineStep, exact, near);
153
+ collectRuns(image, false, lineStep, exact, near);
154
+
155
+ const answer = (value, isExact) => ({ value, exact: isExact, art_width: Math.round(width / value), art_height: Math.round(height / value) });
156
+
157
+ // A run that fills its whole line says nothing about the grid.
158
+ const lengths = [...exact.keys()].filter(length => length !== width && length !== height);
159
+ let divisor = 0;
160
+ for (const length of lengths) divisor = greatestCommonDivisor(divisor, length);
161
+ if (divisor > 1 && Math.min(width, height) / divisor >= 2) return answer(divisor, true);
162
+
163
+ let best = 0;
164
+ let bestCount = 0;
165
+ let total = 0;
166
+ for (const [length, count] of near) {
167
+ total += count;
168
+ if (length < MIN_RUN || length > MAX_RUN || length === width || length === height) continue;
169
+ if (count > bestCount || (count === bestCount && length < best)) {
170
+ best = length;
171
+ bestCount = count;
172
+ }
173
+ }
174
+ const enoughEvidence = bestCount >= Math.max(20, total * 0.02);
175
+ const isPeak = best > MIN_RUN || (near.get(MIN_RUN - 1) || 0) < bestCount;
176
+ const enoughArt = best > 0 && Math.min(width, height) >= MIN_SIDE_FOR_NEAR_PITCH && Math.min(width, height) / best >= 8;
177
+ return enoughEvidence && isPeak && enoughArt ? answer(best, false) : answer(1, false);
178
+ }
179
+
180
+ /** Distinct colours, stopping once there are more than a pixel artist would use. */
181
+ function countColours({ rgba }) {
182
+ const seen = new Set();
183
+ for (let at = 0; at < rgba.length; at += 4) {
184
+ if (rgba[at + 3] < ALPHA_THRESHOLD) continue;
185
+ seen.add((rgba[at] << 16) | (rgba[at + 1] << 8) | rgba[at + 2]);
186
+ if (seen.size > MAX_COUNTED_COLOURS) return MAX_COUNTED_COLOURS + 1;
187
+ }
188
+ return seen.size;
189
+ }
190
+
191
+ /** How much each line varies along its own length, in brightness. */
192
+ function lineVariation({ width, height, rgba }, vertical) {
193
+ const lines = vertical ? width : height;
194
+ const along = vertical ? height : width;
195
+ const step = Math.max(1, Math.floor(along / 256));
196
+ const out = new Float64Array(lines);
197
+ for (let line = 0; line < lines; line += 1) {
198
+ let sum = 0;
199
+ let squares = 0;
200
+ let count = 0;
201
+ for (let i = 0; i < along; i += step) {
202
+ const at = (vertical ? i * width + line : line * width + i) * 4;
203
+ const value = 0.299 * rgba[at] + 0.587 * rgba[at + 1] + 0.114 * rgba[at + 2];
204
+ sum += value;
205
+ squares += value * value;
206
+ count += 1;
207
+ }
208
+ out[line] = Math.sqrt(Math.max(0, squares / count - (sum / count) ** 2));
209
+ }
210
+ return out;
211
+ }
212
+
213
+ const GRID_COUNTS = [16, 12, 10, 8, 6, 5, 4, 3, 2];
214
+
215
+ /**
216
+ * How many equal cells an opaque picture is cut into by gutters: lines that stay
217
+ * nearly one colour along their whole length, where the art around them does not.
218
+ * Every line of a candidate grid has to be one, and off-grid lines mostly not.
219
+ */
220
+ function gutterCount(variation) {
221
+ const size = variation.length;
222
+ if (size < 16) return 1;
223
+ const sorted = [...variation].sort((a, b) => a - b);
224
+ const usual = sorted[size >> 1];
225
+ if (usual < 2) return 1;
226
+ const calm = usual * 0.6;
227
+ const reach = Math.max(1, Math.round(size * 0.015));
228
+ for (const count of GRID_COUNTS) {
229
+ if (size / count < 8) continue;
230
+ const onGrid = new Uint8Array(size);
231
+ let fits = true;
232
+ for (let k = 1; k < count && fits; k += 1) {
233
+ const line = Math.round((k * size) / count);
234
+ let lowest = Infinity;
235
+ for (let at = Math.max(0, line - reach); at <= Math.min(size - 1, line + reach); at += 1) {
236
+ onGrid[at] = 1;
237
+ if (variation[at] < lowest) lowest = variation[at];
238
+ }
239
+ fits = lowest <= calm;
240
+ }
241
+ if (!fits) continue;
242
+ let strays = 0;
243
+ const edge = Math.round(size * 0.03);
244
+ for (let at = edge; at < size - edge; at += 1) if (!onGrid[at] && variation[at] <= calm) strays += 1;
245
+ if (strays <= size * 0.02) return count;
246
+ }
247
+ return 1;
248
+ }
249
+
250
+ /** Spans of lines holding something seen, for finding cells split by clear gaps. */
251
+ function seenSpans({ width, height, rgba }, horizontal) {
252
+ const lines = horizontal ? width : height;
253
+ const along = horizontal ? height : width;
254
+ const spans = [];
255
+ let start = -1;
256
+ for (let line = 0; line <= lines; line += 1) {
257
+ let seen = false;
258
+ if (line < lines) {
259
+ for (let i = 0; i < along && !seen; i += 1) {
260
+ const at = (horizontal ? i * width + line : line * width + i) * 4;
261
+ seen = rgba[at + 3] >= ALPHA_THRESHOLD;
262
+ }
263
+ }
264
+ if (seen && start < 0) start = line;
265
+ if (!seen && start >= 0) {
266
+ spans.push(line - start);
267
+ start = -1;
268
+ }
269
+ }
270
+ return spans;
271
+ }
272
+
273
+ /** Evenly sized spans count as cells; a stray spark next to a sprite does not. */
274
+ function regularSpans(spans) {
275
+ if (spans.length < 2) return 1;
276
+ const largest = Math.max(...spans);
277
+ return spans.every(span => span >= largest * 0.6) ? spans.length : 1;
278
+ }
279
+
280
+ /**
281
+ * Whether a tile carries its own frame: a band at all four edges clearly lighter
282
+ * or darker than its middle, and which way.
283
+ *
284
+ * @returns {{framed: boolean, band: number}}
285
+ */
286
+ export function measureFrame({ width, height, rgba }, cell) {
287
+ const band = Math.max(1, Math.round(Math.min(cell.width, cell.height) * 0.02));
288
+ const light = (x, y) => {
289
+ const at = ((cell.y + y) * width + cell.x + x) * 4;
290
+ return 0.299 * rgba[at] + 0.587 * rgba[at + 1] + 0.114 * rgba[at + 2];
291
+ };
292
+ const mean = (x0, y0, x1, y1) => {
293
+ let sum = 0;
294
+ let count = 0;
295
+ for (let y = y0; y < y1; y += 1) {
296
+ for (let x = x0; x < x1; x += 1) {
297
+ sum += light(x, y);
298
+ count += 1;
299
+ }
300
+ }
301
+ return count ? sum / count : 0;
302
+ };
303
+ const { width: w, height: h } = cell;
304
+ const middle = mean(Math.floor(w * 0.2), Math.floor(h * 0.2), Math.ceil(w * 0.8), Math.ceil(h * 0.8));
305
+ const edges = [mean(0, 0, band, h), mean(w - band, 0, w, h), mean(0, 0, w, band), mean(0, h - band, w, h)];
306
+ const dark = edges.every(edge => edge <= middle - 10);
307
+ const pale = edges.every(edge => edge >= middle + 10);
308
+ return { framed: dark || pale, band };
309
+ }
310
+
311
+ /**
312
+ * How different a tile's opposite edges are, from 0 to 1: what shows as a seam
313
+ * when it is laid beside itself.
314
+ */
315
+ export function measureSeam({ width, rgba }, cell) {
316
+ let sum = 0;
317
+ let count = 0;
318
+ const pixel = (x, y) => ((cell.y + y) * width + cell.x + x) * 4;
319
+ const compare = (a, b) => {
320
+ sum += (Math.abs(rgba[a] - rgba[b]) + Math.abs(rgba[a + 1] - rgba[b + 1]) + Math.abs(rgba[a + 2] - rgba[b + 2])) / (3 * 255);
321
+ count += 1;
322
+ };
323
+ for (let y = 0; y < cell.height; y += 1) compare(pixel(0, y), pixel(cell.width - 1, y));
324
+ for (let x = 0; x < cell.width; x += 1) compare(pixel(x, 0), pixel(x, cell.height - 1));
325
+ return round(sum / count, 3);
326
+ }
327
+
328
+ /**
329
+ * A guess at the layout: one picture, one tile, a strip of frames, or a sheet.
330
+ *
331
+ * @param {{width: number, height: number, rgba: Uint8Array}} image
332
+ * @param {Object} alpha from measureAlpha
333
+ * @param {string} [relativePath] names like floor or wall tip a square opaque image towards a tile
334
+ * @returns {{kind: 'single'|'tile'|'strip'|'sheet', columns: number, rows: number, cell_width: number, cell_height: number, framed: boolean, seam: number|null}}
335
+ */
336
+ export function guessLayout(image, alpha, relativePath = '') {
337
+ const { width, height } = image;
338
+ const opaqueEnough = alpha.opaque_share >= 0.98;
339
+ let columns = 1;
340
+ let rows = 1;
341
+ if (opaqueEnough) {
342
+ columns = gutterCount(lineVariation(image, true));
343
+ rows = gutterCount(lineVariation(image, false));
344
+ } else {
345
+ columns = regularSpans(seenSpans(image, true));
346
+ rows = regularSpans(seenSpans(image, false));
347
+ }
348
+ // Gutters one way and art the other: square cells are the likely answer.
349
+ if (opaqueEnough && columns > 1 && rows === 1 && height % Math.floor(width / columns) === 0) rows = height / Math.floor(width / columns);
350
+ else if (opaqueEnough && rows > 1 && columns === 1 && width % Math.floor(height / rows) === 0) columns = width / Math.floor(height / rows);
351
+ const named = /(^|[/_-])(tile|tiles|tileset|floor|wall|ground|terrain)/i.test(relativePath);
352
+ const cellOf = (x, y) => ({ x, y, width: Math.floor(width / columns), height: Math.floor(height / rows) });
353
+ const describe = kind => {
354
+ const out = { kind, columns, rows, cell_width: Math.floor(width / columns), cell_height: Math.floor(height / rows), framed: false, seam: null };
355
+ if (opaqueEnough && (kind === 'tile' || kind === 'sheet')) {
356
+ // A sheet reads as framed when most of its cells are.
357
+ let framed = 0;
358
+ let seam = 0;
359
+ for (let row = 0; row < rows; row += 1) {
360
+ for (let column = 0; column < columns; column += 1) {
361
+ const cell = cellOf(column * out.cell_width, row * out.cell_height);
362
+ if (measureFrame(image, cell).framed) framed += 1;
363
+ seam += measureSeam(image, cell);
364
+ }
365
+ }
366
+ out.framed = framed * 2 > columns * rows;
367
+ out.seam = round(seam / (columns * rows), 3);
368
+ }
369
+ return out;
370
+ };
371
+ if (columns > 1 || rows > 1) return describe('sheet');
372
+ if (!opaqueEnough && height >= 8 && width % height === 0 && width / height >= 2 && width / height <= 16) {
373
+ columns = width / height;
374
+ return describe('strip');
375
+ }
376
+ if (opaqueEnough && width === height && (named || width <= 128)) return describe('tile');
377
+ return describe('single');
378
+ }
379
+
380
+ /**
381
+ * Whether this reads as pixel art, which needs a nearest filter on screen.
382
+ *
383
+ * @returns {boolean}
384
+ */
385
+ export function looksLikePixelArt(image, alpha, pitch, layout) {
386
+ if (pitch.value > 1) return true;
387
+ if (Math.max(image.width, image.height) <= SMALL_SIDE) return true;
388
+ if (layout.kind !== 'single' && Math.max(layout.cell_width, layout.cell_height) <= SMALL_SIDE) return true;
389
+ return alpha.partial_share < 0.01 && countColours(image) <= MAX_COUNTED_COLOURS;
390
+ }
391
+
392
+ export default { measureAlpha, measurePixelPitch, guessLayout, looksLikePixelArt, measureFrame, measureSeam };
package/src/cli.js CHANGED
@@ -83,6 +83,16 @@ There is no spending flag: each run's model budget is set by the server from
83
83
  what the job was charged.
84
84
  `;
85
85
 
86
+ /** The fix options parsed by the runner rather than declared here. */
87
+ export const FIX_HELP = `
88
+ Fix v2 (Labs beta), when the server reports it live for the game:
89
+ --open-pr after a verified fix, ask, then commit the fix's files, push the branch
90
+ and open a pull request with the evidence report (needs gh auth status)
91
+ --no-labs run the ordinary fix even when Fix v2 is on
92
+ --allow-tests let the checks run the project's tests and run on edited test files
93
+ --allow-tool-scripts let the checks import edits to project.godot, addons, .godot or @tool scripts
94
+ `;
95
+
86
96
  /** Flags every command shares. */
87
97
  function common(command) {
88
98
  return command
@@ -331,6 +341,7 @@ export function buildProgram() {
331
341
  .option('--branch <name>', 'the branch to create (default ravensight/fix-<label>-<hex>)')
332
342
  .option('--no-verify', 'do not replay the finding after the fix')
333
343
  .option('--allow-dirty', 'fix on top of uncommitted changes instead of refusing')
344
+ .addHelpText('after', FIX_HELP)
334
345
  .allowUnknownOption()
335
346
  // Unknown options' values land in command.args; without this commander
336
347
  // counts them as excess positional arguments and refuses the command.
@@ -372,6 +383,228 @@ export function buildProgram() {
372
383
  return simulateCommand(flagsFrom(options, { game: options.game, configFile: options.config, driverConfig: options.driverConfig, resume: options.resume }));
373
384
  }));
374
385
 
386
+ common(program.command('replay').description('play a recorded Godot persona run again with no model; free'))
387
+ .argument('<runId>', 'the run to replay')
388
+ .option('--ref <ref>', 'the commit or branch to replay it on, default HEAD')
389
+ .option('--job <jobId>', 'the run\'s job, to read an uploaded trace when this machine has none')
390
+ .option('--game <gameId>', 'the game, when this repo is not linked')
391
+ .option('--godot-project <dir>', 'the Godot project directory inside this repo')
392
+ .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
393
+ .option('--baseline', 'first replay on the commit the trace was recorded on, to show the game honors its seed')
394
+ .option('--headless', 'replay headless whatever the run was recorded in')
395
+ .option('--no-record', 'do not record the outcome on the job')
396
+ .action(capture(slot, async (runId, options) => {
397
+ const { replayCommand } = await import('./commands/replay.js');
398
+ return replayCommand(runId, flagsFrom(options, {
399
+ game: options.game,
400
+ ref: options.ref,
401
+ job: options.job,
402
+ godotProject: options.godotProject,
403
+ godotPath: options.godotPath,
404
+ baseline: Boolean(options.baseline),
405
+ headless: Boolean(options.headless),
406
+ record: options.record
407
+ }));
408
+ }));
409
+
410
+ common(program.command('feature').description('Labs beta: write a feature spec from a brief, or build an approved one on a new branch'))
411
+ .argument('<brief-or-spec>', 'a short brief in quotes writes a specification; a specification id builds it')
412
+ .option('--game <gameId>', 'the game, when this repo is not linked')
413
+ .option('--branch <name>', 'the branch to create (default ravensight/feature-<title>-<hex>)')
414
+ .addOption(new Option('--personas <list>', 'comma separated persona slugs, used in turn across the scenarios').argParser(list))
415
+ .option('--godot-project <dir>', 'the Godot project directory inside this repo')
416
+ .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
417
+ .option('--headless', 'play the scenarios headless: no screenshots, and no mouse or GUI input reaches the game')
418
+ .option('--open-pr', 'after a verified feature, ask, then commit its files, push the branch and open a pull request (needs gh auth status)')
419
+ .option('--allow-tests', 'let the checks run the project\'s tests and run on edited test files')
420
+ .option('--allow-tool-scripts', 'let the checks import edits to project.godot, addons, .godot or @tool scripts')
421
+ .option('--max-actions <n>', 'a lower ceiling on model turns in the apply loop')
422
+ .option('--assets-dir <dir>', 'the folder inside the project that holds your art and audio (default assets)')
423
+ .option('--alpha-3d', 'Alpha, a proof of concept: work on a project whose main scene has a 3D root')
424
+ .action(capture(slot, async (target, options) => {
425
+ const { featureCommand } = await import('./commands/feature.js');
426
+ return featureCommand(target, flagsFrom(options, {
427
+ game: options.game,
428
+ branch: options.branch,
429
+ personas: options.personas,
430
+ godotProject: options.godotProject,
431
+ godotPath: options.godotPath,
432
+ headless: Boolean(options.headless),
433
+ openPr: Boolean(options.openPr),
434
+ allowTests: Boolean(options.allowTests),
435
+ allowToolScripts: Boolean(options.allowToolScripts),
436
+ maxActions: options.maxActions === undefined ? undefined : Number(options.maxActions),
437
+ assetsDir: options.assetsDir,
438
+ alpha3d: Boolean(options.alpha3d)
439
+ }));
440
+ }));
441
+
442
+ common(program.command('template').description('Labs, Alpha: write the godot-3d proof of concept template into a new directory, for feature and validate with --alpha-3d'))
443
+ .argument('<name>', 'the template: godot-3d')
444
+ .requiredOption('--game <gameId>', 'the game this project reports to, with Labs on')
445
+ .option('--dir <path>', 'where to write it (default ./<name>)')
446
+ .option('--name <title>', 'the project\'s title')
447
+ .action(capture(slot, async (name, options) => {
448
+ const { templateCommand } = await import('./commands/template.js');
449
+ return templateCommand(name, flagsFrom(options, { game: options.game, dir: options.dir, name: options.name }));
450
+ }));
451
+
452
+ common(program.command('assets').description('list your art and audio with what was measured about each file; reads this machine only and sends nothing'))
453
+ .option('--assets-dir <dir>', 'the folder inside the project that holds your art and audio (default assets)')
454
+ .option('--godot-project <dir>', 'the Godot project directory inside this repo')
455
+ .option('--grid <n>', 'the grid you expect, in art pixels across, to warn about any file that differs')
456
+ .action(capture(slot, async options => {
457
+ const { assetsCommand } = await import('./commands/assets.js');
458
+ return assetsCommand(flagsFrom(options, {
459
+ assetsDir: options.assetsDir,
460
+ godotProject: options.godotProject,
461
+ grid: options.grid === undefined ? undefined : Number(options.grid)
462
+ }));
463
+ }));
464
+
465
+ common(program.command('create').description('Labs, Experimental: a small Godot 2D prototype from a brief, one validated milestone at a time, up to a declared count'))
466
+ .argument('<brief-or-plan>', 'a short brief in quotes starts a plan; a plan\'s job id carries it on after a person approved its design')
467
+ .option('--name <title>', 'the game\'s working title (default: the design\'s)')
468
+ .option('--dir <path>', 'where to create the project (default ./<title>)')
469
+ .option('--milestones <n>', 'how many milestones to buy at most: the run stops there', '3')
470
+ .addOption(new Option('--size <size>', 'the largest feature tier a milestone may be').choices(['small', 'medium', 'large']).default('medium'))
471
+ .addOption(new Option('--personas <list>', 'comma separated persona slugs, used in turn').argParser(list))
472
+ .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
473
+ .option('--headless', 'play headless: no screenshots, and no mouse or GUI input reaches the game')
474
+ .option('--allow-tool-scripts', 'let the checks import edits to project.godot, addons, .godot or @tool scripts')
475
+ .option('--assets-dir <dir>', 'the folder inside the project that holds your art and audio (default assets)')
476
+ .option('--stop', 'close the plan now: what finished stays paid for, and what never started comes back')
477
+ .option('--template <name>', 'godot-2d, the only template create uses: it does not make a 3D game')
478
+ .action(capture(slot, async (target, options) => {
479
+ const { createCommand } = await import('./commands/create.js');
480
+ return createCommand(target, flagsFrom(options, {
481
+ name: options.name,
482
+ dir: options.dir,
483
+ milestones: Number(options.milestones),
484
+ size: options.size,
485
+ personas: options.personas,
486
+ godotPath: options.godotPath,
487
+ headless: Boolean(options.headless),
488
+ allowToolScripts: Boolean(options.allowToolScripts),
489
+ assetsDir: options.assetsDir,
490
+ stop: Boolean(options.stop),
491
+ template: options.template
492
+ }));
493
+ }));
494
+
495
+ common(program.command('validate').description('Labs beta: play a branch and its base with personas, and compare them'))
496
+ .argument('[branch]', 'the branch to validate, default HEAD')
497
+ .option('--base <ref>', 'what to compare it with, default the repository\'s default branch')
498
+ .option('--runs <n>', 'persona runs a side, 1 to 10, default 3')
499
+ .addOption(new Option('--personas <list>', 'comma separated persona slugs, used in turn across the pairs').argParser(list))
500
+ .option('--extend-to <n>', 'if the first answer is too small to call, buy more runs up to this many a side')
501
+ .option('--game <gameId>', 'the game, when this repo is not linked')
502
+ .option('--godot-project <dir>', 'the Godot project directory inside this repo')
503
+ .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
504
+ .option('--concurrency <n>', 'runs at once, default 2')
505
+ .option('--headless', 'play headless: no screenshots, and no mouse or GUI input reaches the game')
506
+ .option('--fail-on-regression', 'exit 1 when the comparison reads regressed or mixed; with --hosted, on anything but matched')
507
+ .option('--resume <jobId>', 'carry on with an interrupted validation, with no second charge')
508
+ .option('--alpha-3d', 'Alpha, a proof of concept: compare commits of a project whose main scene has a 3D root')
509
+ .option('--hosted', 'play uploaded builds on hosted machines on one seed and compare them state by state, no personas')
510
+ .option('--seed <text>', 'with --hosted: the seed both builds play')
511
+ .option('--games <n>', 'with --hosted: games a side, 1 to 10, default 3')
512
+ .option('--steps <n>', 'with --hosted: the most actions a game, default 300')
513
+ .option('--no-wait', 'with --hosted: start it and exit; the job page shows the result')
514
+ .action(capture(slot, async (branch, options) => {
515
+ if (options.hosted) {
516
+ const { validateHostedCommand } = await import('./commands/validateHosted.js');
517
+ return validateHostedCommand(branch, flagsFrom(options, {
518
+ game: options.game, base: options.base, godotProject: options.godotProject, godotPath: options.godotPath,
519
+ seed: options.seed, games: options.games, steps: options.steps, wait: options.wait, alpha3d: Boolean(options.alpha3d),
520
+ failOnRegression: Boolean(options.failOnRegression)
521
+ }));
522
+ }
523
+ const { validateCommand } = await import('./commands/validate.js');
524
+ return validateCommand(branch, flagsFrom(options, {
525
+ game: options.game,
526
+ base: options.base,
527
+ runs: options.runs === undefined ? undefined : Number(options.runs),
528
+ personas: options.personas,
529
+ extendTo: options.extendTo === undefined ? undefined : Number(options.extendTo),
530
+ godotProject: options.godotProject,
531
+ godotPath: options.godotPath,
532
+ concurrency: options.concurrency === undefined ? undefined : Number(options.concurrency),
533
+ headless: Boolean(options.headless),
534
+ failOnRegression: Boolean(options.failOnRegression),
535
+ resume: options.resume,
536
+ alpha3d: Boolean(options.alpha3d)
537
+ }));
538
+ }));
539
+
540
+ // Labs lists. Each subcommand takes the shared flags itself, so `--json` and
541
+ // `--game` work after the subcommand name, where people type them.
542
+ const listFlags = command => common(command).option('--game <gameId>', 'the game, when this repo is not linked');
543
+ const statusOption = () => new Option('--status <status>', 'which items to show').choices(['open', 'done', 'dropped', 'all']).default('open');
544
+
545
+ const punch = common(program.command('punch')
546
+ .description('Labs beta: the punch list, what has to be finished before handover, closed one item at a time'));
547
+ listFlags(punch.command('list').description('show the punch list; free'))
548
+ .addOption(statusOption())
549
+ .action(capture(slot, async options => {
550
+ const { punchList } = await import('./commands/punch.js');
551
+ return punchList(flagsFrom(options, { game: options.game, status: options.status }));
552
+ }));
553
+ listFlags(punch.command('add').description('add one item; free, with plain-language hints'))
554
+ .argument('<text>', 'the item, in quotes')
555
+ .action(capture(slot, async (text, options) => {
556
+ const { punchAdd } = await import('./commands/punch.js');
557
+ return punchAdd(text, flagsFrom(options, { game: options.game }));
558
+ }));
559
+ listFlags(punch.command('done').description('close an item as done; free'))
560
+ .argument('<itemId>', 'the item id, li_...')
561
+ .action(capture(slot, async (itemId, options) => {
562
+ const { punchClose } = await import('./commands/punch.js');
563
+ return punchClose(itemId, 'done', flagsFrom(options, { game: options.game }));
564
+ }));
565
+ listFlags(punch.command('drop').description('close an item as dropped; free'))
566
+ .argument('<itemId>', 'the item id, li_...')
567
+ .action(capture(slot, async (itemId, options) => {
568
+ const { punchClose } = await import('./commands/punch.js');
569
+ return punchClose(itemId, 'dropped', flagsFrom(options, { game: options.game }));
570
+ }));
571
+ listFlags(punch.command('draft').description('propose items from this game\'s findings and specs; one AI unit, saves nothing without --accept'))
572
+ .option('--accept', 'add every proposal to the punch list')
573
+ .action(capture(slot, async options => {
574
+ const { punchDraft } = await import('./commands/punch.js');
575
+ return punchDraft(flagsFrom(options, { game: options.game, accept: Boolean(options.accept) }));
576
+ }));
577
+
578
+ const tape = listFlags(program.command('tape')
579
+ .description('Labs beta: blue tape, small first-impression annoyances; tape "<text>" captures one'))
580
+ .argument('[text]', 'the annoyance, in quotes')
581
+ .action(capture(slot, async (text, options, command) => {
582
+ if (!text) {
583
+ command.outputHelp();
584
+ return ExitCode.FAILURE;
585
+ }
586
+ const { tapeAdd } = await import('./commands/tape.js');
587
+ return tapeAdd(text, flagsFrom(options, { game: options.game }));
588
+ }));
589
+ listFlags(tape.command('list').description('show blue tape; free'))
590
+ .addOption(statusOption())
591
+ .action(capture(slot, async options => {
592
+ const { tapeList } = await import('./commands/tape.js');
593
+ return tapeList(flagsFrom(options, { game: options.game, status: options.status }));
594
+ }));
595
+ listFlags(tape.command('promote').description('move an item to the punch list; free'))
596
+ .argument('<itemId>', 'the item id, li_...')
597
+ .action(capture(slot, async (itemId, options) => {
598
+ const { tapePromote } = await import('./commands/tape.js');
599
+ return tapePromote(itemId, flagsFrom(options, { game: options.game }));
600
+ }));
601
+ listFlags(tape.command('drop').description('drop an item; free'))
602
+ .argument('<itemId>', 'the item id, li_...')
603
+ .action(capture(slot, async (itemId, options) => {
604
+ const { tapeDrop } = await import('./commands/tape.js');
605
+ return tapeDrop(itemId, flagsFrom(options, { game: options.game }));
606
+ }));
607
+
375
608
  common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
376
609
  .argument('<trace>', 'a selected trace.json written by simulate')
377
610
  .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')