// deno task check-mods // validates every mod in mods/: manifests, data files, textures, references between them, and typechecks scripts import { BlockJson, FORMAT_VERSION, ItemJson, RecipeJson, validate_block, validate_item, validate_manifest, validate_recipe, } from "$/common/mod_data.ts"; export interface ModReport { id: string; errors: string[]; warnings: string[]; } interface LoadedMod { id: string; dir: string; report: ModReport; manifest?: Record; blocks: { file: string; json: BlockJson }[]; items: { file: string; json: ItemJson }[]; recipes: { file: string; json: RecipeJson }[]; textures: string[]; } // the base game's textures are still built from assets/ until the loader exists (phase 1 in MODS.md), // and the build names all of them bworld: const BASE_TEXTURE_DIR = "assets/sprites/textures"; export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise { 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); 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: [], items: [], recipes: [], textures: [], }; 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("items", "item", validate_item, mod.items); load_data("recipes", "recipe", validate_recipe, mod.recipes); // a mod only registers ids in its own namespace for (const { file, json } of [...mod.blocks, ...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); const size = png_size(`${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; } function check_references(mods: LoadedMod[]) { const block_owners = new Map(); const item_owners = new Map(); const add = (map: Map, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]); const textures = new Set(); for (const file of walk(BASE_TEXTURE_DIR, ".png")) { textures.add(`bworld:${file.replace(/\.png$/, "")}`); } for (const mod of mods) { for (const { json } of mod.blocks) { add(block_owners, json.id, mod.id); if (json.item !== false) add(item_owners, json.id, mod.id); } for (const { json } of mod.items) add(item_owners, json.id, mod.id); for (const texture of mod.textures) textures.add(texture); } const mod_ids = new Set(mods.map((mod) => mod.id)); for (const mod of mods) { const { errors, warnings } = mod.report; const dependencies = new Set( ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((dep) => dep.id), ); for (const dep of dependencies) { if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`); } const uses = (file: string, id: string, what: "block" | "item" | "texture") => { const namespace = id.split(":")[0]; if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) { warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`); } if (what === "texture") { if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`); } else if (!(what === "block" ? block_owners : item_owners).has(id)) { errors.push(`${file}: ${what} ${id} doesn't exist`); } }; for (const { file, json } of mod.blocks) { if ((block_owners.get(json.id)?.length ?? 0) > 1) { errors.push(`${file}: block ${json.id} is also defined by ${block_owners.get(json.id)!.join(", ")}`); } const texture_ids = typeof json.textures === "string" ? [json.textures] : Object.values(json.textures); for (const texture of texture_ids) uses(file, texture, "texture"); if (json.drops) uses(file, json.drops, "item"); } for (const { file, json } of mod.items) { if ((item_owners.get(json.id)?.length ?? 0) > 1) { errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`); } uses(file, json.texture, "texture"); if (json.places) uses(file, json.places, "block"); } for (const { file, json } of mod.recipes) { switch (json.type) { case "shaped": for (const id of Object.values(json.key)) uses(file, id, "item"); uses(file, json.result.id, "item"); break; case "furnace": uses(file, json.input, "item"); uses(file, json.output.id, "item"); break; case "fuel": uses(file, json.item, "item"); break; } } } } 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; const output = await new Deno.Command(Deno.execPath(), { args: ["check", "--config", "deno.json", ...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; } } // reads the size out of the png header instead of decoding the image function png_size(path: string): { width: number; height: number } | undefined { const bytes = Deno.readFileSync(path); const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; if (bytes.length < 24 || !signature.every((b, i) => bytes[i] === b)) return undefined; const view = new DataView(bytes.buffer, bytes.byteOffset); return { width: view.getUint32(16), height: view.getUint32(20) }; } 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); }