Files
bworld/tools/check_mods.ts
T
2026-09-24 23:28:01 -03:00

296 lines
9.5 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 {
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<string, unknown>;
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:<file>
const BASE_TEXTURE_DIR = "assets/sprites/textures";
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
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<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("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<string, string[]>();
const item_owners = new Map<string, string[]>();
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
const textures = new Set<string>();
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<string, string>)
.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<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);
}