import "$/common/blocks/mod.ts"; import "$/common/items/mod.ts"; import { AIR, CHUNK_HEIGHT, CHUNK_SIZE, FACE_OFFSETS, Faces, faces, TICK_DELTA, TICKS_PER_SECOND, } from "$/common/constants.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts"; import { AIR_ID, BlockChange, ClientMessage, ContainerKey, CRAFTING_RESULT_SLOT, MAX_CHAT_LENGTH, MAX_NAME_LENGTH, ServerMessage, } from "$/common/protocol.ts"; import { BLOCK_BEHAVIORS } from "./blocks.ts"; import { consume_recipe_items, update_crafting_result } from "./crafting.ts"; import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts"; import { ServerWorld, Tile } from "./world.ts"; // how far from a player's eyes a block can be changed, a bit more than the client's reach const MAX_REACH = 8; const EYE_HEIGHT = 1.69; // tiles further than this many chunks from every player don't tick const SIMULATION_DISTANCE = 6; const MAX_GIVE = 64 * 36; // everything the game server needs from whatever runs it export interface GameHost { send(conn: number, data: string): void; close(conn: number): void; } export interface SavedWorld { version: 2; seed: string; changes: BlockChange[]; tiles: { id: string; x: number; y: number; z: number; data: Record; containers: Record; }[]; players: Record; } export class GameServer { world: ServerWorld; #host: GameHost; #players = new Map(); #saved_players: Record = {}; #tick = 0; constructor(host: GameHost, save: string | undefined, default_seed: string) { this.#host = host; // version 1 saves only had the seed and block changes const saved: Partial | undefined = save ? JSON.parse(save) : undefined; this.world = new ServerWorld(saved?.seed ?? default_seed); this.world.load_changes(saved?.changes ?? []); for (const tile of saved?.tiles ?? []) { const containers: Record = {}; for (const [name, items] of Object.entries(tile.containers)) { containers[name] = new Container(items.length); containers[name].load(items); } this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers }); } this.#saved_players = saved?.players ?? {}; this.world.dirty = saved?.version !== 2; this.world.on_block_change = (x, y, z, id) => this.#broadcast({ type: "set_block", x, y, z, id }); } // connections on_connect(_conn: number) {} on_disconnect(conn: number) { const player = this.#players.get(conn); if (!player) { return; } this.#close_screen(player); this.#saved_players[player.name] = player.save(); this.world.dirty = true; this.#players.delete(conn); 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)`); } on_message(conn: number, data: string) { let message: ClientMessage; try { message = JSON.parse(data); } catch { return; } if (typeof message !== "object" || message === null) { return; } const player = this.#players.get(conn); if (!player) { if (message.type === "hello") { this.#join(conn, message.name); } return; } this.#handle(player, message); this.#sync(player); } // game loop, the host calls this TICKS_PER_SECOND times a second tick() { this.#tick += 1; const second = this.#tick % TICKS_PER_SECOND === 0; for (const tile of [...this.world.tiles.values()]) { const behavior = BLOCK_BEHAVIORS[tile.id]; if (!behavior?.on_tick && !behavior?.on_second) { continue; } if (!this.#near_any_player(tile.x, tile.z)) { continue; } behavior.on_tick?.(this, tile); if (second) { behavior.on_second?.(this, tile); } // tile data can change on any tick, saving is cheap enough to not track it exactly this.world.dirty = true; } for (const player of this.#players.values()) { this.#sync(player); } } save(): string { for (const player of this.#players.values()) { this.#saved_players[player.name] = player.save(); } const saved: SavedWorld = { version: 2, seed: this.world.seed, changes: this.world.all_changes(), tiles: [...this.world.tiles.values()].map((tile) => ({ id: tile.id, x: tile.x, y: tile.y, z: tile.z, data: tile.data, containers: Object.fromEntries( Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]), ), })), players: this.#saved_players, }; this.world.dirty = false; return JSON.stringify(saved); } // used by block behaviors set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) { const old_tile = this.world.get_tile(x, y, z); if (old_tile) { BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player); this.world.remove_tile(x, y, z); for (const other of this.#players.values()) { if (other.screen?.tile === old_tile) { this.#close_screen(other); this.#send(other, { type: "close_screen" }); } } } this.world.set_block(x, y, z, id); if (BLOCK_BEHAVIORS[id]?.create_tile) { this.get_or_create_tile(x, y, z); } } get_or_create_tile(x: number, y: number, z: number): Tile { let tile = this.world.get_tile(x, y, z); if (!tile) { // blocks placed before tiles were saved have none yet const id = this.world.get_block_id(x, y, z); tile = { id, x, y, z, data: {}, containers: {} }; BLOCK_BEHAVIORS[id]?.create_tile?.(tile); this.world.add_tile(tile); } return tile; } open_screen(player: ServerPlayer, screen: OpenScreen) { this.#close_screen(player); player.screen = screen; this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() }); player.sent.set("properties", JSON.stringify(screen.properties())); player.sent.delete("screen"); } // messages #join(conn: number, raw_name: unknown) { const player = new ServerPlayer(conn, this.#unique_name(raw_name)); const saved = this.#saved_players[player.name]; if (saved) { player.load(saved); } this.#send(player, { type: "welcome", id: player.id, seed: this.world.seed, 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 }, selected_slot: player.selected_slot, }); this.#players.set(conn, player); this.#sync(player); this.#broadcast({ type: "player_join", player: player.info() }, player); this.#broadcast({ type: "chat", text: `${player.name} joined` }); console.log(`${player.name} joined (${this.#players.size} online)`); } #handle(player: ServerPlayer, message: ClientMessage) { switch (message.type) { case "move": { const { x, y, z, yaw, pitch } = message; if (![x, y, z, yaw, pitch].every(is_number)) { return; } Object.assign(player, { x, y, z, yaw, pitch }); this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, player); break; } case "break_block": if (is_block_position(message.x, message.y, message.z)) { this.#break_block(player, message.x, message.y, message.z); } break; case "use_block": if (is_block_position(message.x, message.y, message.z) && faces.includes(message.face)) { this.#use_block(player, message.x, message.y, message.z, message.face); } break; case "select_slot": if (is_int(message.slot) && message.slot >= 0 && message.slot < 9) { player.selected_slot = message.slot; } break; case "click": this.#click(player, message.container, message.index, message.button); break; case "close_screen": this.#close_screen(player); break; case "chat": this.#chat(player, message.text); break; } } #break_block(player: ServerPlayer, x: number, y: number, z: number) { const info = this.world.get_block_info(x, y, z); // air, or something like water that can't be broken if (!info || info.toughness === undefined || !this.#in_reach(player, x, y, z)) { this.#correct(player, x, y, z); return; } const held = EverythingRegistry.get("items", player.held_item?.type_id ?? ""); const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break); this.set_block(x, y, z, AIR_ID, player); if (drops) { player.give(new ItemStack(info.drop_table!)); } } #use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) { const offset = FACE_OFFSETS[face]; const [tx, ty, tz] = [x + offset.x, y + offset.y, z + offset.z]; const info = this.world.get_block_info(x, y, z); if (!info || !this.#in_reach(player, x, y, z)) { this.#correct(player, x, y, z); this.#correct(player, tx, ty, tz); return; } if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) { return; } // place the held block against the face const held_slot = player.inventory.get_slot(player.selected_slot); const held = EverythingRegistry.get("items", held_slot.type_id ?? ""); if (!held?.block_id || ty < 0 || ty >= CHUNK_HEIGHT || !this.#replaceable(tx, ty, tz)) { this.#correct(player, tx, ty, tz); return; } this.set_block(tx, ty, tz, held.block_id, player); held_slot.amount = held_slot.amount! - 1; } #click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) { if (!is_int(index) || !is_int(button)) { return; } const container = this.#get_container(player, key as ContainerKey); if (!container || index < 0 || index >= container.size) { return; } if (key === "crafting" && index === CRAFTING_RESULT_SLOT) { const result = container.get_item(CRAFTING_RESULT_SLOT); if (result && take_output(result, player.cursor)) { consume_recipe_items(container); } } else if (key === "screen" && player.screen?.layout.slots.find((s) => s.index === index)?.output) { const item = container.get_item(index); if (item && take_output(item, player.cursor)) { container.set_item(index, undefined); } } else { click_slot(container, index, player.cursor, button); } if (key === "crafting") { update_crafting_result(container); } } #chat(player: ServerPlayer, raw: unknown) { if (typeof raw !== "string") { return; } const text = raw.trim().slice(0, MAX_CHAT_LENGTH); if (text.length === 0) { return; } if (text.startsWith("/")) { this.#command(player, text); return; } console.log(`<${player.name}> ${text}`); this.#broadcast({ type: "chat", from: player.name, text }); } #command(player: ServerPlayer, text: string) { const [command, ...args] = text.slice(1).split(/\s+/); if (command === "give") { // TODO: only let operators do this let [item_id, count] = args; if (!item_id) { this.#send(player, { type: "chat", text: "Usage: /give [count]" }); return; } if (!item_id.includes(":")) { item_id = `bworld:${item_id}`; } const amount = count === undefined ? 1 : Number(count); if (!EverythingRegistry.get("items", item_id)) { this.#send(player, { type: "chat", text: `Unknown item ${item_id}` }); return; } if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GIVE) { 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); } return; } this.#send(player, { type: "chat", text: `Unknown command /${command}` }); } #close_screen(player: ServerPlayer) { player.screen = undefined; player.sent.delete("screen"); player.sent.delete("properties"); // the crafting grid and whatever the cursor holds go back into the inventory for (let i = 0; i < 9; i++) { const item = player.crafting.get_item(i); if (item) { player.give(item); player.crafting.set_item(i, undefined); } } update_crafting_result(player.crafting); if (player.cursor.item) { player.give(player.cursor.item); player.cursor.item = undefined; } } // helpers #get_container(player: ServerPlayer, key: ContainerKey): Container | undefined { switch (key) { case "inventory": return player.inventory; case "crafting": return player.crafting; case "screen": return player.screen?.container; } } // sends whatever changed since last time #sync(player: ServerPlayer) { const sync = (key: string, value: unknown, message: () => ServerMessage) => { const json = JSON.stringify(value); if (player.sent.get(key) !== json) { player.sent.set(key, json); this.#send(player, message()); } }; const inventory = player.inventory.to_data(); sync("inventory", inventory, () => ({ type: "container", container: "inventory", items: inventory })); const crafting = player.crafting.to_data(); sync("crafting", crafting, () => ({ type: "container", container: "crafting", items: crafting })); const cursor = player.cursor.item?.to_data() ?? null; sync("cursor", cursor, () => ({ type: "cursor", item: cursor })); if (player.screen) { const items = player.screen.container.to_data(); sync("screen", items, () => ({ type: "container", container: "screen", items })); const properties = player.screen.properties(); sync("properties", properties, () => ({ type: "screen_properties", properties })); } } // tell the player what's really at a position, undoing anything their client guessed #correct(player: ServerPlayer, x: number, y: number, z: number) { if (y < 0 || y >= CHUNK_HEIGHT) { return; } this.#send(player, { type: "set_block", x, y, z, id: this.world.get_block_id(x, y, z) }); } #replaceable(x: number, y: number, z: number) { const nid = this.world.get_block_nid(x, y, z); return nid === AIR || EverythingRegistry.get_by_id("blocks", nid)?.id === "bworld:water"; } #in_reach(player: ServerPlayer, x: number, y: number, z: number) { const dx = x + 0.5 - player.x; const dy = y + 0.5 - (player.y + EYE_HEIGHT); const dz = z + 0.5 - player.z; return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH; } #near_any_player(x: number, z: number) { const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); for (const player of this.#players.values()) { const px = Math.floor(player.x / CHUNK_SIZE); const pz = Math.floor(player.z / CHUNK_SIZE); if (Math.max(Math.abs(px - chunk_x), Math.abs(pz - chunk_z)) <= SIMULATION_DISTANCE) { return true; } } return false; } #unique_name(name: unknown): string { const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : ""; const base = cleaned || `player${Math.floor(Math.random() * 10000)}`; const taken = new Set([...this.#players.values()].map((p) => p.name)); let final = base; let i = 2; while (taken.has(final)) { final = `${base}${i}`; i += 1; } return final; } #send(player: ServerPlayer, message: ServerMessage) { this.#host.send(player.conn, JSON.stringify(message)); } #broadcast(message: ServerMessage, except?: ServerPlayer) { const data = JSON.stringify(message); for (const player of this.#players.values()) { if (player !== except) { this.#host.send(player.conn, data); } } } } // ticks happen at a fixed rate export const TICK_MS = TICK_DELTA * 1000; function is_number(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function is_int(value: unknown): value is number { return Number.isInteger(value); } // messages are parsed json, so the types in ClientMessage are only what a well behaved client sends function is_block_position(x: unknown, y: unknown, z: unknown): boolean { return is_int(x) && is_int(y) && is_int(z) && y >= 0 && y < CHUNK_HEIGHT && Math.abs(x) < 30000 * CHUNK_SIZE && Math.abs(z) < 30000 * CHUNK_SIZE; }