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
@@ -0,0 +1,429 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ checkEnv, copyForCheck, CHECK_TIMEOUTS_MS, detectSandbox, parseGodotErrors, runBounded, sandboxProfile, testFileRefusal, toolScriptRefusal
6
+ } from '../run/checks.js';
7
+ import { detectGodotBinary } from '../run/drivers/godot-project.js';
8
+ import { listFiles } from '../run/repoTools.js';
9
+ import { defaultFilter, isMediaPath } from './inventory.js';
10
+
11
+ /**
12
+ * Whether the art and audio a change names actually work: three named checks in
13
+ * the run_check set, plus the scan that says which files a project uses.
14
+ *
15
+ * - `asset_references` reads the changed files: every asset they name exists, with
16
+ * that exact case, inside the project. No process runs.
17
+ * - `asset_import` imports a temporary copy of the project in real Godot and reads
18
+ * what it produced for each asset the change names.
19
+ * - `pixel_art_settings` reads settings: pixel art the change names is drawn with
20
+ * the nearest filter, with no mipmaps and no lossy or VRAM compression.
21
+ *
22
+ * Only what the change names can fail. A studio's old unused file or an old broken
23
+ * reference is the studio's business, and no job is refunded because of it.
24
+ */
25
+
26
+ export const ASSET_CHECK_NAMES = Object.freeze(['asset_references', 'asset_import', 'pixel_art_settings']);
27
+
28
+ /** A failure here means the game breaks, so no change like it is uploaded. */
29
+ export const BLOCKING_ASSET_CHECKS = Object.freeze(['asset_references', 'asset_import']);
30
+
31
+ const TEXT_EXTENSIONS = Object.freeze(['.gd', '.tscn', '.tres', '.gdshader', '.cfg']);
32
+ const MAX_SCANNED_BYTES = 4 * 1024 * 1024;
33
+ const MAX_LISTED = 40;
34
+
35
+ const RESOURCE = /(res|uid):\/\/[^"'\s)\]}]*/g;
36
+ const LOADING_CALL = /\b(?:preload|load|load_from_file|load_threaded_request)\s*\(\s*["']([^"']+)["']/g;
37
+
38
+ /** A script's text with its comments removed: a note about a future file is not a reference. */
39
+ export function withoutComments(text) {
40
+ return String(text).split('\n').map(line => {
41
+ let quote = null;
42
+ for (let i = 0; i < line.length; i += 1) {
43
+ const char = line[i];
44
+ if (quote) {
45
+ if (char === '\\') i += 1;
46
+ else if (char === quote) quote = null;
47
+ } else if (char === '"' || char === "'") quote = char;
48
+ else if (char === '#') return line.slice(0, i);
49
+ }
50
+ return line;
51
+ }).join('\n');
52
+ }
53
+
54
+ /**
55
+ * Everything one text file names, sorted out.
56
+ *
57
+ * @param {string} text
58
+ * @param {string} file its name, which decides the comment rule
59
+ * @returns {{exact: string[], patterns: string[], prefixes: string[], uids: string[], loose: string[]}}
60
+ * `exact` are project relative asset paths, `patterns` are paths with a format
61
+ * marker in them, `prefixes` are folders a path is built from, and `loose` are
62
+ * asset paths given to a loading call that are not `res://` paths at all
63
+ */
64
+ export function scanReferences(text, file = 'x.gd') {
65
+ const source = file.endsWith('.gd') ? withoutComments(text) : String(text);
66
+ const out = { exact: new Set(), patterns: new Set(), prefixes: new Set(), uids: new Set(), loose: new Set() };
67
+ for (const match of source.matchAll(RESOURCE)) {
68
+ const value = match[0];
69
+ if (value.startsWith('uid://')) {
70
+ out.uids.add(value);
71
+ continue;
72
+ }
73
+ const relative = value.slice('res://'.length);
74
+ if (/%[-0-9.]*[a-z]|\{[^}]*\}/i.test(relative)) {
75
+ if (isMediaPath(relative.replace(/%[-0-9.]*[a-z]|\{[^}]*\}/gi, 'x'))) out.patterns.add(relative);
76
+ } else if (relative.endsWith('/')) out.prefixes.add(relative);
77
+ else if (isMediaPath(relative)) out.exact.add(path.posix.normalize(relative));
78
+ }
79
+ for (const match of source.matchAll(LOADING_CALL)) {
80
+ const value = match[1];
81
+ if (!/^(res|uid|user):\/\//.test(value) && isMediaPath(value)) out.loose.add(value);
82
+ }
83
+ return {
84
+ exact: [...out.exact], patterns: [...out.patterns], prefixes: [...out.prefixes], uids: [...out.uids], loose: [...out.loose]
85
+ };
86
+ }
87
+
88
+ /** A project file's text, or null. A link is not followed: it could lead out of the project. */
89
+ async function readInside(projectDir, relative) {
90
+ const absolute = path.join(projectDir, relative);
91
+ const info = await fs.lstat(absolute).catch(() => null);
92
+ if (!info || !info.isFile() || info.size > MAX_SCANNED_BYTES) return null;
93
+ return fs.readFile(absolute, 'utf8').catch(() => null);
94
+ }
95
+
96
+ async function scanFile(projectDir, relative) {
97
+ const text = await readInside(projectDir, relative);
98
+ return text === null ? null : scanReferences(text, relative);
99
+ }
100
+
101
+ function isScanned(relative) {
102
+ return relative === 'project.godot' || TEXT_EXTENSIONS.includes(path.extname(relative).toLowerCase());
103
+ }
104
+
105
+ /**
106
+ * Whether a project relative path exists with exactly that case. A case-insensitive
107
+ * disk answers yes to the wrong case, and an exported game then cannot find it.
108
+ *
109
+ * @returns {Promise<boolean>}
110
+ */
111
+ export async function existsExactly(projectDir, relative) {
112
+ let directory = projectDir;
113
+ const segments = relative.split('/').filter(Boolean);
114
+ for (const [index, segment] of segments.entries()) {
115
+ const names = await fs.readdir(directory).catch(() => []);
116
+ if (!names.includes(segment)) return false;
117
+ directory = path.join(directory, segment);
118
+ if (index === segments.length - 1) {
119
+ const info = await fs.lstat(directory).catch(() => null);
120
+ return Boolean(info && info.isFile());
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /** A pattern from code, like `tiles/floor_%d.png`, as a test of a path. */
127
+ function patternTest(pattern) {
128
+ const source = pattern.split(/%[-0-9.]*[a-z]|\{[^}]*\}/i).map(part => part.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('.*');
129
+ return new RegExp(`^${source}$`);
130
+ }
131
+
132
+ async function uidsOf(projectDir, files) {
133
+ const map = new Map();
134
+ for (const file of files) {
135
+ const text = await readInside(projectDir, `${file.path}.import`);
136
+ const match = text && /^uid="(uid:\/\/[^"]+)"/m.exec(text);
137
+ if (match) map.set(match[1], file.path);
138
+ }
139
+ return map;
140
+ }
141
+
142
+ /**
143
+ * Which of the inventory's files the project uses as it stands, and which of those
144
+ * the change named.
145
+ *
146
+ * @param {{projectDir: string, inventory: Object, changed: string[]}} input
147
+ * @returns {Promise<{used: string[], unused: string[], wired: string[], built_in_code: string[]}>}
148
+ */
149
+ export async function scanUsage({ projectDir, inventory, changed = [] }) {
150
+ const names = inventory.files.map(file => file.path);
151
+ const uids = await uidsOf(projectDir, inventory.files);
152
+ const everywhere = (await listFiles(projectDir)).filter(isScanned);
153
+ const direct = new Set();
154
+ const built = new Set();
155
+ const wired = new Set();
156
+ const changedSet = new Set(changed.map(file => file.split(path.sep).join('/')));
157
+ for (const file of everywhere) {
158
+ const found = await scanFile(projectDir, file);
159
+ if (!found) continue;
160
+ const hits = new Set(found.exact.filter(item => names.includes(item)));
161
+ for (const uid of found.uids) if (uids.has(uid)) hits.add(uids.get(uid));
162
+ const shaped = new Set();
163
+ for (const pattern of found.patterns) {
164
+ const test = patternTest(pattern);
165
+ for (const name of names) if (test.test(name)) shaped.add(name);
166
+ }
167
+ for (const prefix of found.prefixes) for (const name of names) if (name.startsWith(prefix)) shaped.add(name);
168
+ for (const item of hits) direct.add(item);
169
+ for (const item of shaped) built.add(item);
170
+ if (changedSet.has(file)) {
171
+ for (const item of hits) wired.add(item);
172
+ for (const item of shaped) wired.add(item);
173
+ }
174
+ }
175
+ const used = names.filter(name => direct.has(name) || built.has(name));
176
+ return {
177
+ used,
178
+ unused: names.filter(name => !direct.has(name) && !built.has(name)),
179
+ wired: names.filter(name => wired.has(name)),
180
+ built_in_code: names.filter(name => built.has(name) && !direct.has(name))
181
+ };
182
+ }
183
+
184
+ function capped(items) {
185
+ return items.length > MAX_LISTED ? [...items.slice(0, MAX_LISTED), `and ${items.length - MAX_LISTED} more`] : items;
186
+ }
187
+
188
+ /** What the changed files name, with where each came from. */
189
+ async function namedByChange(projectDir, changed, uidMap) {
190
+ const rows = [];
191
+ for (const file of changed.map(item => item.split(path.sep).join('/')).filter(isScanned)) {
192
+ const found = await scanFile(projectDir, file);
193
+ if (!found) continue;
194
+ for (const asset of found.exact) rows.push({ file, asset, kind: 'res' });
195
+ for (const uid of found.uids) if (uidMap.has(uid)) rows.push({ file, asset: uidMap.get(uid), kind: 'res' });
196
+ for (const asset of found.loose) rows.push({ file, asset, kind: 'loose' });
197
+ }
198
+ return rows;
199
+ }
200
+
201
+ function dedupe(rows) {
202
+ const seen = new Set();
203
+ return rows.filter(row => {
204
+ const key = `${row.kind}:${row.asset}`;
205
+ if (seen.has(key)) return false;
206
+ seen.add(key);
207
+ return true;
208
+ });
209
+ }
210
+
211
+ /**
212
+ * The problem with each reference a change makes, as lines a repair can act on.
213
+ *
214
+ * @returns {Promise<string[]>}
215
+ */
216
+ export async function referenceProblems({ projectDir, rows }) {
217
+ const problems = [];
218
+ for (const { file, asset, kind } of rows) {
219
+ if (kind === 'loose') {
220
+ problems.push(`${file} loads "${asset}", which is not a res:// path. Use the res_path from the manifest.`);
221
+ } else if (asset === '..' || asset.startsWith('../')) {
222
+ problems.push(`${file} names res://${asset}, which is outside the project.`);
223
+ } else if (!await existsExactly(projectDir, asset)) {
224
+ problems.push(`${file} names res://${asset}, and no file has exactly that path. Use a res_path from the manifest, or a placeholder primitive named in the plan's placeholders.`);
225
+ }
226
+ }
227
+ return problems;
228
+ }
229
+
230
+ const NEAREST_IN_SCRIPT = /TEXTURE_FILTER_NEAREST\b|texture_filter\s*=\s*1\b/;
231
+
232
+ /** The import settings that spoil pixel art, from a `.import` file's text. */
233
+ export function importSettingProblems(text) {
234
+ const problems = [];
235
+ const setting = name => {
236
+ const match = new RegExp(`^${name}=(.+)$`, 'm').exec(text);
237
+ return match ? match[1].trim() : null;
238
+ };
239
+ if (setting('mipmaps/generate') === 'true') problems.push('mipmaps are on');
240
+ const mode = setting('compress/mode');
241
+ if (mode !== null && mode !== '0') problems.push(`compress/mode is ${mode}, and pixel art needs 0 (lossless)`);
242
+ const limit = setting('process/size_limit');
243
+ if (limit !== null && limit !== '0') problems.push(`process/size_limit is ${limit}, which resizes the art`);
244
+ return problems;
245
+ }
246
+
247
+ /**
248
+ * The three checks, bound to one project and one inventory. Each result has the
249
+ * shape `createCheckRunner` gives, and goes into its `results`, so the evidence and
250
+ * the changeset carry them beside the others.
251
+ *
252
+ * @param {Object} args
253
+ * @param {string} args.projectDir
254
+ * @param {Object} args.inventory from buildInventory
255
+ * @param {{results: Array}} args.checks the run's `createCheckRunner` surface
256
+ * @param {() => Promise<string[]>} args.changedFiles project relative paths
257
+ * @param {string|null} [args.godotPath]
258
+ * @param {boolean} [args.allowToolScripts]
259
+ * @param {boolean} [args.allowTests]
260
+ * @returns {{run: (name: string) => Promise<Object>, gate: (gate: Object, log?: Function) => Promise<Object>}}
261
+ */
262
+ export function createAssetChecks({
263
+ projectDir,
264
+ inventory,
265
+ checks,
266
+ changedFiles,
267
+ godotPath = null,
268
+ allowToolScripts = false,
269
+ allowTests = false,
270
+ env = process.env,
271
+ spawnFn,
272
+ detect = detectGodotBinary,
273
+ copy = copyForCheck,
274
+ sandbox = undefined,
275
+ platform = process.platform,
276
+ timeoutMs = CHECK_TIMEOUTS_MS.godot_import,
277
+ now = () => Date.now()
278
+ }) {
279
+ let sandboxPath = sandbox;
280
+ const made = (name, started, status, detail, errors = [], output = '') => ({
281
+ name, status, ok: status === 'passed' ? true : (status === 'failed' || status === 'check_refused' ? false : null),
282
+ durationMs: now() - started, errors, detail, output
283
+ });
284
+ const fileOf = target => inventory.files.find(file => file.path === target);
285
+
286
+ async function rowsOf(changed) {
287
+ const uids = await uidsOf(projectDir, inventory.files);
288
+ return dedupe(await namedByChange(projectDir, changed, uids));
289
+ }
290
+
291
+ async function references(started) {
292
+ const rows = await rowsOf(await changedFiles());
293
+ if (rows.length === 0) return made('asset_references', started, 'passed', 'the change names no asset');
294
+ const problems = await referenceProblems({ projectDir, rows });
295
+ return problems.length > 0
296
+ ? made('asset_references', started, 'failed', `${problems.length} asset reference(s) do not resolve`, capped(problems))
297
+ : made('asset_references', started, 'passed', `${rows.length} asset(s) named, all present`);
298
+ }
299
+
300
+ async function importAssets(started) {
301
+ const changed = await changedFiles();
302
+ const rows = (await rowsOf(changed)).filter(row => row.kind === 'res');
303
+ const present = [];
304
+ for (const row of rows) if (await existsExactly(projectDir, row.asset)) present.push(row.asset);
305
+ if (present.length === 0) return made('asset_import', started, 'passed', 'the change names no asset to import');
306
+ const refusal = (allowToolScripts ? null : await toolScriptRefusal(projectDir, changed)) || (allowTests ? null : await testFileRefusal(projectDir, changed));
307
+ if (refusal) return made('asset_import', started, 'check_refused', refusal);
308
+ let binary = null;
309
+ try {
310
+ binary = (await detect({ explicitPath: godotPath || undefined, projectDir, env })).path;
311
+ } catch {
312
+ binary = null;
313
+ }
314
+ if (!binary) return made('asset_import', started, 'unavailable', 'no Godot 4.2+ binary was found; pass --godot-path or set RAVENSIGHT_GODOT');
315
+ let copied;
316
+ try {
317
+ copied = await copy(projectDir);
318
+ } catch (error) {
319
+ return made('asset_import', started, 'failed', `the project could not be copied: ${error.message}`);
320
+ }
321
+ try {
322
+ if (sandboxPath === undefined) sandboxPath = await detectSandbox({ platform });
323
+ const environment = checkEnv({ root: copied.root, env, platform });
324
+ await Promise.all(['HOME', 'TMPDIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'APPDATA', 'LOCALAPPDATA']
325
+ .filter(key => environment[key]).map(key => fs.mkdir(environment[key], { recursive: true })));
326
+ const args = ['--headless', '--path', copied.copy, '--import'];
327
+ const options = { cwd: copied.copy, env: environment, timeoutMs, ...(spawnFn ? { spawnFn } : {}) };
328
+ const real = await fs.realpath(copied.root).catch(() => copied.root);
329
+ const imported = sandboxPath
330
+ ? await runBounded(sandboxPath, ['-p', sandboxProfile([copied.root, real]), binary, ...args], options)
331
+ : await runBounded(binary, args, options);
332
+ if (imported.error) return made('asset_import', started, 'failed', imported.error, [], imported.output);
333
+ const problems = [];
334
+ const log = String(imported.output || '');
335
+ for (const asset of present) {
336
+ const text = await fs.readFile(path.join(copied.copy, `${asset}.import`), 'utf8').catch(() => null);
337
+ if (text === null) {
338
+ problems.push(`Godot did not import ${asset}. Godot has no importer for this file, or it is damaged.`);
339
+ continue;
340
+ }
341
+ if (/^valid=false/m.test(text)) {
342
+ problems.push(`Godot could not import ${asset}.`);
343
+ continue;
344
+ }
345
+ const targets = /^dest_files=\[(.*)\]$/m.exec(text);
346
+ const outputs = targets ? [...targets[1].matchAll(/"res:\/\/([^"]+)"/g)].map(match => match[1]) : [];
347
+ if (outputs.length === 0) {
348
+ problems.push(`Godot produced nothing for ${asset}.`);
349
+ continue;
350
+ }
351
+ for (const output of outputs) {
352
+ if (!await fs.stat(path.join(copied.copy, output)).catch(() => null)) problems.push(`Godot did not finish importing ${asset}.`);
353
+ }
354
+ if (log.split('\n').some(line => /^(ERROR|SCRIPT ERROR)/.test(line.trim()) && line.includes(asset))) problems.push(`Godot reported an error about ${asset}.`);
355
+ }
356
+ const scriptErrors = parseGodotErrors(log);
357
+ return problems.length > 0
358
+ ? made('asset_import', started, 'failed', `${problems.length} asset(s) did not import`, capped(problems), log)
359
+ : made('asset_import', started, 'passed', `${present.length} asset(s) imported${scriptErrors.length ? `; the import also listed ${scriptErrors.length} script error(s), which godot_import reports` : ''}`);
360
+ } finally {
361
+ await fs.rm(copied.root, { recursive: true, force: true }).catch(() => {});
362
+ }
363
+ }
364
+
365
+ async function pixelArt(started) {
366
+ const changed = await changedFiles();
367
+ const rows = (await rowsOf(changed)).filter(row => row.kind === 'res');
368
+ const pixelRows = rows.filter(row => fileOf(row.asset)?.image?.pixel_art);
369
+ if (pixelRows.length === 0) return made('pixel_art_settings', started, 'passed', 'the change names no pixel art');
370
+ const settings = (await readInside(projectDir, 'project.godot')) || '';
371
+ const nearestByDefault = defaultFilter(settings) === 'nearest';
372
+ const problems = [];
373
+ for (const { file, asset } of pixelRows) {
374
+ if (!nearestByDefault) {
375
+ const text = (await readInside(projectDir, file)) || '';
376
+ const source = file.endsWith('.gd') ? withoutComments(text) : text;
377
+ if (!NEAREST_IN_SCRIPT.test(source)) {
378
+ problems.push(`${file} draws ${asset}, which is pixel art, and the project's default filter is smooth, so it blurs. Set texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST on the node that draws it${file.endsWith('.tscn') ? ' (texture_filter = 1 in a scene)' : ''}.`);
379
+ }
380
+ }
381
+ const imported = await readInside(projectDir, `${asset}.import`);
382
+ if (imported !== null) {
383
+ for (const problem of importSettingProblems(imported)) problems.push(`${asset}.import: ${problem}.`);
384
+ }
385
+ }
386
+ return problems.length > 0
387
+ ? made('pixel_art_settings', started, 'failed', `${problems.length} pixel art setting(s) are wrong`, capped(problems))
388
+ : made('pixel_art_settings', started, 'passed', `${pixelRows.length} pixel art asset(s) set up right`);
389
+ }
390
+
391
+ async function run(name) {
392
+ const started = now();
393
+ let result;
394
+ try {
395
+ if (name === 'asset_references') result = await references(started);
396
+ else if (name === 'asset_import') result = await importAssets(started);
397
+ else if (name === 'pixel_art_settings') result = await pixelArt(started);
398
+ else result = made(String(name), started, 'refused', `there is no check called ${name}`);
399
+ } catch (error) {
400
+ result = made(String(name), started, 'failed', `the check broke: ${error.message}`);
401
+ }
402
+ if (checks && Array.isArray(checks.results)) checks.results.push(result);
403
+ return result;
404
+ }
405
+
406
+ /**
407
+ * The asset checks, after the ordinary gate. Answers the same gate with any
408
+ * failures added, and `assetsBlock` when the change breaks the game outright.
409
+ */
410
+ async function gate(ordinary, log = () => {}) {
411
+ const failed = [...ordinary.failed];
412
+ let assetsBlock = false;
413
+ for (const name of ASSET_CHECK_NAMES) {
414
+ // A failed or refused import has already said the project cannot be imported.
415
+ if (name === 'asset_import' && ordinary.importFailed) continue;
416
+ const result = await run(name);
417
+ log(`check ${name}: ${result.status} (${result.detail})`);
418
+ if (result.status === 'failed' || result.status === 'check_refused') {
419
+ failed.push(result);
420
+ if (BLOCKING_ASSET_CHECKS.includes(name)) assetsBlock = true;
421
+ }
422
+ }
423
+ return { ...ordinary, failed, assetsBlock };
424
+ }
425
+
426
+ return { run, gate };
427
+ }
428
+
429
+ export default { createAssetChecks, scanUsage, scanReferences, ASSET_CHECK_NAMES, BLOCKING_ASSET_CHECKS };