504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
// 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 { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
|
import { Container as EngineContainer, ItemStack as EngineItemStack } from "$/common/inventory.ts";
|
|
import { get_state_value, set_state_value } from "$/common/utils.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<T> {
|
|
#handlers: { mod: string; handler: (event: T) => void }[] = [];
|
|
|
|
for_mod(mod: string): EventSignal<T> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// the mod whose code is running right now, for apis shared between mods that need to know who called
|
|
let running_mod = "";
|
|
|
|
export function run_guarded<T>(mod: string, what: string, fn: () => T): T | undefined {
|
|
const previous = running_mod;
|
|
running_mod = mod;
|
|
try {
|
|
return fn();
|
|
} catch (e) {
|
|
console.error(`[${mod}] ${what} threw:`, e);
|
|
return undefined;
|
|
} finally {
|
|
running_mod = previous;
|
|
}
|
|
}
|
|
|
|
type Before = {
|
|
[K in keyof ServerBeforeEvents]: ServerBeforeEvents[K] extends EventSignal<infer T> ? Signal<T> : never;
|
|
};
|
|
type After = { [K in keyof ServerAfterEvents]: ServerAfterEvents[K] extends EventSignal<infer T> ? Signal<T> : never };
|
|
|
|
interface Timer {
|
|
mod: string;
|
|
fn: () => void;
|
|
at: number;
|
|
every?: number;
|
|
}
|
|
|
|
export class ModRuntime {
|
|
block_components = new Map<string, { mod: string; component: BlockComponent }>();
|
|
item_components = new Map<string, { mod: string; component: ItemComponent }>();
|
|
commands = new Map<string, { mod: string; name: string; command: Command }>();
|
|
|
|
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<string, Record<string, unknown>> = {};
|
|
|
|
#timers = new Map<number, Timer>();
|
|
// containers mods listen to with on_change, compared slot by slot every tick
|
|
#watched: { mod: string; container: EngineContainer; last: string[]; fn: (slot: number) => void }[] = [];
|
|
#next_timer = 1;
|
|
#setting_up = true;
|
|
#players = new WeakMap<ServerPlayer, Player>();
|
|
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");
|
|
}
|
|
running_mod = mod;
|
|
try {
|
|
await module.setup(this.#context(mod, version));
|
|
} finally {
|
|
running_mod = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<string, unknown> }>("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<ItemRegistry>("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<string, unknown> }>("blocks", block_id);
|
|
return Object.entries(block?.components ?? {}).map(([id, params]) => ({
|
|
...this.block_components.get(id)!,
|
|
id,
|
|
params,
|
|
}));
|
|
}
|
|
|
|
// components on an item, with their params from its json
|
|
item_components_of(item_id: string): { mod: string; id: string; component: ItemComponent; params: unknown }[] {
|
|
const item = EverythingRegistry.get<ItemRegistry>("items", item_id);
|
|
return Object.entries(item?.components ?? {}).map(([id, params]) => ({
|
|
...this.item_components.get(id)!,
|
|
id,
|
|
params,
|
|
}));
|
|
}
|
|
|
|
watch_container(mod: string, container: EngineContainer, fn: (slot: number) => void) {
|
|
const entry = { mod, container, fn, last: snapshot(container) };
|
|
this.#watched.push(entry);
|
|
return () => {
|
|
this.#watched = this.#watched.filter((w) => w !== entry);
|
|
};
|
|
}
|
|
|
|
// items change in many places (clicks, drops, crafting, scripts), so compare instead of hooking every one
|
|
check_watched_containers() {
|
|
for (const watch of [...this.#watched]) {
|
|
const now = snapshot(watch.container);
|
|
const changed = now.flatMap((slot, i) => slot !== watch.last[i] ? [i] : []);
|
|
watch.last = now;
|
|
for (const slot of changed) {
|
|
run_guarded(watch.mod, "a container on_change", () => watch.fn(slot));
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (["give", "tps"].includes(name)) throw new ModLoadError(mod, `/${name} belongs to the engine`);
|
|
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: (x, y, z, name) => {
|
|
const id = game().world.get_block_id(x, y, z);
|
|
if (id === AIR_ID) return undefined;
|
|
const info = state_owner(id, name);
|
|
return get_state_value(game().world.get_block_value(x, y, z), info, name);
|
|
},
|
|
set_state: (x, y, z, name, value) => {
|
|
const id = game().world.get_block_id(x, y, z);
|
|
if (id === AIR_ID) return false;
|
|
const info = state_owner(id, name);
|
|
const bits = info.states!.find((s) => s.name === name)!.bits;
|
|
if (!Number.isInteger(value) || value < 0 || value >= 2 ** bits) {
|
|
throw new Error(`${id} state "${name}" has ${bits} bits, ${value} doesn't fit`);
|
|
}
|
|
const new_value = set_state_value(game().world.get_block_value(x, y, z), info, name, value)!;
|
|
game().set_block_state(x, y, z, new_value >>> 16);
|
|
return true;
|
|
},
|
|
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
|
|
// a block that has this state, or an error saying it doesn't
|
|
function state_owner(id: string, name: string): BlockRegistry {
|
|
const info = EverythingRegistry.get<BlockRegistry>("blocks", id)!;
|
|
if (!info.states?.some((s) => s.name === name)) {
|
|
throw new Error(`${id} has no state "${name}"`);
|
|
}
|
|
return info;
|
|
}
|
|
|
|
function snapshot(container: EngineContainer): string[] {
|
|
return container.to_data().map((item) => JSON.stringify(item));
|
|
}
|
|
|
|
function map_signals<T extends { [name: string]: { for_mod(mod: string): unknown } }>(
|
|
signals: T,
|
|
mod: string,
|
|
): { [K in keyof T]: ReturnType<T[K]["for_mod"]> } {
|
|
return Object.fromEntries(Object.entries(signals).map(([name, signal]) => [name, signal.for_mod(mod)])) as {
|
|
[K in keyof T]: ReturnType<T[K]["for_mod"]>;
|
|
};
|
|
}
|
|
|
|
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<T>(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: (fn) => game.mods.watch_container(running_mod || "unknown", player.inventory, fn),
|
|
};
|
|
|
|
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);
|
|
},
|
|
};
|
|
}
|