Server authority

This commit is contained in:
2026-09-24 19:34:07 -03:00
parent 1bef94ce0c
commit 79faa556de
75 changed files with 2247 additions and 1632 deletions
+541
View File
@@ -0,0 +1,541 @@
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<string, unknown>;
containers: Record<string, (ItemData | null)[]>;
}[];
players: Record<string, SavedPlayer>;
}
export class GameServer {
world: ServerWorld;
#host: GameHost;
#players = new Map<number, ServerPlayer>();
#saved_players: Record<string, SavedPlayer> = {};
#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<SavedWorld> | 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<string, Container> = {};
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<ItemRegistry>("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<ItemRegistry>("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 <item> [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<BlockRegistry>("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;
}