351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
// 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, tests use both to check nothing is lost
|
|
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<string, unknown>;
|
|
}
|
|
|
|
export interface OreJson {
|
|
id: string;
|
|
replaces: string;
|
|
min_y: number;
|
|
max_y: number;
|
|
scale: number;
|
|
threshold: number;
|
|
}
|
|
|
|
export interface ItemJson {
|
|
id: string;
|
|
texture: string;
|
|
tool?: string;
|
|
places?: string;
|
|
max_stack?: number;
|
|
lore?: string;
|
|
components?: Record<string, unknown>;
|
|
}
|
|
|
|
export type RecipeJson =
|
|
| {
|
|
type: "shaped";
|
|
pattern: string[];
|
|
key: Record<string, string>;
|
|
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;
|
|
if (block.components) json.components = block.components;
|
|
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;
|
|
if (json.components) block.components = json.components;
|
|
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;
|
|
if (item.max_stack !== undefined) json.max_stack = item.max_stack;
|
|
if (item.lore !== undefined) json.lore = item.lore;
|
|
if (item.components) json.components = item.components;
|
|
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;
|
|
if (json.max_stack !== undefined) item.max_stack = json.max_stack;
|
|
if (json.lore !== undefined) item.lore = json.lore;
|
|
if (json.components) item.components = json.components;
|
|
return item;
|
|
}
|
|
|
|
// shaped recipes <-> the grid format
|
|
|
|
export function grid_recipe_to_json(recipe: GridRecipe): RecipeJson {
|
|
const key: Record<string, string> = {};
|
|
const letters = new Map<string, string>();
|
|
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<RecipeJson, { type: "shaped" }>): 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<string>) {
|
|
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;
|
|
}
|
|
|
|
export function validate_ore(json: unknown): Problems {
|
|
if (!is_object(json)) return ["ore must be an object"];
|
|
const problems = [...validate_id(json.id, "id"), ...validate_id(json.replaces, "replaces")];
|
|
for (const key of ["min_y", "max_y", "scale", "threshold"]) {
|
|
if (typeof json[key] !== "number") problems.push(`${key} must be a number`);
|
|
}
|
|
if (typeof json.min_y === "number" && typeof json.max_y === "number" && json.min_y > json.max_y) {
|
|
problems.push("min_y must not be above max_y");
|
|
}
|
|
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|