Test mod
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// deno task export-bworld
|
||||
// writes the base game's blocks, items and recipes into mods/bworld as json, from what the game registers now.
|
||||
// the game still loads the typescript definitions until the mod loader exists (phase 1 in MODS.md),
|
||||
// so run this again after changing them. tests/bworld_mod_test.ts fails when the two drift apart
|
||||
import "$/common/blocks/mod.ts";
|
||||
import "$/common/items/mod.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { block_to_json, grid_recipe_to_json, item_to_json, RecipeJson } from "$/common/mod_data.ts";
|
||||
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
|
||||
import { FUEL_VALUES, FURNACE_RECIPES } from "$/server/game/blocks.ts";
|
||||
|
||||
const MOD_DIR = "mods/bworld";
|
||||
const DATA_FOLDERS = ["blocks", "items", "recipes"];
|
||||
|
||||
function name_of(id: string) {
|
||||
return id.split(":")[1];
|
||||
}
|
||||
|
||||
// everything the base game registers, as the json files mods/bworld should contain
|
||||
export function bworld_data_files(): { path: string; content: unknown }[] {
|
||||
const files: { path: string; content: unknown }[] = [];
|
||||
const add = (folder: string, name: string, key: string, content: unknown) =>
|
||||
files.push({ path: `${folder}/${name}.json`, content: { format_version: 1, [key]: content } });
|
||||
|
||||
const items = new Map(EverythingRegistry.entries<ItemRegistry>("items"));
|
||||
|
||||
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("blocks")) {
|
||||
const has_item = items.get(id)?.block_id === id;
|
||||
add("blocks", name_of(id), "block", block_to_json(block, has_item));
|
||||
}
|
||||
|
||||
for (const [id, item] of items) {
|
||||
// block items come from their block's "item" field
|
||||
if (item.block_id === undefined) {
|
||||
add("items", name_of(id), "item", item_to_json(id, item));
|
||||
}
|
||||
}
|
||||
|
||||
for (const recipe of CRAFTING_RECIPES) {
|
||||
add("recipes", `crafting_${name_of(recipe.result.id)}`, "recipe", grid_recipe_to_json(recipe));
|
||||
}
|
||||
for (const recipe of FURNACE_RECIPES) {
|
||||
add(
|
||||
"recipes",
|
||||
`smelting_${name_of(recipe.input)}`,
|
||||
"recipe",
|
||||
{
|
||||
type: "furnace",
|
||||
input: recipe.input,
|
||||
output: { id: recipe.output.type_id, count: recipe.output.amount },
|
||||
cook_time: recipe.cook_time,
|
||||
} satisfies RecipeJson,
|
||||
);
|
||||
}
|
||||
for (const [item, burn_time] of Object.entries(FUEL_VALUES)) {
|
||||
add("recipes", `fuel_${name_of(item)}`, "recipe", { type: "fuel", item, burn_time } satisfies RecipeJson);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
for (const folder of DATA_FOLDERS) {
|
||||
try {
|
||||
Deno.removeSync(`${MOD_DIR}/${folder}`, { recursive: true });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
||||
}
|
||||
Deno.mkdirSync(`${MOD_DIR}/${folder}`, { recursive: true });
|
||||
}
|
||||
|
||||
const files = bworld_data_files();
|
||||
for (const { path, content } of files) {
|
||||
Deno.writeTextFileSync(`${MOD_DIR}/${path}`, JSON.stringify(content, null, "\t") + "\n");
|
||||
}
|
||||
|
||||
// match the repo's formatting so reruns don't show up as changes
|
||||
await new Deno.Command(Deno.execPath(), { args: ["fmt", "--quiet", ...DATA_FOLDERS.map((f) => `${MOD_DIR}/${f}`)] })
|
||||
.output();
|
||||
|
||||
const count = (folder: string) => files.filter((f) => f.path.startsWith(`${folder}/`)).length;
|
||||
console.log(
|
||||
`Wrote ${count("blocks")} blocks, ${count("items")} items and ${count("recipes")} recipes to ${MOD_DIR}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// deno task new-mod <id> ["Display Name"]
|
||||
// copies templates/mod into mods/<id>, with the template's placeholder id and name replaced
|
||||
import { NAMESPACE_PATTERN, RESERVED_NAMESPACES } from "$/common/mod_data.ts";
|
||||
|
||||
const TEMPLATE = "templates/mod";
|
||||
const PLACEHOLDER_ID = "example_mod";
|
||||
const PLACEHOLDER_NAME = "Example Mod";
|
||||
const TEXT_FILES = /\.(json|md|ts|js)$/;
|
||||
|
||||
export function create_mod(id: string, name: string, mods_dir = "mods") {
|
||||
if (!NAMESPACE_PATTERN.test(id)) {
|
||||
throw new Error(`"${id}" isn't a valid mod id: use 1-32 characters of a-z, 0-9 and _`);
|
||||
}
|
||||
if (RESERVED_NAMESPACES.includes(id)) {
|
||||
throw new Error(`"${id}" is reserved`);
|
||||
}
|
||||
const target = `${mods_dir}/${id}`;
|
||||
try {
|
||||
Deno.statSync(target);
|
||||
throw new Error(`${target} already exists`);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
||||
}
|
||||
copy_dir(TEMPLATE, target, (text) => text.replaceAll(PLACEHOLDER_ID, id).replaceAll(PLACEHOLDER_NAME, name));
|
||||
return target;
|
||||
}
|
||||
|
||||
function copy_dir(from: string, to: string, transform: (text: string) => string) {
|
||||
Deno.mkdirSync(to, { recursive: true });
|
||||
for (const entry of Deno.readDirSync(from)) {
|
||||
const source = `${from}/${entry.name}`;
|
||||
const destination = `${to}/${entry.name}`;
|
||||
if (entry.isDirectory) {
|
||||
copy_dir(source, destination, transform);
|
||||
} else if (TEXT_FILES.test(entry.name)) {
|
||||
Deno.writeTextFileSync(destination, transform(Deno.readTextFileSync(source)));
|
||||
} else {
|
||||
Deno.copyFileSync(source, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [id, name] = Deno.args;
|
||||
if (!id) {
|
||||
console.error('Usage: deno task new-mod <id> ["Display Name"]');
|
||||
Deno.exit(1);
|
||||
}
|
||||
const display_name = name ?? id.split("_").map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
|
||||
try {
|
||||
const path = create_mod(id, display_name);
|
||||
console.log(`Created ${path}. Edit its manifest.json, then run deno task check-mods.`);
|
||||
console.log("Note: the game can't load mods yet, see the implementation plan in MODS.md.");
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user