import { AIR, CHUNK_HEIGHT, CHUNK_SIZE, FACE_OFFSETS, Faces, faces, PLAYER_EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH, 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, PROTOCOL_VERSION, ServerMessage, } from "$/common/protocol.ts"; import type { AtlasListing, 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 type { GameLoop } from "./game_loop.ts"; import { consume_recipe_items, update_crafting_result } from "./crafting.ts"; import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts"; import { ServerWorld, Tile } from "./world.ts"; import { BLOCK_DROP_PICKUP_DELAY, DESPAWN_TICKS, ITEM_SIZE, ItemEntity, PLAYER_DROP_PICKUP_DELAY, SavedItemEntity, } from "./item_entity.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; // how far past a player's box items get picked up from, sideways and up or down, like minecraft const PICKUP_REACH_XZ = 1; const PICKUP_REACH_Y = 0.5; // items this close (past their own size) merge into one stack const MERGE_DISTANCE = 0.5; // tiles further than this many chunks from every player don't tick const SIMULATION_DISTANCE = 6; const MAX_GIVE = 64 * 36; // how long a client gets between welcome and ready to download and load everything const READY_TIMEOUT_TICKS = 60 * TICKS_PER_SECOND; // 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; // a mod block's data, what BlockRef.data holds mod_data?: unknown; }[]; players: Record; // items on the ground entities?: SavedItemEntity[]; // ctx.storage of every mod, by mod id mod_storage?: Record>; } // the loaded mods, see load_mods.ts export interface GameMods { listings: ModListing[]; atlas: AtlasListing; recipes: RecipeBook; worldgen?: WorldgenSetup; runtime: ModRuntime; } export class GameServer { world: ServerWorld; #host: GameHost; #players = new Map(); // connections that got welcome and are downloading mods, by conn #pending = new Map(); #saved_players: Record = {}; #tick = 0; // items on the ground, by id #entities = new Map(); #next_entity_id = 1; #mods: GameMods; mods: ModRuntime; recipes: RecipeBook; // what runs tick(), for /tps. tests call tick() themselves loop?: GameLoop; 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 | undefined = save ? JSON.parse(save) : undefined; 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 = {}; 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 }); } for (const entity of saved?.entities ?? []) { const item = ItemEntity.load(this.#new_entity_id(), entity); this.#entities.set(item.id, item); } this.#saved_players = saved?.players ?? {}; this.world.dirty = saved?.version !== 2; this.world.on_block_change = (x, y, z, id, state) => this.#broadcast(state ? { type: "set_block", x, y, z, id, state } : { type: "set_block", x, y, z, id }); } // connections on_connect(_conn: number) {} on_disconnect(conn: number) { this.#pending.delete(conn); 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.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)`); } 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) { this.#handshake(conn, message); 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]; 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); if (second) { 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.#tick_items(); this.mods.tick(); this.mods.check_watched_containers(); for (const [conn, pending] of this.#pending) { if (this.#tick - pending.since > READY_TIMEOUT_TICKS) { this.#pending.delete(conn); this.#reject(conn, "Took too long to load the server's mods"); } } 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()]), ), mod_data: tile.mod_data, })), players: this.#saved_players, entities: [...this.#entities.values()].map((entity) => entity.save()), mod_storage: this.mods.storage, }; this.world.dirty = false; return JSON.stringify(saved); } // 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; this.give_stack(player, stack); } } // into the inventory, whatever doesn't fit drops at the player's feet give_stack(player: ServerPlayer, stack: ItemStack) { if (player.inventory.add_item(stack) > 0) { this.spawn_item(player.x, player.y, player.z, stack, PLAYER_DROP_PICKUP_DELAY); } } spawn_item(x: number, y: number, z: number, stack: ItemStack, pickup_delay = 0): ItemEntity { const entity = new ItemEntity(this.#new_entity_id(), stack, x, y, z, pickup_delay); this.#entities.set(entity.id, entity); this.#broadcast({ type: "add_entity", entity: entity.info() }); this.world.dirty = true; return entity; } // like minecraft's Block.popResource: from a bit off the block's middle, flying up and out a little pop_item(x: number, y: number, z: number, stack: ItemStack) { const spread = () => (Math.random() - 0.5) * 0.5; const entity = this.spawn_item( x + 0.5 + spread(), y + 0.5 - ITEM_SIZE / 2 + spread(), z + 0.5 + spread(), stack, BLOCK_DROP_PICKUP_DELAY, ); entity.vx = (Math.random() - 0.5) * 4; entity.vy = 4; entity.vz = (Math.random() - 0.5) * 4; return entity; } // out of the player's face the way they're looking, like minecraft's Player.drop throw_item(player: ServerPlayer, stack: ItemStack) { const entity = this.spawn_item( player.x, player.y + PLAYER_EYE_HEIGHT - 0.3, player.z, stack, PLAYER_DROP_PICKUP_DELAY, ); // minecraft's numbers are per tick const speed = 0.3 * TICKS_PER_SECOND; const cos_pitch = Math.cos(player.pitch); const spread_angle = Math.random() * Math.PI * 2; const spread = Math.random() * 0.02 * TICKS_PER_SECOND; entity.vx = -Math.sin(player.yaw) * cos_pitch * speed + Math.cos(spread_angle) * spread; entity.vy = Math.sin(player.pitch) * speed + (0.1 + (Math.random() - Math.random()) * 0.1) * TICKS_PER_SECOND; entity.vz = -Math.cos(player.yaw) * cos_pitch * speed + Math.sin(spread_angle) * spread; return entity; } item_entities(): ItemEntity[] { return [...this.#entities.values()]; } 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); } // changes a block's state bits without anything else happening, see ServerWorld.get_block_value set_block_state(x: number, y: number, z: number, state: number) { const id = this.world.get_block_id(x, y, z); if (id !== AIR_ID) { this.world.set_block(x, y, z, id, state); } } set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer, state?: number) { 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); 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, state); 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 { 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 // hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md #handshake(conn: number, message: ClientMessage) { if (message.type === "hello") { if (this.#pending.has(conn)) { return; } if (message.protocol !== PROTOCOL_VERSION) { this.#reject( conn, `This server needs a game version with protocol ${PROTOCOL_VERSION}, yours has ${ message.protocol ?? "none" }. Reload the page to update.`, ); return; } this.#pending.set(conn, { name: message.name, since: this.#tick }); this.#host.send( conn, JSON.stringify( { type: "welcome", protocol: PROTOCOL_VERSION, seed: this.world.seed, atlas: this.#mods.atlas, mods: this.#mods.listings, } satisfies ServerMessage, ), ); return; } if (message.type === "ready") { const pending = this.#pending.get(conn); if (!pending) { return; } this.#pending.delete(conn); this.#join(conn, pending.name); } } #reject(conn: number, reason: string) { this.#host.send(conn, JSON.stringify({ type: "rejected", reason } satisfies ServerMessage)); this.#host.close(conn); } #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); } else { Object.assign(player, this.world.spawn_point()); } this.#send(player, { type: "join", id: player.id, name: player.name, players: [...this.#players.values()].map((p) => p.info()), changes: this.world.all_changes(), entities: [...this.#entities.values()].map((entity) => entity.info()), 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` }); this.mods.after.player_join.emit({ player: this.mods.player(player) }); 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 "hit_block": if (is_block_position(message.x, message.y, message.z)) { this.#hit_block(player, message.x, message.y, message.z); } break; case "use_item": this.#use_item(player); 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 "drop_item": this.#drop_item(player, message.container, message.index, message.all === true); 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 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("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) { this.pop_item(x, y, z, new ItemStack(info.drop_table!)); } this.mods.after.block_break.emit(event); } #hit_block(player: ServerPlayer, x: number, y: number, z: number) { const info = this.world.get_block_info(x, y, z); if (!info || !this.#in_reach(player, x, y, z)) { return; } const components = this.mods.components_of(info.id).filter(({ component }) => component.on_click); if (components.length === 0) { return; } const block = this.mods.block_ref(x, y, z, info.id); const player_api = this.mods.player(player); for (const { mod, id, component, params } of components) { run_guarded(mod, `${id} on_click`, () => component.on_click!(block, params, player_api)); } } // the held item's on_use handlers, returns whether it has any #use_item(player: ServerPlayer): boolean { const held = player.held_item; if (!held) { return false; } const components = this.mods.item_components_of(held.type_id).filter(({ component }) => component.on_use); if (components.length === 0) { return false; } const player_api = this.mods.player(player); const item = player_api.held_item!; for (const { mod, id, component, params } of components) { run_guarded(mod, `${id} on_use`, () => component.on_use!(item, params, player_api)); } return true; } #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; } 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; } // an item that does something when used does that instead of being placed if (this.#use_item(player)) { this.#correct(player, tx, ty, tz); 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; } 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); } #drop_item(player: ServerPlayer, key: unknown, index: unknown, all: boolean) { if (!is_int(index)) { return; } const container = this.#get_container(player, key as ContainerKey); // the crafting result isn't an item yet, minecraft crafts it first but that's not supported if ( !container || index < 0 || index >= container.size || (key === "crafting" && index === CRAFTING_RESULT_SLOT) ) { return; } const item = container.get_item(index); if (!item) { return; } const thrown = item.clone(); thrown.amount = all ? item.amount : 1; item.amount -= thrown.amount; container.set_item(index, item.amount > 0 ? item : undefined); if (key === "crafting") { update_crafting_result(container, this.recipes.shaped); } this.throw_item(player, thrown); } #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, this.recipes.shaped); } } #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; } 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) { 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; } this.give(player, item_id, amount); return; } if (command === "tps") { if (!this.loop) { this.#send(player, { type: "chat", text: "The game loop isn't running" }); return; } const { tps, mspt, skipped } = this.loop.stats; this.#send(player, { type: "chat", text: `${tps} ticks per second (aiming for ${TICKS_PER_SECOND}), ${mspt.toFixed(1)} ms per tick, ` + `${skipped} skipped since starting`, }); 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}` }); } #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.crafting.set_item(i, undefined); this.give_stack(player, item); } } update_crafting_result(player.crafting, this.recipes.shaped); if (player.cursor.item) { const item = player.cursor.item; player.cursor.item = undefined; this.give_stack(player, item); } } // items on the ground #new_entity_id() { return `item${this.#next_entity_id++}`; } #remove_item(entity: ItemEntity) { this.#entities.delete(entity.id); this.#broadcast({ type: "remove_entity", id: entity.id }); } // like minecraft's ItemEntity.tick: only near players, falling, merging, despawning, and getting picked up #tick_items() { if (this.#entities.size === 0) { return; } const is_solid = (x: number, y: number, z: number) => this.world.has_collision(x, y, z); const active = [...this.#entities.values()].filter((entity) => this.#near_any_player(entity.x, entity.z)); for (const entity of active) { entity.tick(is_solid); if (entity.age >= DESPAWN_TICKS) { this.#remove_item(entity); } } if (this.#tick % 2 === 0) { this.#merge_items(active.filter((entity) => this.#entities.has(entity.id))); } for (const player of this.#players.values()) { for (const entity of active) { if (this.#entities.has(entity.id) && entity.pickup_delay === 0 && this.#can_pick_up(player, entity)) { this.#pick_up(player, entity); } } } for (const entity of active) { const moved = entity.x !== entity.sent_x || entity.y !== entity.sent_y || entity.z !== entity.sent_z; if (moved && this.#entities.has(entity.id)) { entity.sent_x = entity.x; entity.sent_y = entity.y; entity.sent_z = entity.z; this.#broadcast({ type: "move_entity", id: entity.id, x: entity.x, y: entity.y, z: entity.z }); } } this.world.dirty = true; } // the smaller stack goes into the bigger one, like minecraft's ItemEntity.tryToMerge #merge_items(entities: ItemEntity[]) { for (const a of entities) { for (const b of entities) { if (!this.#entities.has(a.id) || !this.#entities.has(b.id) || !a.can_merge_with(b)) { continue; } const reach = MERGE_DISTANCE + ITEM_SIZE; if (Math.abs(a.x - b.x) > reach || Math.abs(a.z - b.z) > reach || Math.abs(a.y - b.y) > ITEM_SIZE) { continue; } const [into, from] = a.item.amount >= b.item.amount ? [a, b] : [b, a]; const moving = Math.min(from.item.amount, into.item.max_amount - into.item.amount); into.item.amount += moving; from.item.amount -= moving; into.pickup_delay = Math.max(into.pickup_delay, from.pickup_delay); into.age = Math.min(into.age, from.age); this.#broadcast({ type: "set_entity_item", id: into.id, item: into.item.to_data() }); if (from.item.amount === 0) { this.#remove_item(from); } else { this.#broadcast({ type: "set_entity_item", id: from.id, item: from.item.to_data() }); } } } } // the player's box grown by the pickup reach touches the item's box #can_pick_up(player: ServerPlayer, entity: ItemEntity) { const reach_xz = PLAYER_WIDTH / 2 + PICKUP_REACH_XZ + ITEM_SIZE / 2; return Math.abs(player.x - entity.x) <= reach_xz && Math.abs(player.z - entity.z) <= reach_xz && entity.y + ITEM_SIZE >= player.y - PICKUP_REACH_Y && entity.y <= player.y + PLAYER_HEIGHT + PICKUP_REACH_Y; } // as much as fits, the rest stays on the ground #pick_up(player: ServerPlayer, entity: ItemEntity) { const before = entity.item.amount; const left = player.inventory.add_item(entity.item); if (left === before) { return; } if (left === 0) { this.#entities.delete(entity.id); this.#broadcast({ type: "take_entity", id: entity.id, player: player.id }); } else { this.#broadcast({ type: "set_entity_item", id: entity.id, item: entity.item.to_data() }); } } // 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)?.replaceable ?? false); } #in_reach(player: ServerPlayer, x: number, y: number, z: number) { const dx = x + 0.5 - player.x; const dy = y + 0.5 - (player.y + PLAYER_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); } } } } 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; }