// runs mods' server scripts: builds each one's ServerContext and keeps what they register. // see "Server scripts" in MODS.md. parts not built yet throw when used, saying so import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { ItemStack as EngineItemStack } from "$/common/inventory.ts"; import type { BlockComponent, BlockRef, Command, Container, EventSignal, ItemComponent, ItemStack, Player, ServerAfterEvents, ServerBeforeEvents, ServerContext, } from "$/common/mod_api/server.ts"; import { ModLoadError } from "$/common/mod_loader.ts"; import { AIR_ID } from "$/common/protocol.ts"; import type { GameServer } from "./game_server.ts"; import type { ServerPlayer } from "./player.ts"; // a list of handlers that can't break each other: one throwing is logged under its mod and the rest still run export class Signal { #handlers: { mod: string; handler: (event: T) => void }[] = []; for_mod(mod: string): EventSignal { return { subscribe: (handler) => { const entry = { mod, handler }; this.#handlers.push(entry); return () => { this.#handlers = this.#handlers.filter((h) => h !== entry); }; }, }; } emit(event: T) { for (const { mod, handler } of [...this.#handlers]) { run_guarded(mod, "an event handler", () => handler(event)); } } get empty() { return this.#handlers.length === 0; } } export function run_guarded(mod: string, what: string, fn: () => T): T | undefined { try { return fn(); } catch (e) { console.error(`[${mod}] ${what} threw:`, e); return undefined; } } type Before = { [K in keyof ServerBeforeEvents]: ServerBeforeEvents[K] extends EventSignal ? Signal : never; }; type After = { [K in keyof ServerAfterEvents]: ServerAfterEvents[K] extends EventSignal ? Signal : never }; interface Timer { mod: string; fn: () => void; at: number; every?: number; } export class ModRuntime { block_components = new Map(); item_components = new Map(); commands = new Map(); before: Before = { block_break: new Signal(), block_place: new Signal(), block_interact: new Signal(), chat_send: new Signal(), }; after: After = { block_break: new Signal(), block_place: new Signal(), block_interact: new Signal(), chat_send: new Signal(), player_join: new Signal(), player_leave: new Signal(), server_start: new Signal(), tick: new Signal(), }; // per mod key value storage, saved with the world storage: Record> = {}; #timers = new Map(); #next_timer = 1; #setting_up = true; #players = new WeakMap(); game!: GameServer; // imports each server script and calls its setup, in load order async load_scripts(game: GameServer, scripts: { mod: string; version: string; url: string }[]) { this.game = game; for (const { mod, version, url } of scripts) { const module = await import(url); if (typeof module.setup !== "function") { throw new ModLoadError(mod, "the server script doesn't export a setup function"); } await module.setup(this.#context(mod, version)); } } // after every setup ran: registration closes and every component blocks and items use must exist finish_setup() { this.#setting_up = false; for (const [id, block] of EverythingRegistry.entries<{ components?: Record }>("blocks")) { for (const component of Object.keys(block.components ?? {})) { if (!this.block_components.has(component)) { throw new ModLoadError( id.split(":")[0], `block ${id} uses component ${component}, which nothing registered`, ); } } } for (const [id, item] of EverythingRegistry.entries("items")) { const components = Object.entries(item.components ?? {}); if (components.length === 0) continue; for (const [component_id] of components) { if (!this.item_components.has(component_id)) { throw new ModLoadError( id.split(":")[0], `item ${id} uses component ${component_id}, which nothing registered`, ); } } // items are created all over the engine, hook their creation once here const previous = item.on_create; item.on_create = (stack) => { previous?.(stack); for (const [component_id, params] of components) { const { mod, component } = this.item_components.get(component_id)!; if (component.on_create) { run_guarded( mod, `${component_id} on_create`, () => component.on_create!(item_api(stack), params), ); } } }; } } // components on a block, with their params from its json components_of(block_id: string): { mod: string; id: string; component: BlockComponent; params: unknown }[] { const block = EverythingRegistry.get<{ components?: Record }>("blocks", block_id); return Object.entries(block?.components ?? {}).map(([id, params]) => ({ ...this.block_components.get(id)!, id, params, })); } tick() { const now = this.game.current_tick; for (const [handle, timer] of [...this.#timers]) { if (timer.at > now) continue; if (timer.every) { timer.at = now + timer.every; } else { this.#timers.delete(handle); } run_guarded(timer.mod, "a timer", timer.fn); } if (!this.after.tick.empty) { this.after.tick.emit({ dt: 1 / 20 }); } } player(player: ServerPlayer): Player { let api = this.#players.get(player); if (!api) { api = player_api(this.game, player); this.#players.set(player, api); } return api; } block_ref(x: number, y: number, z: number, id: string): BlockRef { const game = this.game; return { id, x, y, z, get data() { return game.world.get_tile(x, y, z)?.mod_data; }, set data(value) { game.get_or_create_tile(x, y, z).mod_data = value; game.world.dirty = true; }, }; } #context(mod: string, version: string): ServerContext { const setup_only = (what: string) => { if (!this.#setting_up) { throw new Error(`[${mod}] ${what} can only be registered during setup`); } }; const own = (id: string, what: string) => { if (!id.startsWith(`${mod}:`)) { throw new ModLoadError(mod, `${what} ${id} must be in the namespace "${mod}"`); } }; const game = () => this.game; const ctx: ServerContext = { mod: { id: mod, version }, components: { register_block: (id, component) => { setup_only("components"); own(id, "component"); if (this.block_components.has(id)) { throw new ModLoadError(mod, `component ${id} is registered twice`); } this.block_components.set(id, { mod, component: component as BlockComponent }); }, register_item: (id, component) => { setup_only("components"); own(id, "component"); if (this.item_components.has(id)) { throw new ModLoadError(mod, `component ${id} is registered twice`); } this.item_components.set(id, { mod, component: component as ItemComponent }); }, }, commands: { register: (name, command) => { setup_only("commands"); if (!/^[a-z0-9_]+$/.test(name)) { throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`); } if (name === "give") throw new ModLoadError(mod, `/give belongs to the base game`); const existing = this.commands.get(name); if (existing) { throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`); } const entry = { mod, name, command }; this.commands.set(name, entry); this.commands.set(`${mod}:${name}`, entry); }, }, events: { before: map_signals(this.before, mod), after: map_signals(this.after, mod), }, system: { run_timeout: (fn, ticks) => this.#add_timer(mod, fn, ticks), run_interval: (fn, ticks) => this.#add_timer(mod, fn, ticks, Math.max(1, ticks)), clear_run: (handle) => void this.#timers.delete(handle), get current_tick() { return game().current_tick; }, }, world: { get_block: (x, y, z) => game().world.get_block_id(x, y, z), set_block: (x, y, z, id) => { if (id !== AIR_ID && !EverythingRegistry.get("blocks", id)) throw new Error(`unknown block ${id}`); game().set_block(x, y, z, id); return true; }, get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"), set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"), get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never, is_loaded: () => true, get seed() { return game().world.seed; }, }, players: { all: () => game().players().map((p) => this.player(p)), get: (id) => { const p = game().players().find((p) => p.id === id); return p && this.player(p); }, by_name: (name) => { const p = game().players().find((p) => p.name === name); return p && this.player(p); }, }, recipes: { furnace_result: (input) => { const recipe = game().recipes.furnace.get(input); return recipe && { output: { ...recipe.output }, cook_time: recipe.cook_time }; }, fuel_value: (item) => game().recipes.fuel.get(item) ?? 0, is_fuel: (item) => game().recipes.fuel.has(item), is_smeltable: (item) => game().recipes.furnace.has(item), }, containers: not_yet_object(mod, "containers", "step 7 in MODS.md"), ui: not_yet_object(mod, "ui", "step 7 in MODS.md"), net: not_yet_object(mod, "net", "step 8 in MODS.md"), storage: { get: (key) => structuredClone(this.storage[mod]?.[key]) as never, set: (key, value) => { (this.storage[mod] ??= {})[key] = structuredClone(value); game().world.dirty = true; }, delete: (key) => { delete this.storage[mod]?.[key]; game().world.dirty = true; }, }, log: (...args) => console.log(`[${mod}]`, ...args), }; return ctx; } #add_timer(mod: string, fn: () => void, ticks: number, every?: number) { const handle = this.#next_timer++; this.#timers.set(handle, { mod, fn, at: this.game.current_tick + Math.max(0, ticks), every }); return handle; } } // each event's signal, as seen by one mod so errors say which mod threw function map_signals( signals: T, mod: string, ): { [K in keyof T]: ReturnType } { return Object.fromEntries(Object.entries(signals).map(([name, signal]) => [name, signal.for_mod(mod)])) as { [K in keyof T]: ReturnType; }; } function not_yet(mod: string, name: string, why: string) { return () => { throw new Error(`[${mod}] ctx.${name} isn't implemented yet: ${why}`); }; } // every method on it throws, saying what isn't built yet function not_yet_object(mod: string, name: string, where: string): T { return new Proxy({}, { get: (_, prop) => not_yet(mod, `${name}.${String(prop)}`, where), }) as T; } // the engine's item stacks as mods see them: { id, count, data } export function item_api(stack: EngineItemStack): ItemStack { return { get id() { return stack.type_id; }, get count() { return stack.amount; }, set count(value) { stack.amount = value; }, get data() { return stack.data; }, set data(value) { stack.data = value; }, }; } function new_stack(item: ItemStack): EngineItemStack { if (!EverythingRegistry.get("items", item.id)) throw new Error(`unknown item ${item.id}`); const stack = new EngineItemStack(item.id, item.count); if (item.data !== undefined) stack.data = structuredClone(item.data); return stack; } function player_api(game: GameServer, player: ServerPlayer): Player { const inventory: Container = { id: `player:${player.id}`, size: player.inventory.size, get: (slot) => { const stack = player.inventory.get_item(slot); return stack && item_api(stack); }, set: (slot, item) => player.inventory.set_item(slot, item ? new_stack(item) : undefined), add: (item) => { const stack = new_stack(item); const left = player.inventory.add_item(stack); return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined; }, on_change: not_yet("player", "inventory.on_change", "containers are step 7 in MODS.md"), }; return { get id() { return player.id; }, get name() { return player.name; }, get position() { return { x: player.x, y: player.y, z: player.z }; }, inventory, get selected_slot() { return player.selected_slot; }, get held_item() { const stack = player.held_item; return stack && item_api(stack); }, give_item(id, count = 1, data) { if (!EverythingRegistry.get("items", id)) throw new Error(`unknown item ${id}`); game.give(player, id, count, data); }, send_message(text) { game.send_chat(player, String(text)); }, teleport(x, y, z) { game.teleport(player, x, y, z); }, }; }