980 lines
31 KiB
TypeScript
980 lines
31 KiB
TypeScript
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<string, unknown>;
|
|
containers: Record<string, (ItemData | null)[]>;
|
|
// a mod block's data, what BlockRef.data holds
|
|
mod_data?: unknown;
|
|
}[];
|
|
players: Record<string, SavedPlayer>;
|
|
// items on the ground
|
|
entities?: SavedItemEntity[];
|
|
// 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[];
|
|
atlas: AtlasListing;
|
|
recipes: RecipeBook;
|
|
worldgen?: WorldgenSetup;
|
|
runtime: ModRuntime;
|
|
}
|
|
|
|
export class GameServer {
|
|
world: ServerWorld;
|
|
#host: GameHost;
|
|
#players = new Map<number, ServerPlayer>();
|
|
// connections that got welcome and are downloading mods, by conn
|
|
#pending = new Map<number, { name: unknown; since: number }>();
|
|
#saved_players: Record<string, SavedPlayer> = {};
|
|
#tick = 0;
|
|
// items on the ground, by id
|
|
#entities = new Map<string, ItemEntity>();
|
|
#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<SavedWorld> | 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<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 });
|
|
}
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 "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<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) {
|
|
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<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;
|
|
}
|
|
|
|
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) {
|
|
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 <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;
|
|
}
|
|
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.get_block_nid(x, y, z) !== AIR;
|
|
|
|
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<BlockRegistry>("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;
|
|
}
|