Test mod
This commit is contained in:
@@ -6,4 +6,5 @@ EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
|
||||
has_collision: false,
|
||||
transparent: true,
|
||||
alpha: 0.8,
|
||||
replaceable: true,
|
||||
});
|
||||
|
||||
@@ -48,6 +48,12 @@ export class EverythingRegistry {
|
||||
static get_registry<T>(registry: string): T[] {
|
||||
return this.#id_to_value.get(registry) as T[];
|
||||
}
|
||||
|
||||
// [key, value] pairs in registration order
|
||||
static entries<T>(registry: string): [string, T][] {
|
||||
const values = this.#id_to_value.get(registry) ?? [];
|
||||
return [...(this.#key_to_id.get(registry) ?? [])].map(([key, id]) => [key, values[id] as T]);
|
||||
}
|
||||
}
|
||||
|
||||
interface TextureSideTopBottom {
|
||||
@@ -96,6 +102,8 @@ export interface BlockRegistry {
|
||||
// right clicking it does something instead of placing a block, clients don't predict placing against it.
|
||||
// behavior runs on the server, see server/game/blocks.ts
|
||||
interactive?: boolean;
|
||||
// placing a block into it replaces it, like water
|
||||
replaceable?: boolean;
|
||||
|
||||
compiled_states?: CompiledStateDefinition[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// what client scripts get, see "Client scripts" and "GUIs" in MODS.md
|
||||
import type { Id, KeyCode, ModInfo, Position } from "./shared.ts";
|
||||
|
||||
export type { Id, ItemStack, KeyCode, ModInfo, Position } from "./shared.ts";
|
||||
|
||||
export interface ClientContext {
|
||||
mod: ModInfo;
|
||||
ui: ClientUi;
|
||||
hud: HudRegistry;
|
||||
input: { bind(id: Id, default_key: KeyCode, on_press: () => void): void };
|
||||
net: ClientNet;
|
||||
player: { readonly name: string; readonly position: Readonly<Position> };
|
||||
// read only, what this client sees
|
||||
world: { get_block(x: number, y: number, z: number): Id | undefined };
|
||||
log(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export interface ModScreen<Props = unknown> {
|
||||
on_open?(): void;
|
||||
on_tick?(dt: number): void;
|
||||
on_render(g: Graphics): void;
|
||||
// the server sent new props for this screen
|
||||
on_props?(props: Props): void;
|
||||
on_close?(): void;
|
||||
// return true to keep the screen open when escape is pressed
|
||||
on_escape?(): boolean;
|
||||
}
|
||||
|
||||
export type Color = [number, number, number, number];
|
||||
|
||||
// immediate mode drawing, like the debug ui, styled with assets/sprites/ui.png
|
||||
export interface Graphics {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly mouse: { x: number; y: number; down: boolean; pressed: boolean };
|
||||
rect(x: number, y: number, w: number, h: number, color?: Color): void;
|
||||
panel(x: number, y: number, w: number, h: number): void;
|
||||
text(text: string, x: number, y: number, options?: { scale?: number; color?: Color }): void;
|
||||
measure_text(text: string, scale?: number): number;
|
||||
texture(id: Id, x: number, y: number, w: number, h: number): void;
|
||||
item(id: Id, x: number, y: number, count?: number): void;
|
||||
clip(x: number, y: number, w: number, h: number, draw: () => void): void;
|
||||
button(label: string, x: number, y: number, w: number, h: number): boolean;
|
||||
text_input(id: string, x: number, y: number, w: number): string;
|
||||
slider(id: string, x: number, y: number, w: number, min: number, max: number): number;
|
||||
// server synced slots, same rules as container screens
|
||||
slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void;
|
||||
key_pressed(key: KeyCode): boolean;
|
||||
}
|
||||
|
||||
export interface ClientUi {
|
||||
register_screen<Props>(id: Id, create: (props: Props) => ModScreen<Props>): void;
|
||||
// open a screen that doesn't involve the server, like a settings page
|
||||
open<Props>(id: Id, props: Props): void;
|
||||
}
|
||||
|
||||
export interface HudRegistry {
|
||||
register(id: Id, element: { on_render(g: Graphics): void }): void;
|
||||
}
|
||||
|
||||
export interface ClientNet {
|
||||
on<T = unknown>(channel: Id, handler: (data: T) => void): void;
|
||||
send(channel: Id, data: unknown): void;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// what server scripts get, see "Server scripts" in MODS.md
|
||||
import type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
|
||||
|
||||
export type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
|
||||
|
||||
export interface ServerContext {
|
||||
mod: ModInfo;
|
||||
components: ComponentRegistry; // only during setup
|
||||
commands: CommandRegistry; // only during setup
|
||||
events: { before: ServerBeforeEvents; after: ServerAfterEvents };
|
||||
system: System;
|
||||
world: ServerWorld;
|
||||
players: PlayerList;
|
||||
containers: ContainerApi;
|
||||
recipes: RecipeApi;
|
||||
ui: ServerUi;
|
||||
net: ServerNet;
|
||||
storage: ModStorage;
|
||||
log(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
// components
|
||||
|
||||
export interface BlockRef {
|
||||
readonly id: Id;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
// tile data, any json value. saved with the world, never sent to clients
|
||||
// deno-lint-ignore no-explicit-any
|
||||
data: any;
|
||||
}
|
||||
|
||||
// P is the component's params from the block json
|
||||
// deno-lint-ignore no-explicit-any
|
||||
export interface BlockComponent<P = any> {
|
||||
on_create?(block: BlockRef, params: P): void;
|
||||
on_break?(block: BlockRef, params: P, player: Player | undefined): void;
|
||||
on_click?(block: BlockRef, params: P, player: Player): void;
|
||||
// return true if it did something, so no block gets placed
|
||||
on_interact?(block: BlockRef, params: P, player: Player): boolean;
|
||||
on_tick?(block: BlockRef, params: P, dt: number): void;
|
||||
on_second?(block: BlockRef, params: P, dt: number): void;
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
export interface ItemComponent<P = any> {
|
||||
on_create?(item: ItemStack, params: P): void;
|
||||
get_lore?(item: ItemStack, params: P): string;
|
||||
on_use?(item: ItemStack, params: P, player: Player): void;
|
||||
}
|
||||
|
||||
export interface ComponentRegistry {
|
||||
register_block<P>(id: Id, component: BlockComponent<P>): void;
|
||||
register_item<P>(id: Id, component: ItemComponent<P>): void;
|
||||
}
|
||||
|
||||
// commands
|
||||
|
||||
export interface Command {
|
||||
description: string;
|
||||
usage: string;
|
||||
run(args: string[], player: Player): void;
|
||||
}
|
||||
|
||||
export interface CommandRegistry {
|
||||
register(name: string, command: Command): void;
|
||||
}
|
||||
|
||||
// events
|
||||
|
||||
export interface Cancelable {
|
||||
cancel: boolean;
|
||||
}
|
||||
|
||||
export interface BlockBreakEvent {
|
||||
readonly player: Player;
|
||||
readonly block: BlockRef;
|
||||
readonly item: ItemStack | undefined;
|
||||
}
|
||||
|
||||
export interface BlockPlaceEvent {
|
||||
readonly player: Player;
|
||||
readonly block: { readonly id: Id } & Position;
|
||||
readonly face: Face;
|
||||
readonly item: ItemStack | undefined;
|
||||
}
|
||||
|
||||
export interface BlockInteractEvent {
|
||||
readonly player: Player;
|
||||
readonly block: BlockRef;
|
||||
readonly item: ItemStack | undefined;
|
||||
}
|
||||
|
||||
export interface ChatSendEvent {
|
||||
readonly player: Player;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServerBeforeEvents {
|
||||
block_break: EventSignal<BlockBreakEvent & Cancelable>;
|
||||
block_place: EventSignal<BlockPlaceEvent & Cancelable>;
|
||||
block_interact: EventSignal<BlockInteractEvent & Cancelable>;
|
||||
chat_send: EventSignal<ChatSendEvent & Cancelable>;
|
||||
}
|
||||
|
||||
export interface ServerAfterEvents {
|
||||
block_break: EventSignal<BlockBreakEvent>;
|
||||
block_place: EventSignal<BlockPlaceEvent>;
|
||||
block_interact: EventSignal<BlockInteractEvent>;
|
||||
chat_send: EventSignal<Readonly<ChatSendEvent>>;
|
||||
player_join: EventSignal<{ readonly player: Player }>;
|
||||
player_leave: EventSignal<{ readonly player: Player }>;
|
||||
server_start: EventSignal<Record<never, never>>;
|
||||
tick: EventSignal<{ readonly dt: number }>;
|
||||
}
|
||||
|
||||
// world and players
|
||||
|
||||
export interface ServerWorld {
|
||||
get_block(x: number, y: number, z: number): Id | undefined; // undefined when the chunk isn't loaded
|
||||
set_block(x: number, y: number, z: number, id: Id): boolean; // runs on_break / on_create, synced to everyone
|
||||
get_state(x: number, y: number, z: number, name: string): number | undefined;
|
||||
set_state(x: number, y: number, z: number, name: string, value: number): boolean;
|
||||
get_block_data<T>(x: number, y: number, z: number): T | undefined;
|
||||
is_loaded(x: number, z: number): boolean;
|
||||
readonly seed: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly position: Readonly<Position>;
|
||||
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
||||
readonly selected_slot: number;
|
||||
readonly held_item: ItemStack | undefined;
|
||||
give_item(id: Id, count?: number, data?: unknown): void;
|
||||
send_message(text: string): void;
|
||||
teleport(x: number, y: number, z: number): void;
|
||||
}
|
||||
|
||||
export interface PlayerList {
|
||||
all(): Player[];
|
||||
get(id: string): Player | undefined;
|
||||
by_name(name: string): Player | undefined;
|
||||
}
|
||||
|
||||
export interface System {
|
||||
run_timeout(fn: () => void, ticks: number): number;
|
||||
run_interval(fn: () => void, ticks: number): number;
|
||||
clear_run(handle: number): void;
|
||||
readonly current_tick: number;
|
||||
}
|
||||
|
||||
// small key value store per mod, saved with the world
|
||||
export interface ModStorage {
|
||||
get<T>(key: string): T | undefined;
|
||||
set(key: string, value: unknown): void; // json values only
|
||||
delete(key: string): void;
|
||||
}
|
||||
|
||||
// containers and recipes
|
||||
|
||||
export interface Container {
|
||||
readonly id: string;
|
||||
readonly size: number;
|
||||
get(slot: number): ItemStack | undefined;
|
||||
set(slot: number, item: ItemStack | undefined): void;
|
||||
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
|
||||
on_change(fn: (slot: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface ContainerApi {
|
||||
create(size: number): Container; // saved with the world
|
||||
get(id: string): Container | undefined;
|
||||
delete(id: string): void;
|
||||
}
|
||||
|
||||
export interface RecipeApi {
|
||||
furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined;
|
||||
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
|
||||
is_fuel(item: Id): boolean;
|
||||
is_smeltable(item: Id): boolean;
|
||||
}
|
||||
|
||||
// guis
|
||||
|
||||
export interface ActionForm {
|
||||
title: string;
|
||||
body?: string;
|
||||
buttons: { text: string; icon?: Id }[];
|
||||
}
|
||||
|
||||
export interface MessageForm {
|
||||
title: string;
|
||||
body: string;
|
||||
buttons: [string, string];
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| { type: "toggle"; label: string; default?: boolean }
|
||||
| { type: "slider"; label: string; min: number; max: number; step?: number; default?: number }
|
||||
| { type: "dropdown"; label: string; options: string[]; default?: number }
|
||||
| { type: "text"; label: string; placeholder?: string; default?: string; max_length?: number };
|
||||
|
||||
export interface ModalForm {
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: false } & T);
|
||||
|
||||
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
|
||||
|
||||
export interface ContainerScreenOptions {
|
||||
title: string;
|
||||
container: Container;
|
||||
// x and y in slot units
|
||||
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[];
|
||||
player_inventory?: boolean;
|
||||
bars?: { id: string; x: number; y: number; texture: Id }[];
|
||||
labels?: { x: number; y: number; property: string }[];
|
||||
}
|
||||
|
||||
export interface ScreenHandle<Props = unknown> {
|
||||
readonly player: Player;
|
||||
set_property(id: string, value: number): void;
|
||||
update(props: Props): void; // custom screens only
|
||||
close(): void;
|
||||
on_close(fn: () => void): void;
|
||||
}
|
||||
|
||||
export interface ServerUi {
|
||||
message_form(player: Player, form: MessageForm): Promise<FormResult<{ selection: 0 | 1 }>>;
|
||||
action_form(player: Player, form: ActionForm): Promise<FormResult<{ selection: number }>>;
|
||||
modal_form(player: Player, form: ModalForm): Promise<FormResult<{ values: (boolean | number | string)[] }>>;
|
||||
open_container(player: Player, options: ContainerScreenOptions): ScreenHandle;
|
||||
open_screen<Props>(
|
||||
player: Player,
|
||||
id: Id,
|
||||
props: Props,
|
||||
options?: { containers?: Record<string, Container> },
|
||||
): ScreenHandle<Props>;
|
||||
}
|
||||
|
||||
// mod channels
|
||||
|
||||
export interface ServerNet {
|
||||
on<T = unknown>(channel: Id, handler: (player: Player, data: T) => void): void;
|
||||
send(player: Player, channel: Id, data: unknown): void;
|
||||
broadcast(channel: Id, data: unknown): void;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// types every side of a mod shares. these are the api mods see, not the engine's own classes:
|
||||
// items are { id, count, data } here, like on the network
|
||||
|
||||
export type Id = string;
|
||||
|
||||
export interface ItemStack {
|
||||
readonly id: Id;
|
||||
count: number;
|
||||
// any json value, set by server scripts
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ModInfo {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export type Face = "west" | "east" | "bottom" | "top" | "north" | "south";
|
||||
|
||||
// KeyboardEvent.code values, like "KeyE" or "Digit1"
|
||||
export type KeyCode = string;
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface EventSignal<T> {
|
||||
subscribe(handler: (event: T) => void): Unsubscribe;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// what worldgen scripts get, see "World generation" in MODS.md. runs in chunk workers and must be deterministic
|
||||
import type { Id } from "./shared.ts";
|
||||
|
||||
export type { Id } from "./shared.ts";
|
||||
|
||||
export interface WorldgenContext {
|
||||
register_terrain(id: Id, generate: (chunk: TerrainChunk) => void): void;
|
||||
register_feature(id: Id, generate: (chunk: FeatureChunk) => void): void;
|
||||
}
|
||||
|
||||
export interface FeatureChunk {
|
||||
// chunk coordinates
|
||||
readonly x: number;
|
||||
readonly z: number;
|
||||
readonly seed: string;
|
||||
// seeded from the seed, chunk and feature id
|
||||
readonly rng: { next(): number };
|
||||
// create_noise_2d(new Alea(seed + "_" + name)), cached per seed and name
|
||||
noise_2d(name: string): (x: number, z: number) => number;
|
||||
noise_3d(name: string): (x: number, y: number, z: number) => number;
|
||||
// surface height and biome, inside this chunk only
|
||||
height_at(x: number, z: number): number;
|
||||
biome_at(x: number, z: number): Id;
|
||||
get_block(x: number, y: number, z: number): Id | undefined; // inside this chunk only
|
||||
set_block(x: number, y: number, z: number, id: Id): void; // up to one chunk away, like trees
|
||||
}
|
||||
|
||||
export interface TerrainChunk extends FeatureChunk {
|
||||
set_height(x: number, z: number, height: number): void;
|
||||
set_biome(x: number, z: number, biome: Id): void;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// 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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
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<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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user