// the json formats from MODS.md, and converting them to and from the engine's registry entries. // the mod loader uses the from_json direction, tools/export_bworld_mod.ts the other one import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts"; export const FORMAT_VERSION = 1; export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; export const NAMESPACE_PATTERN = /^[a-z0-9_]{1,32}$/; export const RESERVED_NAMESPACES = ["bworld", "engine"]; type BlockTextures = BlockRegistry["textures"]; export interface ManifestJson { format_version: number; id: string; name: string; description?: string; version: string; authors?: string[]; game_version?: string; dependencies?: { id: string; version: string }[]; scripts?: { server?: string; client?: string; worldgen?: string }; credits?: string; } export interface BlockJson { id: string; textures: BlockTextures; transparent?: boolean; alpha?: number; collision?: boolean; mining?: { toughness: number; tool?: string; requires_tool?: boolean }; drops?: string; item?: boolean; interactive?: boolean; replaceable?: boolean; states?: BlockStateDefinition[]; components?: Record; } export interface ItemJson { id: string; texture: string; tool?: string; places?: string; max_stack?: number; lore?: string; components?: Record; } export type RecipeJson = | { type: "shaped"; pattern: string[]; key: Record; result: { id: string; count: number }; } | { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number } | { type: "fuel"; item: string; burn_time: number }; // the crafting grid's format, see server/game/crafting.ts export interface GridRecipe { width: number; height: number; pattern: (string | undefined)[]; result: { id: string; count: number }; } // blocks export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { const json: BlockJson = { id: block.id, textures: block.textures }; if (block.transparent) json.transparent = true; if (block.alpha !== undefined) json.alpha = block.alpha; if (!block.has_collision) json.collision = false; if (block.toughness !== undefined) { json.mining = { toughness: block.toughness }; if (block.tool_to_break !== undefined) json.mining.tool = block.tool_to_break; if (block.requires_tool) json.mining.requires_tool = true; } if (block.drop_table !== undefined) json.drops = block.drop_table; if (!has_item) json.item = false; if (block.interactive) json.interactive = true; if (block.replaceable) json.replaceable = true; if (block.states) json.states = block.states; return json; } export function block_from_json(json: BlockJson): { block: BlockRegistry; has_item: boolean } { const block: BlockRegistry = { id: json.id, textures: json.textures, has_collision: json.collision ?? true, }; if (json.transparent) block.transparent = true; if (json.alpha !== undefined) block.alpha = json.alpha; if (json.mining) { block.toughness = json.mining.toughness; block.requires_tool = json.mining.requires_tool ?? false; if (json.mining.tool !== undefined) block.tool_to_break = json.mining.tool; } if (json.drops !== undefined) block.drop_table = json.drops; if (json.interactive) block.interactive = true; if (json.replaceable) block.replaceable = true; if (json.states) block.states = json.states; return { block, has_item: json.item ?? true }; } // items that aren't the item form of a block export function item_to_json(id: string, item: ItemRegistry): ItemJson { if (typeof item.texture_id !== "string") { throw new Error(`${id} picks its texture with a function, which json can't hold`); } const json: ItemJson = { id, texture: item.texture_id }; if (item.tool_type !== undefined) json.tool = item.tool_type; if (item.block_id !== undefined) json.places = item.block_id; return json; } export function item_from_json(json: ItemJson): ItemRegistry { const item: ItemRegistry = { texture_id: json.texture }; if (json.tool !== undefined) item.tool_type = json.tool; if (json.places !== undefined) item.block_id = json.places; return item; } // shaped recipes <-> the grid format export function grid_recipe_to_json(recipe: GridRecipe): RecipeJson { const key: Record = {}; const letters = new Map(); for (const id of recipe.pattern) { if (id === undefined || letters.has(id)) continue; const letter = pick_letter(id, new Set(letters.values())); letters.set(id, letter); key[letter] = id; } const pattern: string[] = []; for (let y = 0; y < recipe.height; y++) { let row = ""; for (let x = 0; x < recipe.width; x++) { const id = recipe.pattern[y * recipe.width + x]; row += id === undefined ? " " : letters.get(id); } pattern.push(row); } return { type: "shaped", pattern, key, result: recipe.result }; } export function grid_recipe_from_json(json: Extract): GridRecipe { const height = json.pattern.length; const width = Math.max(...json.pattern.map((row) => row.length)); const pattern: (string | undefined)[] = []; for (const row of json.pattern) { for (let x = 0; x < width; x++) { const letter = row[x] ?? " "; pattern.push(letter === " " ? undefined : json.key[letter]); } } return { width, height, pattern, result: json.result }; } // a readable letter for an item in a pattern: its first letter if free, like P for planks function pick_letter(id: string, taken: Set) { const name = id.split(":")[1].toUpperCase(); for (const letter of [...name, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]) { if (/[A-Z]/.test(letter) && !taken.has(letter)) { return letter; } } throw new Error(`Ran out of letters for ${id}`); } // validation. every function returns a list of problems, empty when it's fine type Problems = string[]; export function validate_manifest(json: unknown, folder_name: string): Problems { const problems: Problems = []; if (!is_object(json)) return ["manifest.json must be an object"]; if (json.format_version !== FORMAT_VERSION) { problems.push(`format_version must be ${FORMAT_VERSION}`); } if (typeof json.id !== "string" || !NAMESPACE_PATTERN.test(json.id)) { problems.push("id must be 1-32 characters of a-z, 0-9 and _"); } else if (json.id !== folder_name) { problems.push(`id "${json.id}" must match the folder name "${folder_name}"`); } if (typeof json.name !== "string" || json.name.length === 0) problems.push("name is required"); if (typeof json.version !== "string" || !/^\d+\.\d+\.\d+/.test(json.version)) { problems.push("version must be semver, like 1.0.0"); } if (json.dependencies !== undefined) { if (!Array.isArray(json.dependencies)) { problems.push("dependencies must be a list"); } else { for (const dep of json.dependencies) { if (!is_object(dep) || typeof dep.id !== "string" || typeof dep.version !== "string") { problems.push("each dependency needs an id and a version range"); } } } } if (json.scripts !== undefined) { if (!is_object(json.scripts)) { problems.push("scripts must be an object"); } else { for (const [side, path] of Object.entries(json.scripts)) { if (!["server", "client", "worldgen"].includes(side)) problems.push(`unknown script "${side}"`); if (typeof path !== "string") problems.push(`scripts.${side} must be a path`); } } } return problems; } export function validate_block(json: unknown): Problems { if (!is_object(json)) return ["block must be an object"]; const problems = validate_id(json.id, "id"); const textures = json.textures; const texture_ok = typeof textures === "string" || (is_object(textures) && (["top", "bottom", "side"].every((k) => typeof textures[k] === "string") || ["front", "side"].every((k) => typeof textures[k] === "string"))); if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }"); for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) { if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`); } if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) { problems.push("alpha must be between 0 and 1"); } if (json.mining !== undefined) { if (!is_object(json.mining) || typeof json.mining.toughness !== "number" || json.mining.toughness < 0) { problems.push("mining.toughness must be a number of seconds"); } } if (json.drops !== undefined) problems.push(...validate_id(json.drops, "drops")); if (json.states !== undefined) { const states = json.states; if (!Array.isArray(states)) { problems.push("states must be a list"); } else { const bits = states.reduce((sum, s) => sum + (is_object(s) && typeof s.bits === "number" ? s.bits : 0), 0); if (bits > 16) problems.push(`states use ${bits} bits, the most is 16`); } } return problems; } export function validate_item(json: unknown): Problems { if (!is_object(json)) return ["item must be an object"]; const problems = validate_id(json.id, "id"); problems.push(...validate_id(json.texture, "texture")); if (json.places !== undefined) problems.push(...validate_id(json.places, "places")); if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) { problems.push("max_stack must be a positive whole number"); } return problems; } export function validate_recipe(json: unknown): Problems { if (!is_object(json)) return ["recipe must be an object"]; const problems: Problems = []; switch (json.type) { case "shaped": { const pattern = json.pattern; if ( !Array.isArray(pattern) || pattern.length < 1 || pattern.length > 3 || !pattern.every((row) => typeof row === "string" && row.length >= 1 && row.length <= 3) ) { problems.push("pattern must be 1-3 rows of 1-3 characters"); } else if (is_object(json.key)) { for (const letter of pattern.join("").replaceAll(" ", "")) { if (!(letter in json.key)) problems.push(`pattern uses "${letter}" but key doesn't define it`); } } if (!is_object(json.key)) { problems.push("key must map letters to item ids"); } else { for (const id of Object.values(json.key)) problems.push(...validate_id(id, "key")); } problems.push(...validate_stack(json.result, "result")); break; } case "furnace": problems.push(...validate_id(json.input, "input"), ...validate_stack(json.output, "output")); if (!Number.isInteger(json.cook_time) || (json.cook_time as number) < 1) { problems.push("cook_time must be a positive number of ticks"); } break; case "fuel": problems.push(...validate_id(json.item, "item")); if (!Number.isInteger(json.burn_time) || (json.burn_time as number) < 1) { problems.push("burn_time must be a positive number of ticks"); } break; default: problems.push('type must be "shaped", "furnace" or "fuel"'); } return problems; } function validate_id(value: unknown, field: string): Problems { return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`]; } function validate_stack(value: unknown, field: string): Problems { if (!is_object(value)) return [`${field} must be { id, count }`]; const problems = validate_id(value.id, `${field}.id`); if (!Number.isInteger(value.count) || (value.count as number) < 1) { problems.push(`${field}.count must be a positive whole number`); } return problems; } function is_object(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }