// 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: 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 { 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; } if (id === "engine") { error(`"engine" is reserved for the engine itself`); } const scripts = (mod.manifest?.scripts ?? {}) as Record; 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 = ( 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) .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 { 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); }