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.
- package/README.md +249 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +92 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +165 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- 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 };
|