import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts"; import { Container } from "$/common/inventory.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { LruMap } from "./lru.ts"; // generation only, rebuilt when needed. 256 KB each const RAW_CACHE_SIZE = 128; const CHUNK_CACHE_SIZE = 256; // how far from the middle of the world to look for dry land to spawn on, in chunks const SPAWN_SEARCH_CHUNKS = 32; // a block with state the server keeps, like a chest's items. never sent to clients as is export interface Tile { id: string; x: number; y: number; z: number; data: Record; containers: Record; // a mod block's data, what BlockRef.data holds mod_data?: unknown; } export function position_key(x: number, y: number, z: number) { return `${x},${y},${z}`; } // the authoritative world: generated terrain plus everything players changed export class ServerWorld { readonly seed: string; readonly block_ids: Record = {}; readonly worldgen: WorldgenSetup | undefined; // what a freshly placed or generated block stores, by numeric id: the id with its default states readonly default_values: number[] = []; // what generation made, and the final blocks with neighbors' leaves and player changes applied #raw = new LruMap(RAW_CACHE_SIZE); #chunks = new LruMap(CHUNK_CACHE_SIZE); // changes from generated terrain, per chunk. these and the tiles are what gets saved #changes = new Map>(); tiles = new Map(); // set when anything that gets saved changes dirty = false; on_block_change?: (x: number, y: number, z: number, id: string, state: number) => void; constructor(seed: string, worldgen?: WorldgenSetup) { this.seed = seed; this.worldgen = worldgen; EverythingRegistry.get_registry("blocks").forEach((block, nid) => { this.block_ids[block.id] = nid; this.default_values[nid] = default_block_value(nid, block); }); } #spawn: { x: number; y: number; z: number } | undefined; // where new players start: the dry land closest to the middle of the world, like minecraft's world spawn spawn_point() { if (!this.#spawn) { this.#spawn = this.#find_spawn(); } return this.#spawn; } #find_spawn() { const water = this.block_ids["bworld:water"]; // chunks in rings around the middle, one column each for (let radius = 0; radius <= SPAWN_SEARCH_CHUNKS; radius++) { for (let cz = -radius; cz <= radius; cz++) { for (let cx = -radius; cx <= radius; cx++) { if (Math.max(Math.abs(cx), Math.abs(cz)) !== radius) continue; const x = cx * CHUNK_SIZE + CHUNK_SIZE / 2; const z = cz * CHUNK_SIZE + CHUNK_SIZE / 2; let y = CHUNK_HEIGHT - 1; while (y > 0 && this.get_block_nid(x, y, z) === AIR) y--; if (y > 0 && this.get_block_nid(x, y, z) !== water) { return { x: x + 0.5, y: y + 1, z: z + 0.5 }; } } } } return { x: 0.5, y: CHUNK_HEIGHT - 1, z: 0.5 }; } get_block_id(x: number, y: number, z: number): string { const nid = this.get_block_nid(x, y, z); return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id("blocks", nid)?.id ?? AIR_ID; } get_block_nid(x: number, y: number, z: number): number { if (y < 0 || y >= CHUNK_HEIGHT) { return AIR; } const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); const blocks = this.#get_chunk(chunk_x, chunk_z); return blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] & ID_MASK; } // the id with its state bits get_block_value(x: number, y: number, z: number): number { if (y < 0 || y >= CHUNK_HEIGHT) { return AIR; } const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); return this.#get_chunk(chunk_x, chunk_z)[index_in_chunk(x, y, z, chunk_x, chunk_z)]; } // whether entities bump into the block there, water and other blocks with "collision": false don't has_collision(x: number, y: number, z: number) { return this.get_block_info(x, y, z)?.has_collision ?? false; } get_block_info(x: number, y: number, z: number): BlockRegistry | undefined { const nid = this.get_block_nid(x, y, z); return nid === AIR ? undefined : EverythingRegistry.get_by_id("blocks", nid); } // only changes the block, the game server runs behaviors and tiles. state bits default to the block's defaults set_block(x: number, y: number, z: number, id: string, state?: number) { if (y < 0 || y >= CHUNK_HEIGHT) { return; } const nid = id === AIR_ID ? AIR : this.block_ids[id]; if (nid === undefined) { throw new Error(`Unknown block ${id}`); } const value = state === undefined ? this.default_values[nid] ?? nid : block_value(nid, state); const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); const blocks = this.#get_chunk(chunk_x, chunk_z); blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = value; const state_bits = value >>> 16; this.#record_change(x, y, z, id, state_bits); this.dirty = true; this.on_block_change?.(x, y, z, id, state_bits); } all_changes(): BlockChange[] { const all: BlockChange[] = []; for (const chunk_changes of this.#changes.values()) { all.push(...chunk_changes.values()); } return all; } load_changes(changes: BlockChange[]) { for (const [x, y, z, id, state] of changes) { this.#record_change(x, y, z, id, state ?? 0); } } get_tile(x: number, y: number, z: number) { return this.tiles.get(position_key(x, y, z)); } add_tile(tile: Tile) { this.tiles.set(position_key(tile.x, tile.y, tile.z), tile); this.dirty = true; } remove_tile(x: number, y: number, z: number) { this.tiles.delete(position_key(x, y, z)); this.dirty = true; } #record_change(x: number, y: number, z: number, id: string, state: number) { const key = chunk_key(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)); let chunk_changes = this.#changes.get(key); if (!chunk_changes) { chunk_changes = new Map(); this.#changes.set(key, chunk_changes); } chunk_changes.set(position_key(x, y, z), state ? [x, y, z, id, state] : [x, y, z, id]); } #get_raw(chunk_x: number, chunk_z: number) { const key = chunk_key(chunk_x, chunk_z); let raw = this.#raw.get(key); if (!raw) { raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids, this.worldgen, this.default_values); this.#raw.set(key, raw); } return raw; } #get_chunk(chunk_x: number, chunk_z: number) { const key = chunk_key(chunk_x, chunk_z); let blocks = this.#chunks.get(key); if (!blocks) { blocks = this.#build_chunk(chunk_x, chunk_z); this.#chunks.set(key, blocks); } return blocks; } // same rules as the client: own blocks, then neighbors' leaves only into air, then player changes #build_chunk(chunk_x: number, chunk_z: number) { const blocks = this.#get_raw(chunk_x, chunk_z).blocks.slice(); for (let dx = -1; dx <= 1; dx++) { for (let dz = -1; dz <= 1; dz++) { if (dx === 0 && dz === 0) { continue; } const spills = this.#get_raw(chunk_x + dx, chunk_z + dz).spills; for (let i = 0; i < spills.length; i += 4) { const [x, y, z, nid] = [spills[i], spills[i + 1], spills[i + 2], spills[i + 3]]; if (Math.floor(x / CHUNK_SIZE) !== chunk_x || Math.floor(z / CHUNK_SIZE) !== chunk_z) { continue; } const index = index_in_chunk(x, y, z, chunk_x, chunk_z); if (blocks[index] === AIR) { blocks[index] = nid; } } } } for (const [x, y, z, id, state] of this.#changes.get(chunk_key(chunk_x, chunk_z))?.values() ?? []) { const nid = id === AIR_ID ? AIR : this.block_ids[id]; if (nid !== undefined) { blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = block_value(nid, state ?? 0); } } return blocks; } } function index_in_chunk(x: number, y: number, z: number, chunk_x: number, chunk_z: number) { return y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); }