Server actually ticks

This commit is contained in:
2026-09-25 13:25:25 -03:00
parent 2a2ccae9ce
commit dfabe40e7a
18 changed files with 763 additions and 79 deletions
+27 -18
View File
@@ -211,8 +211,10 @@ program**, so the server and each client can number blocks differently. Saves an
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
functions.
Block states work the same as today: `[{ "name": "facing", "bits": 2, "default": 0 }]`. They can't change textures yet,
because the mesher ignores them. `variants` is reserved for that.
Block states are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block
starts with its defaults when it's placed or generated, and server scripts read and change them with
`ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. They can't change textures
yet, because the mesher ignores them. `variants` is reserved for that.
## Items
@@ -335,18 +337,24 @@ ctx.components.register_block("copper_tools:oxidizes", {
});
```
| Handler | Called when | Return value |
| ------------------------------------ | ----------------------------------------------------- | ------------------------------------------ |
| `on_create(block, params)` | The block is placed or set. | ignored |
| `on_break(block, params, player?)` | The block is broken or replaced. | ignored |
| `on_click(block, params, player)` | A player left clicks it. | ignored |
| `on_interact(block, params, player)` | A player right clicks it. | `true` if handled, so no block gets placed |
| `on_tick(block, params, dt)` | Every tick (20 per second) while its chunk is loaded. | ignored |
| `on_second(block, params, dt)` | Every second while its chunk is loaded. | ignored |
| Handler | Called when | Return value |
| ------------------------------------ | ----------------------------------------- | ------------------------------------------ |
| `on_create(block, params)` | The block is placed or set. | ignored |
| `on_break(block, params, player?)` | The block is broken or replaced. | ignored |
| `on_click(block, params, player)` | A player starts hitting it. | ignored |
| `on_interact(block, params, player)` | A player right clicks it. | `true` if handled, so no block gets placed |
| `on_tick(block, params, dt)` | Every tick (20 per second) near a player. | ignored |
| `on_second(block, params, dt)` | Every second near a player. | ignored |
`block` is `{ id, x, y, z, data }`. `data` is tile data: any JSON value, `undefined` until a handler sets it, saved with
the world and **never sent to clients**. To show tile data to a player, open a screen with it (see [GUIs](#guis)).
`on_tick` and `on_second` only run for blocks that have tile data, like today.
`on_tick` and `on_second` only run for blocks that have tile data, so a component that ticks from the start sets
`block.data` in `on_create`. They run within 6 chunks of a player. `dt` is in seconds: 0.05 for `on_tick`, 1 for
`on_second`.
The server runs a fixed 20 ticks per second. When a tick takes too long, the next ones run back to back until it has
caught up, so game time keeps pace with real time. When it's more than ten ticks behind it skips the rest and logs a
warning. `/tps` shows how it's doing. Timers (`ctx.system`) count these ticks.
When a block lists several components, each handler runs in the listed order. `on_interact` counts as handled if any
component returns `true`.
@@ -354,6 +362,8 @@ component returns `true`.
Item components use
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
params, player) })`.
`on_use` runs when a player right clicks while holding the item, both at nothing and at a block. At a block, the block's
own interaction comes first, and an item with `on_use` is used instead of being placed.
### Events
@@ -436,8 +446,8 @@ ctx.commands.register("heal", {
});
```
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using a base game
name like `give`, is a load error. `/copper_tools:heal` always works as the unambiguous form.
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the
engine's (`give` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
## Client scripts
@@ -1118,7 +1128,7 @@ loading mods (the base game and the template), their scripts and worldgen, saves
## Implementation plan
Steps 1–5 are done and 6 and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the game
Steps 1–6 are done and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the game
working:
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
@@ -1136,10 +1146,9 @@ working:
5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before
joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Done._
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
`FUEL_VALUES` into the recipe registry. _Mostly done_ (`server/game/mod_runtime.ts`). Not yet: `on_click` (clients
don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state`
(block states aren't synced or saved), and `Container.on_change`. `ctx.containers`, `ctx.ui` and `ctx.net` throw
until steps 7 and 8, and so do the client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
`FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in
`server/game/game_loop.ts`). `ctx.containers`, `ctx.ui` and `ctx.net` throw until steps 7 and 8, and so do the
client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics`
API (built on the existing renderer and debug UI widgets) and the HUD.
8. **Mod channels** and keybinds.
+24 -11
View File
@@ -1,5 +1,5 @@
import { Component } from "$/common/ecs/mod.ts";
import { chunk_key } from "$/common/utils.ts";
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
@@ -101,7 +101,8 @@ export class Dimension extends Component {
return this.chunks.get(chunk_key(x, z));
}
add_block(block: Block) {
// state is the block's state bits, its defaults when not given
add_block(block: Block, state?: number) {
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
@@ -109,7 +110,7 @@ export class Dimension extends Component {
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
}
const nid = EverythingRegistry.get_id("blocks", block.id)!;
const [nid, info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!;
const lx = block.x - block_chunk_x * CHUNK_SIZE;
const lz = block.z - block_chunk_z * CHUNK_SIZE;
@@ -117,11 +118,23 @@ export class Dimension extends Component {
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = nid;
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
}
// the id with its state bits, VOID outside loaded chunks
get_block_value(x: number, y: number, z: number) {
const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(chunk_x, chunk_z);
if (!chunk) {
return VOID;
}
return chunk.blocks[y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE)] ??
AIR;
}
get_block(x: number, y: number, z: number) {
const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE);
@@ -196,18 +209,18 @@ export class Dimension extends Component {
return [x, y, z];
}
record_change(x: number, y: number, z: number, id: string) {
record_change(x: number, y: number, z: number, id: string, state = 0) {
const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
let chunk_changes = this.changes.get(chunk_key);
if (!chunk_changes) {
chunk_changes = new Map();
this.changes.set(chunk_key, chunk_changes);
}
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]);
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id, state]);
}
// set a block the server told us about
apply_change(x: number, y: number, z: number, id: string) {
apply_change(x: number, y: number, z: number, id: string, state = 0) {
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
if (!chunk || !chunk.generated) {
return;
@@ -222,15 +235,15 @@ export class Dimension extends Component {
}
const nid = EverythingRegistry.get_id("blocks", id);
if (nid === undefined || nid === current) {
if (nid === undefined || block_value(nid, state) === this.get_block_value(x, y, z)) {
return;
}
this.add_block({ x, y, z, id });
this.add_block({ x, y, z, id }, state);
}
apply_chunk_changes(cx: number, cz: number) {
for (const [x, y, z, id] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
this.apply_change(x, y, z, id);
for (const [x, y, z, id, state] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
this.apply_change(x, y, z, id, state);
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ export function start_game(world: ClientWorld) {
const dimension = new Entity("dimension");
world.dimension?.dispose();
world.dimension = new Dimension(world, world.connection.seed);
for (const [x, y, z, id] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id);
for (const [x, y, z, id, state] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id, state);
}
dimension.add(world.dimension);
world.add_entity(dimension);
+2 -2
View File
@@ -39,8 +39,8 @@ export class NetworkSystem extends System {
break;
}
case "set_block":
world.dimension.record_change(message.x, message.y, message.z, message.id);
world.dimension.apply_change(message.x, message.y, message.z, message.id);
world.dimension.record_change(message.x, message.y, message.z, message.id, message.state);
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
break;
case "chat":
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
+12 -3
View File
@@ -128,6 +128,10 @@ export class PlayerControlsSystem extends System {
if (block && player_component.screens.length === 0) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
if (InputManager.is_mouse_pressed(0)) {
// for mods' on_click, breaking itself is timed here and sent when done
send({ type: "hit_block", x: block.x, y: block.y, z: block.z });
}
if (player_component.breaking_block) {
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
player_component.break_progress_max = block_info.toughness ?? 9999;
@@ -151,9 +155,11 @@ export class PlayerControlsSystem extends System {
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
const target_id = world.dimension.get_block(target.x, target.y, target.z);
const replaceable = target_id === AIR ||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.id === "bworld:water";
if (!block_info.interactive && holding_item_info?.block_id && replaceable) {
world.dimension.add_block({ ...target, id: holding_item_info.block_id });
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
// items with components might do something else on the server, like on_use
const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
if (!block_info.interactive && place_id && replaceable) {
world.dimension.add_block({ ...target, id: place_id });
hotbar_slot.amount = hotbar_slot.amount! - 1;
}
}
@@ -161,6 +167,9 @@ export class PlayerControlsSystem extends System {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) {
send({ type: "use_item" });
}
}
if (player_component.screens.length === 0) {
+5 -1
View File
@@ -6,6 +6,7 @@ import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { default_block_value } from "$/common/utils.ts";
const pad = 0.5;
@@ -268,6 +269,8 @@ let block_ids: Record<string, number> = {};
let textures_info: TexturesInfo = {};
let image: Texture;
let worldgen: WorldgenSetup | undefined;
// numeric id to what a generated block stores, with its default states. matches the server
let default_values: number[] = [];
// generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's
let worldgen_ready: Promise<void> = Promise.resolve();
@@ -279,6 +282,7 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
block_ids = message.block_ids;
textures_info = message.textures_info;
image = message.image as Texture;
default_values = blocks_registry.map((block, nid) => default_block_value(nid, block));
worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
worldgen = setup;
});
@@ -319,7 +323,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
}
function generate(chunk_x: number, chunk_z: number, seed: string) {
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen);
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen, default_values);
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
}
+9 -5
View File
@@ -1,5 +1,5 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.ts";
@@ -323,6 +323,8 @@ export function generate_raw_chunk(
seed: string,
block_ids: Record<string, number>,
worldgen?: WorldgenSetup,
// the value to store for each numeric id, with its default states. just the id when missing
default_values?: number[],
): RawChunk {
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
const spills: number[] = [];
@@ -333,6 +335,7 @@ export function generate_raw_chunk(
if (y < 0 || y >= CHUNK_HEIGHT) {
return;
}
nid = default_values?.[nid] ?? nid;
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
@@ -364,7 +367,7 @@ export function generate_raw_chunk(
);
if (worldgen) {
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores);
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values);
generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features);
}
@@ -379,6 +382,7 @@ function generate_ores(
seed: string,
block_ids: Record<string, number>,
ores: OreJson[],
default_values: number[] | undefined,
) {
const usable = ores
.map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] }))
@@ -392,7 +396,7 @@ function generate_ores(
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
const current = blocks[index];
const current = blocks[index] & ID_MASK;
if (current === AIR) {
continue;
}
@@ -404,7 +408,7 @@ function generate_ores(
const wx = chunk_x * CHUNK_SIZE + lx;
const wz = chunk_z * CHUNK_SIZE + lz;
if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) {
blocks[index] = ore.nid;
blocks[index] = default_values?.[ore.nid] ?? ore.nid;
break;
}
}
@@ -452,7 +456,7 @@ function generate_features(
if (y < 0 || y >= CHUNK_HEIGHT) {
return undefined;
}
const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")];
const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")] & ID_MASK;
return nid === AIR ? "bworld:air" : ids_by_nid[nid];
},
set_block(x, y, z, id) {
+7 -3
View File
@@ -19,8 +19,8 @@ export interface PlayerInfo {
pitch: number;
}
// x, y, z, block id
export type BlockChange = [number, number, number, string];
// x, y, z, block id, and its state bits when they aren't 0
export type BlockChange = [number, number, number, string, number?];
// the containers a client can see and click. "screen" is whatever the open server screen shows
export type ContainerKey = "inventory" | "crafting" | "screen";
@@ -53,6 +53,10 @@ export type ClientMessage =
| { type: "ready" }
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
| { type: "break_block"; x: number; y: number; z: number }
// the player started hitting a block, for on_click
| { type: "hit_block"; x: number; y: number; z: number }
// right click without looking at a block, for items' on_use
| { type: "use_item" }
// right click on a block: interact with it, or place the held block against `face`
| { type: "use_block"; x: number; y: number; z: number; face: Faces }
| { type: "select_slot"; slot: number }
@@ -88,7 +92,7 @@ export type ServerMessage =
| { type: "player_leave"; id: string }
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
// also sent to the player who caused it, which corrects anything their client predicted wrong
| { type: "set_block"; x: number; y: number; z: number; id: string }
| { type: "set_block"; x: number; y: number; z: number; id: string; state?: number }
| { type: "chat"; from?: string; text: string }
| { type: "teleport"; x: number; y: number; z: number }
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
+15 -1
View File
@@ -79,7 +79,21 @@ export function set_state_value(value: number, block_info: BlockRegistry, name:
state_bits = (state_bits & ~s.mask) | (new_value << s.shift);
return (state_bits << 16) | id;
// >>> 0 keeps it unsigned when the top state bit is set
return ((state_bits << 16) | id) >>> 0;
}
// a block's numeric id with its states at their defaults, what placing it should store
export function default_block_value(nid: number, block_info: BlockRegistry | undefined): number {
let value = nid;
for (const state of block_info?.states ?? []) {
value = set_state_value(value, block_info!, state.name, state.default) ?? value;
}
return value;
}
export function block_value(nid: number, state: number): number {
return ((state << STATE_SHIFT) | nid) >>> 0;
}
function compile_block_states(block_info: BlockRegistry) {
+107
View File
@@ -0,0 +1,107 @@
// runs the game at a fixed rate. a slow tick makes the next ones run back to back until the game catches up,
// so game time keeps up with real time. if it falls too far behind, it skips ahead instead and says so
import { TICKS_PER_SECOND } from "$/common/constants.ts";
// at most this many ticks in a row to catch up, then skip the rest
const MAX_CATCH_UP_TICKS = 10;
// don't repeat the can't keep up warning more often than this
const WARN_INTERVAL_MS = 15_000;
export interface LoopClock {
now(): number;
// run fn after ms, returns something clear() takes
schedule(fn: () => void, ms: number): unknown;
clear(handle: unknown): void;
}
const real_clock: LoopClock = {
now: () => performance.now(),
schedule: (fn, ms) => setTimeout(fn, ms),
clear: (handle) => clearTimeout(handle as number),
};
export interface LoopStats {
// ticks that ran in the last second
tps: number;
// average milliseconds a tick took over the last second
mspt: number;
// ticks skipped because the game couldn't keep up, since it started
skipped: number;
}
export class GameLoop {
readonly tick_ms: number;
#tick: () => void;
#clock: LoopClock;
#timer: unknown;
#running = false;
#next_tick = 0;
#last_warning = -Infinity;
// ticks in the last second, as [when it started, how long it took]
#recent: [number, number][] = [];
#skipped = 0;
constructor(tick: () => void, tps = TICKS_PER_SECOND, clock: LoopClock = real_clock) {
this.#tick = tick;
this.tick_ms = 1000 / tps;
this.#clock = clock;
}
start() {
if (this.#running) return;
this.#running = true;
this.#next_tick = this.#clock.now();
this.#run();
}
stop() {
this.#running = false;
this.#clock.clear(this.#timer);
}
get stats(): LoopStats {
const now = this.#clock.now();
const recent = this.#recent.filter(([at]) => now - at < 1000);
const total = recent.reduce((sum, [, took]) => sum + took, 0);
return { tps: recent.length, mspt: recent.length ? total / recent.length : 0, skipped: this.#skipped };
}
#run() {
if (!this.#running) return;
let ran = 0;
while (this.#clock.now() >= this.#next_tick && ran < MAX_CATCH_UP_TICKS) {
const started = this.#clock.now();
try {
this.#tick();
} catch (e) {
// one bad tick shouldn't stop the world
console.error("Tick failed:", e);
}
const took = this.#clock.now() - started;
this.#recent.push([started, took]);
this.#next_tick += this.tick_ms;
ran++;
}
const now = this.#clock.now();
this.#recent = this.#recent.filter(([at]) => now - at < 1000);
const behind = Math.floor((now - this.#next_tick) / this.tick_ms);
if (behind > 0) {
// catching up would mean running the game in fast forward for a while, skip instead
this.#skipped += behind;
this.#next_tick += behind * this.tick_ms;
if (now - this.#last_warning > WARN_INTERVAL_MS) {
this.#last_warning = now;
console.warn(
`Can't keep up, skipped ${behind} ticks (${Math.round(behind * this.tick_ms)} ms). ` +
`Ticks take ${this.stats.mspt.toFixed(1)} ms on average, ${this.tick_ms} ms is the budget.`,
);
}
}
this.#timer = this.#clock.schedule(() => this.#run(), Math.max(0, this.#next_tick - this.#clock.now()));
}
}
+78 -7
View File
@@ -25,6 +25,7 @@ import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.t
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";
@@ -83,6 +84,8 @@ export class GameServer {
#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;
@@ -106,7 +109,8 @@ export class GameServer {
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 });
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
@@ -186,6 +190,7 @@ export class GameServer {
}
this.mods.tick();
this.mods.check_watched_containers();
for (const [conn, pending] of this.#pending) {
if (this.#tick - pending.since > READY_TIMEOUT_TICKS) {
@@ -257,7 +262,15 @@ export class GameServer {
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
}
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) {
// 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);
@@ -282,7 +295,7 @@ export class GameServer {
}
}
this.world.set_block(x, y, z, id);
this.world.set_block(x, y, z, id, state);
if (BLOCK_BEHAVIORS[id]?.create_tile) {
this.get_or_create_tile(x, y, z);
@@ -407,6 +420,14 @@ export class GameServer {
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);
@@ -459,6 +480,40 @@ export class GameServer {
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];
@@ -495,6 +550,12 @@ export class GameServer {
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 ?? "");
@@ -596,6 +657,19 @@ export class GameServer {
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)));
@@ -672,7 +746,7 @@ export class GameServer {
#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";
return nid === AIR || (EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.replaceable ?? false);
}
#in_reach(player: ServerPlayer, x: number, y: number, z: number) {
@@ -723,9 +797,6 @@ export class GameServer {
}
}
// 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);
}
+81 -7
View File
@@ -1,7 +1,8 @@
// runs mods' server scripts: builds each one's ServerContext and keeps what they register.
// see "Server scripts" in MODS.md. parts not built yet throw when used, saying so
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { ItemStack as EngineItemStack } from "$/common/inventory.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { Container as EngineContainer, ItemStack as EngineItemStack } from "$/common/inventory.ts";
import { get_state_value, set_state_value } from "$/common/utils.ts";
import type {
BlockComponent,
BlockRef,
@@ -47,12 +48,19 @@ export class Signal<T> {
}
}
// the mod whose code is running right now, for apis shared between mods that need to know who called
let running_mod = "";
export function run_guarded<T>(mod: string, what: string, fn: () => T): T | undefined {
const previous = running_mod;
running_mod = mod;
try {
return fn();
} catch (e) {
console.error(`[${mod}] ${what} threw:`, e);
return undefined;
} finally {
running_mod = previous;
}
}
@@ -94,6 +102,8 @@ export class ModRuntime {
storage: Record<string, Record<string, unknown>> = {};
#timers = new Map<number, Timer>();
// containers mods listen to with on_change, compared slot by slot every tick
#watched: { mod: string; container: EngineContainer; last: string[]; fn: (slot: number) => void }[] = [];
#next_timer = 1;
#setting_up = true;
#players = new WeakMap<ServerPlayer, Player>();
@@ -107,7 +117,12 @@ export class ModRuntime {
if (typeof module.setup !== "function") {
throw new ModLoadError(mod, "the server script doesn't export a setup function");
}
await module.setup(this.#context(mod, version));
running_mod = mod;
try {
await module.setup(this.#context(mod, version));
} finally {
running_mod = "";
}
}
}
@@ -165,6 +180,36 @@ export class ModRuntime {
}));
}
// components on an item, with their params from its json
item_components_of(item_id: string): { mod: string; id: string; component: ItemComponent; params: unknown }[] {
const item = EverythingRegistry.get<ItemRegistry>("items", item_id);
return Object.entries(item?.components ?? {}).map(([id, params]) => ({
...this.item_components.get(id)!,
id,
params,
}));
}
watch_container(mod: string, container: EngineContainer, fn: (slot: number) => void) {
const entry = { mod, container, fn, last: snapshot(container) };
this.#watched.push(entry);
return () => {
this.#watched = this.#watched.filter((w) => w !== entry);
};
}
// items change in many places (clicks, drops, crafting, scripts), so compare instead of hooking every one
check_watched_containers() {
for (const watch of [...this.#watched]) {
const now = snapshot(watch.container);
const changed = now.flatMap((slot, i) => slot !== watch.last[i] ? [i] : []);
watch.last = now;
for (const slot of changed) {
run_guarded(watch.mod, "a container on_change", () => watch.fn(slot));
}
}
}
tick() {
const now = this.game.current_tick;
for (const [handle, timer] of [...this.#timers]) {
@@ -246,7 +291,7 @@ export class ModRuntime {
if (!/^[a-z0-9_]+$/.test(name)) {
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
}
if (name === "give") throw new ModLoadError(mod, `/give belongs to the base game`);
if (["give", "tps"].includes(name)) throw new ModLoadError(mod, `/${name} belongs to the engine`);
const existing = this.commands.get(name);
if (existing) {
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
@@ -275,8 +320,24 @@ export class ModRuntime {
game().set_block(x, y, z, id);
return true;
},
get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"),
set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"),
get_state: (x, y, z, name) => {
const id = game().world.get_block_id(x, y, z);
if (id === AIR_ID) return undefined;
const info = state_owner(id, name);
return get_state_value(game().world.get_block_value(x, y, z), info, name);
},
set_state: (x, y, z, name, value) => {
const id = game().world.get_block_id(x, y, z);
if (id === AIR_ID) return false;
const info = state_owner(id, name);
const bits = info.states!.find((s) => s.name === name)!.bits;
if (!Number.isInteger(value) || value < 0 || value >= 2 ** bits) {
throw new Error(`${id} state "${name}" has ${bits} bits, ${value} doesn't fit`);
}
const new_value = set_state_value(game().world.get_block_value(x, y, z), info, name, value)!;
game().set_block_state(x, y, z, new_value >>> 16);
return true;
},
get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never,
is_loaded: () => true,
get seed() {
@@ -330,6 +391,19 @@ export class ModRuntime {
}
// each event's signal, as seen by one mod so errors say which mod threw
// a block that has this state, or an error saying it doesn't
function state_owner(id: string, name: string): BlockRegistry {
const info = EverythingRegistry.get<BlockRegistry>("blocks", id)!;
if (!info.states?.some((s) => s.name === name)) {
throw new Error(`${id} has no state "${name}"`);
}
return info;
}
function snapshot(container: EngineContainer): string[] {
return container.to_data().map((item) => JSON.stringify(item));
}
function map_signals<T extends { [name: string]: { for_mod(mod: string): unknown } }>(
signals: T,
mod: string,
@@ -394,7 +468,7 @@ function player_api(game: GameServer, player: ServerPlayer): Player {
const left = player.inventory.add_item(stack);
return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined;
},
on_change: not_yet("player", "inventory.on_change", "containers are step 7 in MODS.md"),
on_change: (fn) => game.mods.watch_container(running_mod || "unknown", player.inventory, fn),
};
return {
+5 -2
View File
@@ -2,7 +2,7 @@
// runs the game in a worker without any permissions, the host does files and networking
import type { GameServer } from "./game_server.ts";
import { TICK_MS } from "./game_server.ts";
import { GameLoop } from "./game_loop.ts";
import { GameToHost, HostToGame } from "./host_protocol.ts";
import { data_url, start_game } from "./load_mods.ts";
@@ -68,7 +68,10 @@ async function init(message: Extract<HostToGame, { type: "init" }>) {
return;
}
setInterval(() => game!.tick(), TICK_MS);
const loop = new GameLoop(() => game!.tick());
game.loop = loop;
loop.start();
setInterval(() => {
if (game!.world.dirty) {
post({ type: "save", data: game!.save(), final: false });
+29 -14
View File
@@ -3,7 +3,7 @@ import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.
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 { chunk_key } from "$/common/utils.ts";
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { LruMap } from "./lru.ts";
// generation only, rebuilt when needed. 128 KB each
@@ -31,6 +31,8 @@ export class ServerWorld {
readonly seed: string;
readonly block_ids: Record<string, number> = {};
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<number, RawChunk>(RAW_CACHE_SIZE);
@@ -43,13 +45,14 @@ export class ServerWorld {
// set when anything that gets saved changes
dirty = false;
on_block_change?: (x: number, y: number, z: number, id: string) => void;
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<BlockRegistry>("blocks").forEach((block, nid) => {
this.block_ids[block.id] = nid;
this.default_values[nid] = default_block_value(nid, block);
});
}
@@ -68,13 +71,23 @@ export class ServerWorld {
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)];
}
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<BlockRegistry>("blocks", nid);
}
// only changes the block, the game server runs behaviors and tiles
set_block(x: number, y: number, z: number, id: string) {
// 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;
}
@@ -82,15 +95,17 @@ export class ServerWorld {
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)] = nid;
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = value;
this.#record_change(x, y, z, id);
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);
this.on_block_change?.(x, y, z, id, state_bits);
}
all_changes(): BlockChange[] {
@@ -102,8 +117,8 @@ export class ServerWorld {
}
load_changes(changes: BlockChange[]) {
for (const [x, y, z, id] of changes) {
this.#record_change(x, y, z, id);
for (const [x, y, z, id, state] of changes) {
this.#record_change(x, y, z, id, state ?? 0);
}
}
@@ -121,21 +136,21 @@ export class ServerWorld {
this.dirty = true;
}
#record_change(x: number, y: number, z: number, id: string) {
#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), [x, y, z, id]);
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);
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;
@@ -174,10 +189,10 @@ export class ServerWorld {
}
}
for (const [x, y, z, id] of this.#changes.get(chunk_key(chunk_x, chunk_z))?.values() ?? []) {
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)] = nid;
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = block_value(nid, state ?? 0);
}
}
+10 -2
View File
@@ -4,8 +4,16 @@ import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
// used by blocks/example_block.json, which passes { message } as params
ctx.components.register_block<{ message: string }>("example_mod:announce", {
on_interact(_block, params, player) {
player.send_message(params.message);
// block data is saved with the world. on_tick and on_second only run for blocks that have some
on_create(block) {
block.data = { age: 0 };
},
// on_tick runs 20 times a second, on_second once, while a player is nearby
on_second(block) {
block.data.age += 1;
},
on_interact(block, params, player) {
player.send_message(`${params.message} It's been here for ${block.data?.age ?? 0} seconds.`);
// handled, so right clicking it doesn't place a block
return true;
},
+116
View File
@@ -0,0 +1,116 @@
import { assert, assertEquals } from "@std/assert";
import { GameLoop, LoopClock } from "$/server/game/game_loop.ts";
// a clock tests move by hand. ticks can move it too, to pretend they were slow
class FakeClock implements LoopClock {
time = 0;
#timers: { at: number; fn: () => void; id: number }[] = [];
#next_id = 1;
now() {
return this.time;
}
schedule(fn: () => void, ms: number) {
const id = this.#next_id++;
this.#timers.push({ at: this.time + ms, fn, id });
return id;
}
clear(handle: unknown) {
this.#timers = this.#timers.filter((t) => t.id !== handle);
}
// runs timers in order until `ms` have passed
advance(ms: number) {
const end = this.time + ms;
while (true) {
this.#timers.sort((a, b) => a.at - b.at);
const timer = this.#timers[0];
if (!timer || timer.at > end) break;
this.#timers.shift();
this.time = Math.max(this.time, timer.at);
timer.fn();
}
this.time = end;
}
}
Deno.test("runs 20 ticks a second", () => {
const clock = new FakeClock();
let ticks = 0;
const loop = new GameLoop(() => ticks++, 20, clock);
loop.start();
clock.advance(10_000);
// the first tick runs right away
assertEquals(ticks, 201);
assertEquals(loop.stats.tps, 20);
loop.stop();
clock.advance(1000);
assertEquals(ticks, 201);
});
Deno.test("a slow tick is made up for, so game time keeps up", () => {
const clock = new FakeClock();
let ticks = 0;
const loop = new GameLoop(
() => {
ticks++;
// tick 10 takes 200 ms, four ticks' worth
if (ticks === 10) clock.time += 200;
},
20,
clock,
);
loop.start();
clock.advance(2000);
assertEquals(ticks, 41);
assertEquals(loop.stats.skipped, 0);
});
Deno.test("falling too far behind skips ticks and warns", () => {
const clock = new FakeClock();
const warnings: string[] = [];
const warn = console.warn;
console.warn = (message: string) => warnings.push(message);
try {
let ticks = 0;
const loop = new GameLoop(
() => {
ticks++;
// one 2 second hiccup, forty ticks' worth
if (ticks === 5) clock.time += 2000;
},
20,
clock,
);
loop.start();
clock.advance(5000);
// it catches up ten, skips the rest and carries on at 20 per second
assert(loop.stats.skipped >= 25 && loop.stats.skipped <= 31, `skipped ${loop.stats.skipped}`);
assertEquals(loop.stats.tps, 20);
assertEquals(warnings.length, 1);
assert(warnings[0].startsWith("Can't keep up"), warnings[0]);
} finally {
console.warn = warn;
}
});
Deno.test("a tick that throws doesn't stop the loop", () => {
const clock = new FakeClock();
let ticks = 0;
const error = console.error;
console.error = () => {};
try {
const loop = new GameLoop(
() => {
ticks++;
if (ticks === 3) throw new Error("oops");
},
20,
clock,
);
loop.start();
clock.advance(1000);
assertEquals(ticks, 21);
} finally {
console.error = error;
}
});
+5 -1
View File
@@ -83,7 +83,11 @@ Deno.test("a mod made from the template loads and runs", async () => {
take(1);
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
const messages = take(1);
assert(messages.some((m) => m.type === "chat" && m.text === "You found the example block!"));
assert(messages.some((m) => m.type === "chat" && m.text.startsWith("You found the example block!")));
// it counts its age with on_second
for (let i = 0; i < 3 * 20; i++) game.tick();
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
assert(take(1).some((m) => m.text === "You found the example block! It's been here for 3 seconds."));
assertEquals(game.world.get_block_id(1, y + 2, 1), "bworld:air");
// the shaped recipe: 4 example items in a square
+229
View File
@@ -0,0 +1,229 @@
import { assert, assertEquals } from "@std/assert";
import { copy_dir, test_game } from "./helpers.ts";
// a mod that uses every part of the server api worth testing, and writes down what happened in its storage
const SERVER_SCRIPT = `
import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
const log = (key: string, value: unknown = 1) => {
const all = ctx.storage.get<Record<string, unknown[]>>("log") ?? {};
(all[key] ??= []).push(value);
ctx.storage.set("log", all);
};
ctx.components.register_block("testmod:ticker", {
on_create(block) { block.data = { name: "ticker" }; },
on_tick(block, _params, dt) { log("tick_" + block.x, dt); },
on_second(block, _params, dt) { log("second_" + block.x, dt); },
});
ctx.components.register_block("testmod:lazy", {
on_tick() { log("lazy_tick"); },
});
ctx.components.register_block("testmod:broken", {
on_create(block) { block.data = {}; },
on_tick() { throw new Error("broken on purpose"); },
on_click(block, _params, player) { log("click", [block.id, player.name]); },
});
ctx.components.register_item("testmod:wand", {
on_use(item, params: { power: number }, player) { log("use", [item.id, params.power, player.name]); },
});
ctx.system.run_timeout(() => log("timeout", ctx.system.current_tick), 5);
const interval = ctx.system.run_interval(() => {
log("interval", ctx.system.current_tick);
if (ctx.system.current_tick >= 30) ctx.system.clear_run(interval);
}, 10);
ctx.events.after.tick.subscribe(() => log("tick_event"));
ctx.events.after.player_join.subscribe(({ player }) => {
player.inventory.on_change((slot) => log("inventory", slot));
});
ctx.commands.register("state", {
description: "", usage: "",
run([x, y, z, name, value], player) {
const [bx, by, bz] = [Number(x), Number(y), Number(z)];
try {
if (value !== undefined) ctx.world.set_state(bx, by, bz, name, Number(value));
player.send_message("state " + ctx.world.get_state(bx, by, bz, name));
} catch (e) {
player.send_message("error " + (e as Error).message);
}
},
});
}
`;
const block = (id: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({ format_version: 1, block: { id, textures: "bworld:stone", mining: { toughness: 1 }, ...extra } });
function test_mods() {
const dir = Deno.makeTempDirSync({ prefix: "bworld_scripts_" });
copy_dir("mods/bworld", `${dir}/bworld`);
const mod = `${dir}/testmod`;
Deno.mkdirSync(`${mod}/blocks`, { recursive: true });
Deno.mkdirSync(`${mod}/items`);
Deno.mkdirSync(`${mod}/scripts`);
Deno.writeTextFileSync(
`${mod}/manifest.json`,
JSON.stringify({
format_version: 1,
id: "testmod",
name: "Test",
version: "1.0.0",
dependencies: [{ id: "bworld", version: "*" }],
scripts: { server: "scripts/server.ts" },
}),
);
Deno.writeTextFileSync(
`${mod}/blocks/ticker.json`,
block("testmod:ticker", { components: { "testmod:ticker": {} } }),
);
Deno.writeTextFileSync(`${mod}/blocks/lazy.json`, block("testmod:lazy", { components: { "testmod:lazy": {} } }));
Deno.writeTextFileSync(
`${mod}/blocks/broken.json`,
block("testmod:broken", { components: { "testmod:broken": {} } }),
);
Deno.writeTextFileSync(
`${mod}/blocks/lamp.json`,
block("testmod:lamp", {
states: [{ name: "power", bits: 3, default: 2 }, { name: "lit", bits: 1, default: 0 }],
}),
);
Deno.writeTextFileSync(
`${mod}/items/wand.json`,
JSON.stringify({
format_version: 1,
item: {
id: "testmod:wand",
texture: "bworld:stone",
places: "bworld:stone",
components: { "testmod:wand": { power: 7 } },
},
}),
);
Deno.writeTextFileSync(`${mod}/scripts/server.ts`, SERVER_SCRIPT);
return dir;
}
async function setup(save?: string) {
const dir = test_mods();
const game = await test_game(dir, save);
game.join(1, "alice");
game.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 });
game.take(1);
const log = () => (game.game.mods.storage.testmod?.log ?? {}) as Record<string, unknown[]>;
return { ...game, dir, log };
}
Deno.test("components tick 20 times a second and on_second once, only with block data and near players", async () => {
const { game, log, dir } = await setup();
const error = console.error;
console.error = () => {};
try {
game.set_block(2, 100, 2, "testmod:ticker");
game.set_block(3, 100, 2, "testmod:lazy");
game.set_block(4, 100, 2, "testmod:broken");
// 20 chunks away from the only player
game.set_block(16 * 20, 100, 0, "testmod:ticker");
for (let i = 0; i < 40; i++) game.tick();
} finally {
console.error = error;
}
assertEquals(log()["tick_2"]?.length, 40);
assert(log()["tick_2"].every((dt) => dt === 1 / 20));
assertEquals(log()["second_2"], [1, 1]);
assertEquals(log()["lazy_tick"], undefined, "blocks without data don't tick");
assertEquals(log()[`tick_${16 * 20}`], undefined, "blocks far from every player don't tick");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("timers and the tick event run on the game loop's ticks", async () => {
const { game, log, dir } = await setup();
for (let i = 0; i < 50; i++) game.tick();
assertEquals(log()["timeout"], [5]);
assertEquals(log()["interval"], [10, 20, 30]);
assertEquals(log()["tick_event"]?.length, 50);
Deno.removeSync(dir, { recursive: true });
});
Deno.test("hitting a block runs on_click, using an item runs on_use instead of placing it", async () => {
const { game, send, take, log, dir } = await setup();
game.set_block(1, 99, 1, "testmod:broken");
send(1, { type: "hit_block", x: 1, y: 99, z: 1 });
assertEquals(log()["click"], [["testmod:broken", "alice"]]);
send(1, { type: "chat", text: "/give testmod:wand" });
const inventory = take(1).filter((m) => m.container === "inventory").at(-1).items;
send(1, { type: "select_slot", slot: inventory.findIndex((i: { id: string } | null) => i?.id === "testmod:wand") });
send(1, { type: "use_item" });
assertEquals(log()["use"], [["testmod:wand", 7, "alice"]]);
// on a block: used, not placed (even though it can place stone)
send(1, { type: "use_block", x: 1, y: 99, z: 1, face: "top" });
assertEquals(log()["use"]?.length, 2);
assertEquals(game.world.get_block_id(1, 100, 1), "bworld:air");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("block states start at their defaults, can be changed, and are synced and saved", async () => {
const { game, send, take, dir } = await setup();
const state = (args: string) => {
send(1, { type: "chat", text: `/state ${args}` });
return take(1).filter((m) => m.type === "chat").map((m) => m.text).at(-1);
};
game.set_block(5, 100, 5, "testmod:lamp");
take(1);
assertEquals(state("5 100 5 power"), "state 2");
assertEquals(state("5 100 5 lit"), "state 0");
send(1, { type: "chat", text: "/state 5 100 5 power 5" });
const change = take(1).find((m) => m.type === "set_block");
assertEquals(change.id, "testmod:lamp");
assertEquals(change.state, 5, "other clients get the new state");
assertEquals(state("5 100 5 lit 1"), "state 1");
assertEquals(state("5 100 5 power"), "state 5");
assert(state("5 100 5 power 8").startsWith('error testmod:lamp state "power" has 3 bits'));
assert(state("5 100 5 color").startsWith('error testmod:lamp has no state "color"'));
// saved and loaded, and new players get it with the world's changes
const saved = game.save();
Deno.removeSync(dir, { recursive: true });
const again = await setup(saved);
assertEquals(again.game.world.get_block_value(5, 100, 5) >>> 16, 5 | (1 << 3));
const joined = again.join(2, "bob");
assert(joined.changes.some((c: unknown[]) => c[0] === 5 && c[3] === "testmod:lamp" && c[4] === (5 | (1 << 3))));
Deno.removeSync(again.dir, { recursive: true });
});
Deno.test("inventory on_change fires for the slots that changed", async () => {
const { game, send, log, dir } = await setup();
send(1, { type: "chat", text: "/give bworld:stone 3" });
send(1, { type: "chat", text: "/give bworld:dirt" });
game.tick();
assertEquals(log()["inventory"], [0, 1]);
game.tick();
assertEquals(log()["inventory"], [0, 1], "nothing changed since");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("/tps reports the loop, and mods can't take engine commands", async () => {
const { send, take, dir } = await setup();
send(1, { type: "chat", text: "/tps" });
assertEquals(take(1).at(-1).text, "The game loop isn't running");
Deno.removeSync(dir, { recursive: true });
const clash = test_mods();
Deno.writeTextFileSync(
`${clash}/testmod/scripts/server.ts`,
SERVER_SCRIPT.replace('ctx.commands.register("state"', 'ctx.commands.register("tps"'),
);
let error = "";
try {
await test_game(clash);
} catch (e) {
error = (e as Error).message;
}
assert(error.includes("/tps belongs to the engine"), error);
Deno.removeSync(clash, { recursive: true });
});