Implement main game as a mod

This commit is contained in:
2026-09-24 23:49:34 -03:00
parent bb42dd662e
commit 6458bc0440
131 changed files with 1810 additions and 786 deletions
+166 -22
View File
@@ -1,6 +1,3 @@
import "$/common/blocks/mod.ts";
import "$/common/items/mod.ts";
import {
AIR,
CHUNK_HEIGHT,
@@ -23,7 +20,10 @@ import {
MAX_NAME_LENGTH,
ServerMessage,
} from "$/common/protocol.ts";
import type { ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { WorldgenSetup } from "$/common/generation.ts";
import { BLOCK_BEHAVIORS } from "./blocks.ts";
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
import { ServerWorld, Tile } from "./world.ts";
@@ -52,8 +52,20 @@ export interface SavedWorld {
z: number;
data: Record<string, unknown>;
containers: Record<string, (ItemData | null)[]>;
// a mod block's data, what BlockRef.data holds
mod_data?: unknown;
}[];
players: Record<string, SavedPlayer>;
// ctx.storage of every mod, by mod id
mod_storage?: Record<string, Record<string, unknown>>;
}
// the loaded mods, see load_mods.ts
export interface GameMods {
listings: ModListing[];
recipes: RecipeBook;
worldgen?: WorldgenSetup;
runtime: ModRuntime;
}
export class GameServer {
@@ -62,13 +74,20 @@ export class GameServer {
#players = new Map<number, ServerPlayer>();
#saved_players: Record<string, SavedPlayer> = {};
#tick = 0;
#mods: GameMods;
mods: ModRuntime;
recipes: RecipeBook;
constructor(host: GameHost, save: string | undefined, default_seed: string) {
constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) {
this.#host = host;
this.#mods = mods;
this.mods = mods.runtime;
this.recipes = mods.recipes;
// version 1 saves only had the seed and block changes
const saved: Partial<SavedWorld> | undefined = save ? JSON.parse(save) : undefined;
this.world = new ServerWorld(saved?.seed ?? default_seed);
this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen);
this.mods.storage = saved?.mod_storage ?? {};
this.world.load_changes(saved?.changes ?? []);
for (const tile of saved?.tiles ?? []) {
const containers: Record<string, Container> = {};
@@ -97,6 +116,7 @@ export class GameServer {
this.#saved_players[player.name] = player.save();
this.world.dirty = true;
this.#players.delete(conn);
this.mods.after.player_leave.emit({ player: this.mods.player(player) });
this.#broadcast({ type: "player_leave", id: player.id });
this.#broadcast({ type: "chat", text: `${player.name} left` });
console.log(`${player.name} left (${this.#players.size} online)`);
@@ -133,20 +153,35 @@ export class GameServer {
for (const tile of [...this.world.tiles.values()]) {
const behavior = BLOCK_BEHAVIORS[tile.id];
if (!behavior?.on_tick && !behavior?.on_second) {
const components = this.mods.components_of(tile.id)
.filter(({ component }) => component.on_tick || component.on_second);
if (!behavior?.on_tick && !behavior?.on_second && components.length === 0) {
continue;
}
if (!this.#near_any_player(tile.x, tile.z)) {
continue;
}
behavior.on_tick?.(this, tile);
behavior?.on_tick?.(this, tile);
if (second) {
behavior.on_second?.(this, tile);
behavior?.on_second?.(this, tile);
}
if (components.length > 0 && tile.mod_data !== undefined) {
const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id);
for (const { mod, id, component, params } of components) {
if (component.on_tick) {
run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA));
}
if (second && component.on_second) {
run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1));
}
}
}
// tile data can change on any tick, saving is cheap enough to not track it exactly
this.world.dirty = true;
}
this.mods.tick();
for (const player of this.#players.values()) {
this.#sync(player);
}
@@ -169,16 +204,60 @@ export class GameServer {
containers: Object.fromEntries(
Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]),
),
mod_data: tile.mod_data,
})),
players: this.#saved_players,
mod_storage: this.mods.storage,
};
this.world.dirty = false;
return JSON.stringify(saved);
}
// used by block behaviors
// used by block behaviors and mods
get current_tick() {
return this.#tick;
}
players(): ServerPlayer[] {
return [...this.#players.values()];
}
give(player: ServerPlayer, id: string, count: number, data?: unknown) {
// split into stacks so max stack sizes are respected
let left = count;
while (left > 0) {
const stack = new ItemStack(id);
stack.amount = Math.min(left, stack.max_amount);
if (data !== undefined) stack.data = structuredClone(data);
left -= stack.amount;
player.give(stack);
}
}
send_chat(player: ServerPlayer, text: string) {
this.#send(player, { type: "chat", text });
}
teleport(player: ServerPlayer, x: number, y: number, z: number) {
Object.assign(player, { x, y, z });
this.#send(player, { type: "teleport", x, y, z });
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
}
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) {
const old_id = this.world.get_block_id(x, y, z);
if (old_id !== AIR_ID) {
const components = this.mods.components_of(old_id).filter(({ component }) => component.on_break);
if (components.length > 0) {
const block = this.mods.block_ref(x, y, z, old_id);
const player_api = player && this.mods.player(player);
for (const { mod, id: component_id, component, params } of components) {
run_guarded(mod, `${component_id} on_break`, () => component.on_break!(block, params, player_api));
}
}
}
const old_tile = this.world.get_tile(x, y, z);
if (old_tile) {
BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player);
@@ -196,6 +275,15 @@ export class GameServer {
if (BLOCK_BEHAVIORS[id]?.create_tile) {
this.get_or_create_tile(x, y, z);
}
if (id !== AIR_ID) {
const block = this.mods.block_ref(x, y, z, id);
for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) {
if (component.on_create) {
run_guarded(mod, `${component_id} on_create`, () => component.on_create!(block, params));
}
}
}
}
get_or_create_tile(x: number, y: number, z: number): Tile {
@@ -230,7 +318,9 @@ export class GameServer {
this.#send(player, {
type: "welcome",
id: player.id,
name: player.name,
seed: this.world.seed,
mods: this.#mods.listings,
players: [...this.#players.values()].map((p) => p.info()),
changes: this.world.all_changes(),
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
@@ -241,6 +331,7 @@ export class GameServer {
this.#broadcast({ type: "player_join", player: player.info() }, player);
this.#broadcast({ type: "chat", text: `${player.name} joined` });
this.mods.after.player_join.emit({ player: this.mods.player(player) });
console.log(`${player.name} joined (${this.#players.size} online)`);
}
@@ -290,6 +381,18 @@ export class GameServer {
return;
}
const event = {
player: this.mods.player(player),
block: this.mods.block_ref(x, y, z, info.id),
item: this.mods.player(player).held_item,
};
const before = { ...event, cancel: false };
this.mods.before.block_break.emit(before);
if (before.cancel) {
this.#correct(player, x, y, z);
return;
}
const held = EverythingRegistry.get<ItemRegistry>("items", player.held_item?.type_id ?? "");
const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break);
@@ -297,6 +400,7 @@ export class GameServer {
if (drops) {
player.give(new ItemStack(info.drop_table!));
}
this.mods.after.block_break.emit(event);
}
#use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) {
@@ -310,7 +414,28 @@ export class GameServer {
return;
}
if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) {
const player_api = this.mods.player(player);
const block = this.mods.block_ref(x, y, z, info.id);
const interact = { player: player_api, block, item: player_api.held_item };
const before_interact = { ...interact, cancel: false };
this.mods.before.block_interact.emit(before_interact);
if (before_interact.cancel) {
this.#correct(player, tx, ty, tz);
return;
}
let handled = BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player) ?? false;
for (const { mod, id: component_id, component, params } of this.mods.components_of(info.id)) {
if (component.on_interact) {
handled = run_guarded(mod, `${component_id} on_interact`, () =>
component.on_interact!(block, params, player_api)) ===
true || handled;
}
}
if (handled) {
this.mods.after.block_interact.emit(interact);
// the client didn't guess a placement for interactive blocks, but it might have for others
this.#correct(player, tx, ty, tz);
return;
}
@@ -322,8 +447,22 @@ export class GameServer {
return;
}
const place = {
player: player_api,
block: { id: held.block_id, x: tx, y: ty, z: tz },
face,
item: player_api.held_item,
};
const before_place = { ...place, cancel: false };
this.mods.before.block_place.emit(before_place);
if (before_place.cancel) {
this.#correct(player, tx, ty, tz);
return;
}
this.set_block(tx, ty, tz, held.block_id, player);
held_slot.amount = held_slot.amount! - 1;
this.mods.after.block_place.emit(place);
}
#click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) {
@@ -350,7 +489,7 @@ export class GameServer {
}
if (key === "crafting") {
update_crafting_result(container);
update_crafting_result(container, this.recipes.shaped);
}
}
@@ -366,8 +505,15 @@ export class GameServer {
this.#command(player, text);
return;
}
console.log(`<${player.name}> ${text}`);
this.#broadcast({ type: "chat", from: player.name, text });
const before = { player: this.mods.player(player), message: text, cancel: false };
this.mods.before.chat_send.emit(before);
const message = String(before.message).slice(0, MAX_CHAT_LENGTH);
if (before.cancel || message.length === 0) {
return;
}
console.log(`<${player.name}> ${message}`);
this.#broadcast({ type: "chat", from: player.name, text: message });
this.mods.after.chat_send.emit({ player: before.player, message });
}
#command(player: ServerPlayer, text: string) {
@@ -391,14 +537,12 @@ export class GameServer {
this.#send(player, { type: "chat", text: `Count must be between 1 and ${MAX_GIVE}` });
return;
}
// split into stacks so max stack sizes are respected
let left = amount;
while (left > 0) {
const stack = new ItemStack(item_id);
stack.amount = Math.min(left, stack.max_amount);
left -= stack.amount;
player.give(stack);
}
this.give(player, item_id, amount);
return;
}
const mod_command = this.mods.commands.get(command);
if (mod_command) {
run_guarded(mod_command.mod, `/${command}`, () => mod_command.command.run(args, this.mods.player(player)));
return;
}
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
@@ -417,7 +561,7 @@ export class GameServer {
player.crafting.set_item(i, undefined);
}
}
update_crafting_result(player.crafting);
update_crafting_result(player.crafting, this.recipes.shaped);
if (player.cursor.item) {
player.give(player.cursor.item);
player.cursor.item = undefined;