Files
2026-09-24 23:49:34 -03:00

357 lines
12 KiB
TypeScript
Raw Permalink 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,
OreJson,
RecipeJson,
validate_block,
validate_item,
validate_manifest,
validate_ore,
validate_recipe,
} from "$/common/mod_data.ts";
export interface ModReport {
id: string;
errors: string[];
warnings: string[];
}
export 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 }[];
ores: { file: string; json: OreJson }[];
textures: string[];
// absolute paths by texture id
texture_files: Map<string, string>;
}
// the engine's own textures, named engine:<file>
export const ENGINE_TEXTURE_DIR = "assets/sprites/textures";
// 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);
return mods;
}
// dependencies before the mods that need them, otherwise alphabetical
export function load_order(mods: LoadedMod[]): LoadedMod[] {
const by_id = new Map(mods.map((mod) => [mod.id, mod]));
const ordered: LoadedMod[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (mod: LoadedMod, path: string[]) => {
if (state.get(mod.id) === "done") return;
if (state.get(mod.id) === "visiting") {
throw new Error(`mods depend on each other in a circle: ${[...path, mod.id].join(" -> ")}`);
}
state.set(mod.id, "visiting");
const dependencies = ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((d) => d.id).sort();
for (const dependency of dependencies) {
const other = by_id.get(dependency);
if (other) visit(other, [...path, mod.id]);
}
state.set(mod.id, "done");
ordered.push(mod);
};
for (const mod of [...mods].sort((a, b) => a.id.localeCompare(b.id))) {
visit(mod, []);
}
return ordered;
}
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: [],
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("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.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(`${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(ENGINE_TEXTURE_DIR, ".png")) {
textures.add(`engine:${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;
}
}
for (const { file, json } of mod.ores) {
uses(file, json.id, "block");
uses(file, json.replaces, "block");
}
}
}
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);
}