Files
bworld/tools/check_mods.ts
T
2026-09-26 17:33:07 -03:00

236 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// deno task check-mods
// validates every mod in mods/: manifests, data files, textures, references between them, and typechecks scripts
import {
FORMAT_VERSION,
OreJson,
validate_block,
validate_item,
validate_manifest,
validate_model,
validate_ore,
validate_recipe,
} from "$/common/mod_data.ts";
import { check_references, load_order, type LoadedMod, type ModReport } from "$/common/mod_check.ts";
import { png_size } from "$/common/bmod.ts";
export { load_order, type LoadedMod, type ModReport };
// the engine's own textures, named engine:<file>
export const ENGINE_TEXTURE_DIR = "assets/sprites/textures";
// engine:missing is drawn by code, the rest are files
export function engine_texture_ids(): string[] {
return [
"engine:missing",
...walk(ENGINE_TEXTURE_DIR, ".png").map((file) => `engine:${file.replace(/\.png$/, "")}`),
];
}
// loads and checks every mod without typechecking, which is slow. the build uses this
export function load_and_check(mods_dir = "mods"): LoadedMod[] {
const mods: LoadedMod[] = [];
for (const entry of safe_read_dir(mods_dir)) {
if (entry.isDirectory && !entry.name.startsWith(".") && !entry.name.startsWith("_")) {
mods.push(load_mod(mods_dir, entry.name));
}
}
check_references(mods, engine_texture_ids());
return mods;
}
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
const mods = load_and_check(mods_dir);
try {
load_order(mods);
} catch (e) {
for (const mod of mods) mod.report.errors.push((e as Error).message);
}
if (options.typecheck) {
for (const mod of mods) {
await typecheck_scripts(mod);
}
}
return mods.map((mod) => mod.report);
}
function load_mod(mods_dir: string, id: string): LoadedMod {
const dir = `${mods_dir}/${id}`;
const mod: LoadedMod = {
id,
dir,
report: { id, errors: [], warnings: [] },
blocks: [],
models: [],
items: [],
recipes: [],
ores: [],
textures: [],
texture_files: new Map(),
};
const error = (message: string) => mod.report.errors.push(message);
const manifest = read_json(`${dir}/manifest.json`, error);
if (manifest !== undefined) {
for (const problem of validate_manifest(manifest, id)) error(`manifest.json: ${problem}`);
mod.manifest = manifest as Record<string, unknown>;
}
if (id === "engine") {
error(`"engine" is reserved for the engine itself`);
}
const scripts = (mod.manifest?.scripts ?? {}) as Record<string, string>;
for (const [side, path] of Object.entries(scripts)) {
if (typeof path === "string" && !exists(`${dir}/${path}`)) {
error(`manifest.json: scripts.${side} ${path} doesn't exist`);
}
}
const credits = mod.manifest?.credits;
if (typeof credits === "string" && !exists(`${dir}/${credits}`)) {
error(`manifest.json: credits ${credits} doesn't exist`);
}
const load_data = <T>(
folder: string,
key: string,
validate: (json: unknown) => string[],
into: { file: string; json: T }[],
) => {
for (const file of walk(`${dir}/${folder}`, ".json")) {
const path = `${folder}/${file}`;
const wrapper = read_json(`${dir}/${path}`, (m) => error(`${path}: ${m}`));
if (wrapper === undefined) continue;
if (!is_object(wrapper) || wrapper.format_version !== FORMAT_VERSION || !(key in wrapper)) {
error(`${path}: must be { "format_version": ${FORMAT_VERSION}, "${key}": { ... } }`);
continue;
}
const problems = validate(wrapper[key]);
for (const problem of problems) error(`${path}: ${problem}`);
if (problems.length === 0) into.push({ file: path, json: wrapper[key] as T });
}
};
load_data("blocks", "block", validate_block, mod.blocks);
load_data("models", "model", validate_model, mod.models);
load_data("items", "item", validate_item, mod.items);
load_data("recipes", "recipe", validate_recipe, mod.recipes);
if (exists(`${dir}/worldgen/ores.json`)) {
const ores = read_json(`${dir}/worldgen/ores.json`, (m) => error(`worldgen/ores.json: ${m}`));
if (!is_object(ores) || ores.format_version !== FORMAT_VERSION || !Array.isArray(ores.ores)) {
error(`worldgen/ores.json: must be { "format_version": ${FORMAT_VERSION}, "ores": [ ... ] }`);
} else {
ores.ores.forEach((ore, i) => {
const problems = validate_ore(ore);
for (const problem of problems) error(`worldgen/ores.json: ore ${i}: ${problem}`);
if (problems.length === 0) mod.ores.push({ file: "worldgen/ores.json", json: ore as OreJson });
});
}
}
// a mod only registers ids in its own namespace
for (const { file, json } of [...mod.blocks, ...mod.models, ...mod.items]) {
if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`);
}
for (const file of walk(`${dir}/textures`, ".png")) {
const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`;
mod.textures.push(texture_id);
mod.texture_files.set(texture_id, `${dir}/textures/${file}`);
const size = png_size(Deno.readFileSync(`${dir}/textures/${file}`));
if (!size) {
error(`textures/${file}: isn't a png`);
} else if (size.width !== 16 || size.height !== 16) {
error(`textures/${file}: is ${size.width}×${size.height}, textures must be 16×16`);
}
}
return mod;
}
async function typecheck_scripts(mod: LoadedMod) {
const scripts = Object.values((mod.manifest?.scripts ?? {}) as Record<string, string>)
.filter((path) => typeof path === "string" && exists(`${mod.dir}/${path}`))
.map((path) => `${mod.dir}/${path}`);
if (scripts.length === 0) return;
// a mod with its own deno.json is its own deno project, see pack_mod.ts
const config = ["deno.json", "deno.jsonc"].map((name) => `${mod.dir}/${name}`).find(exists) ?? "deno.json";
const output = await new Deno.Command(Deno.execPath(), {
args: ["check", "--config", config, ...scripts],
stdout: "piped",
stderr: "piped",
env: { NO_COLOR: "1" },
}).output();
if (!output.success) {
const text = new TextDecoder().decode(output.stderr).trim();
mod.report.errors.push(`scripts don't typecheck:\n${text}`);
}
}
// helpers
function read_json(path: string, error: (message: string) => void): unknown {
let text: string;
try {
text = Deno.readTextFileSync(path);
} catch {
error(`${path.split("/").pop()} is missing`);
return undefined;
}
try {
return JSON.parse(text);
} catch (e) {
error(`isn't valid json: ${(e as Error).message}`);
return undefined;
}
}
// relative paths of files ending in `extension`, in subfolders too
function walk(dir: string, extension: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of safe_read_dir(dir)) {
if (entry.isDirectory) {
files.push(...walk(`${dir}/${entry.name}`, extension, `${prefix}${entry.name}/`));
} else if (entry.name.endsWith(extension)) {
files.push(`${prefix}${entry.name}`);
}
}
return files.sort();
}
function safe_read_dir(dir: string): Deno.DirEntry[] {
try {
return [...Deno.readDirSync(dir)].sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
function exists(path: string) {
try {
Deno.statSync(path);
return true;
} catch {
return false;
}
}
function is_object(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
if (import.meta.main) {
const reports = await check_mods();
let errors = 0;
for (const report of reports) {
const status = report.errors.length ? "✗" : "✓";
console.log(`${status} ${report.id}`);
for (const error of report.errors) console.log(` error: ${error.replaceAll("\n", "\n ")}`);
for (const warning of report.warnings) console.log(` warning: ${warning}`);
errors += report.errors.length;
}
if (reports.length === 0) console.log("No mods in mods/");
Deno.exit(errors ? 1 : 0);
}