Server actually ticks
This commit is contained in:
@@ -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
|
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
|
||||||
functions.
|
functions.
|
||||||
|
|
||||||
Block states work the same as today: `[{ "name": "facing", "bits": 2, "default": 0 }]`. They can't change textures yet,
|
Block states are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block
|
||||||
because the mesher ignores them. `variants` is reserved for that.
|
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
|
## Items
|
||||||
|
|
||||||
@@ -335,18 +337,24 @@ ctx.components.register_block("copper_tools:oxidizes", {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
| Handler | Called when | Return value |
|
| Handler | Called when | Return value |
|
||||||
| ------------------------------------ | ----------------------------------------------------- | ------------------------------------------ |
|
| ------------------------------------ | ----------------------------------------- | ------------------------------------------ |
|
||||||
| `on_create(block, params)` | The block is placed or set. | ignored |
|
| `on_create(block, params)` | The block is placed or set. | ignored |
|
||||||
| `on_break(block, params, player?)` | The block is broken or replaced. | 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_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_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_tick(block, params, dt)` | Every tick (20 per second) near a player. | ignored |
|
||||||
| `on_second(block, params, dt)` | Every second while its chunk is loaded. | 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
|
`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)).
|
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
|
When a block lists several components, each handler runs in the listed order. `on_interact` counts as handled if any
|
||||||
component returns `true`.
|
component returns `true`.
|
||||||
@@ -354,6 +362,8 @@ component returns `true`.
|
|||||||
Item components use
|
Item components use
|
||||||
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
|
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
|
||||||
params, player) })`.
|
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
|
### 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
|
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
|
||||||
name like `give`, is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
engine's (`give` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
||||||
|
|
||||||
## Client scripts
|
## Client scripts
|
||||||
|
|
||||||
@@ -1118,7 +1128,7 @@ loading mods (the base game and the template), their scripts and worldgen, saves
|
|||||||
|
|
||||||
## Implementation plan
|
## 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:
|
working:
|
||||||
|
|
||||||
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
|
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
|
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._
|
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
|
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
|
`FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in
|
||||||
don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state`
|
`server/game/game_loop.ts`). `ctx.containers`, `ctx.ui` and `ctx.net` throw until steps 7 and 8, and so do the
|
||||||
(block states aren't synced or saved), and `Container.on_change`. `ctx.containers`, `ctx.ui` and `ctx.net` throw
|
client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
|
||||||
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`
|
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.
|
API (built on the existing renderer and debug UI widgets) and the HUD.
|
||||||
8. **Mod channels** and keybinds.
|
8. **Mod channels** and keybinds.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component } from "$/common/ecs/mod.ts";
|
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 { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.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";
|
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));
|
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_x = Math.floor(block.x / CHUNK_SIZE);
|
||||||
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
||||||
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
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);
|
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 lx = block.x - block_chunk_x * CHUNK_SIZE;
|
||||||
const lz = block.z - block_chunk_z * 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;
|
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;
|
chunk.dirty = true;
|
||||||
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
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) {
|
get_block(x: number, y: number, z: number) {
|
||||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
@@ -196,18 +209,18 @@ export class Dimension extends Component {
|
|||||||
return [x, y, z];
|
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)}`;
|
const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
|
||||||
let chunk_changes = this.changes.get(chunk_key);
|
let chunk_changes = this.changes.get(chunk_key);
|
||||||
if (!chunk_changes) {
|
if (!chunk_changes) {
|
||||||
chunk_changes = new Map();
|
chunk_changes = new Map();
|
||||||
this.changes.set(chunk_key, chunk_changes);
|
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
|
// 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));
|
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||||
if (!chunk || !chunk.generated) {
|
if (!chunk || !chunk.generated) {
|
||||||
return;
|
return;
|
||||||
@@ -222,15 +235,15 @@ export class Dimension extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nid = EverythingRegistry.get_id("blocks", id);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.add_block({ x, y, z, id });
|
this.add_block({ x, y, z, id }, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_chunk_changes(cx: number, cz: number) {
|
apply_chunk_changes(cx: number, cz: number) {
|
||||||
for (const [x, y, z, id] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
|
for (const [x, y, z, id, state] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
|
||||||
this.apply_change(x, y, z, id);
|
this.apply_change(x, y, z, id, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ export function start_game(world: ClientWorld) {
|
|||||||
const dimension = new Entity("dimension");
|
const dimension = new Entity("dimension");
|
||||||
world.dimension?.dispose();
|
world.dimension?.dispose();
|
||||||
world.dimension = new Dimension(world, world.connection.seed);
|
world.dimension = new Dimension(world, world.connection.seed);
|
||||||
for (const [x, y, z, id] of world.connection.initial_changes) {
|
for (const [x, y, z, id, state] of world.connection.initial_changes) {
|
||||||
world.dimension.record_change(x, y, z, id);
|
world.dimension.record_change(x, y, z, id, state);
|
||||||
}
|
}
|
||||||
dimension.add(world.dimension);
|
dimension.add(world.dimension);
|
||||||
world.add_entity(dimension);
|
world.add_entity(dimension);
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export class NetworkSystem extends System {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "set_block":
|
case "set_block":
|
||||||
world.dimension.record_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);
|
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
|
||||||
break;
|
break;
|
||||||
case "chat":
|
case "chat":
|
||||||
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ export class PlayerControlsSystem extends System {
|
|||||||
|
|
||||||
if (block && player_component.screens.length === 0) {
|
if (block && player_component.screens.length === 0) {
|
||||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
|
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) {
|
if (player_component.breaking_block) {
|
||||||
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
|
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
|
||||||
player_component.break_progress_max = block_info.toughness ?? 9999;
|
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 = { 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 target_id = world.dimension.get_block(target.x, target.y, target.z);
|
||||||
const replaceable = target_id === AIR ||
|
const replaceable = target_id === AIR ||
|
||||||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.id === "bworld:water";
|
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
|
||||||
if (!block_info.interactive && holding_item_info?.block_id && replaceable) {
|
// items with components might do something else on the server, like on_use
|
||||||
world.dimension.add_block({ ...target, id: holding_item_info.block_id });
|
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;
|
hotbar_slot.amount = hotbar_slot.amount! - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +167,9 @@ export class PlayerControlsSystem extends System {
|
|||||||
player_component.breaking_block = undefined;
|
player_component.breaking_block = undefined;
|
||||||
player_component.break_progress_max = 0;
|
player_component.break_progress_max = 0;
|
||||||
player_component.break_progress = 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) {
|
if (player_component.screens.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Texture } from "../renderer/types.ts";
|
|||||||
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
|
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
|
||||||
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
|
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
|
||||||
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
||||||
|
import { default_block_value } from "$/common/utils.ts";
|
||||||
|
|
||||||
const pad = 0.5;
|
const pad = 0.5;
|
||||||
|
|
||||||
@@ -268,6 +269,8 @@ let block_ids: Record<string, number> = {};
|
|||||||
let textures_info: TexturesInfo = {};
|
let textures_info: TexturesInfo = {};
|
||||||
let image: Texture;
|
let image: Texture;
|
||||||
let worldgen: WorldgenSetup | undefined;
|
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
|
// 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();
|
let worldgen_ready: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -279,6 +282,7 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
|||||||
block_ids = message.block_ids;
|
block_ids = message.block_ids;
|
||||||
textures_info = message.textures_info;
|
textures_info = message.textures_info;
|
||||||
image = message.image as Texture;
|
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_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
|
||||||
worldgen = setup;
|
worldgen = setup;
|
||||||
});
|
});
|
||||||
@@ -319,7 +323,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generate(chunk_x: number, chunk_z: number, seed: string) {
|
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]);
|
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
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 { FeatureChunk } from "$/common/mod_api/worldgen.ts";
|
||||||
import type { OreJson } from "$/common/mod_data.ts";
|
import type { OreJson } from "$/common/mod_data.ts";
|
||||||
|
|
||||||
@@ -323,6 +323,8 @@ export function generate_raw_chunk(
|
|||||||
seed: string,
|
seed: string,
|
||||||
block_ids: Record<string, number>,
|
block_ids: Record<string, number>,
|
||||||
worldgen?: WorldgenSetup,
|
worldgen?: WorldgenSetup,
|
||||||
|
// the value to store for each numeric id, with its default states. just the id when missing
|
||||||
|
default_values?: number[],
|
||||||
): RawChunk {
|
): RawChunk {
|
||||||
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
|
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
|
||||||
const spills: number[] = [];
|
const spills: number[] = [];
|
||||||
@@ -333,6 +335,7 @@ export function generate_raw_chunk(
|
|||||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
nid = default_values?.[nid] ?? nid;
|
||||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
|
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
|
||||||
@@ -364,7 +367,7 @@ export function generate_raw_chunk(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (worldgen) {
|
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);
|
generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +382,7 @@ function generate_ores(
|
|||||||
seed: string,
|
seed: string,
|
||||||
block_ids: Record<string, number>,
|
block_ids: Record<string, number>,
|
||||||
ores: OreJson[],
|
ores: OreJson[],
|
||||||
|
default_values: number[] | undefined,
|
||||||
) {
|
) {
|
||||||
const usable = ores
|
const usable = ores
|
||||||
.map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] }))
|
.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 lz = 0; lz < CHUNK_SIZE; lz++) {
|
||||||
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
||||||
const index = y * CHUNK_AREA + lz * 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) {
|
if (current === AIR) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -404,7 +408,7 @@ function generate_ores(
|
|||||||
const wx = chunk_x * CHUNK_SIZE + lx;
|
const wx = chunk_x * CHUNK_SIZE + lx;
|
||||||
const wz = chunk_z * CHUNK_SIZE + lz;
|
const wz = chunk_z * CHUNK_SIZE + lz;
|
||||||
if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,7 +456,7 @@ function generate_features(
|
|||||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
return undefined;
|
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];
|
return nid === AIR ? "bworld:air" : ids_by_nid[nid];
|
||||||
},
|
},
|
||||||
set_block(x, y, z, id) {
|
set_block(x, y, z, id) {
|
||||||
|
|||||||
+7
-3
@@ -19,8 +19,8 @@ export interface PlayerInfo {
|
|||||||
pitch: number;
|
pitch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// x, y, z, block id
|
// x, y, z, block id, and its state bits when they aren't 0
|
||||||
export type BlockChange = [number, number, number, string];
|
export type BlockChange = [number, number, number, string, number?];
|
||||||
|
|
||||||
// the containers a client can see and click. "screen" is whatever the open server screen shows
|
// the containers a client can see and click. "screen" is whatever the open server screen shows
|
||||||
export type ContainerKey = "inventory" | "crafting" | "screen";
|
export type ContainerKey = "inventory" | "crafting" | "screen";
|
||||||
@@ -53,6 +53,10 @@ export type ClientMessage =
|
|||||||
| { type: "ready" }
|
| { type: "ready" }
|
||||||
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
|
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||||
| { type: "break_block"; x: number; y: number; z: 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`
|
// 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: "use_block"; x: number; y: number; z: number; face: Faces }
|
||||||
| { type: "select_slot"; slot: number }
|
| { type: "select_slot"; slot: number }
|
||||||
@@ -88,7 +92,7 @@ export type ServerMessage =
|
|||||||
| { type: "player_leave"; id: string }
|
| { type: "player_leave"; id: string }
|
||||||
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
| { 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
|
// 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: "chat"; from?: string; text: string }
|
||||||
| { type: "teleport"; x: number; y: number; z: number }
|
| { type: "teleport"; x: number; y: number; z: number }
|
||||||
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
|
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
|
||||||
|
|||||||
+15
-1
@@ -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);
|
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) {
|
function compile_block_states(block_info: BlockRegistry) {
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.t
|
|||||||
import type { WorldgenSetup } from "$/common/generation.ts";
|
import type { WorldgenSetup } from "$/common/generation.ts";
|
||||||
import { BLOCK_BEHAVIORS } from "./blocks.ts";
|
import { BLOCK_BEHAVIORS } from "./blocks.ts";
|
||||||
import { ModRuntime, run_guarded } from "./mod_runtime.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 { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||||
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
||||||
import { ServerWorld, Tile } from "./world.ts";
|
import { ServerWorld, Tile } from "./world.ts";
|
||||||
@@ -83,6 +84,8 @@ export class GameServer {
|
|||||||
#mods: GameMods;
|
#mods: GameMods;
|
||||||
mods: ModRuntime;
|
mods: ModRuntime;
|
||||||
recipes: RecipeBook;
|
recipes: RecipeBook;
|
||||||
|
// what runs tick(), for /tps. tests call tick() themselves
|
||||||
|
loop?: GameLoop;
|
||||||
|
|
||||||
constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) {
|
constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) {
|
||||||
this.#host = host;
|
this.#host = host;
|
||||||
@@ -106,7 +109,8 @@ export class GameServer {
|
|||||||
this.#saved_players = saved?.players ?? {};
|
this.#saved_players = saved?.players ?? {};
|
||||||
this.world.dirty = saved?.version !== 2;
|
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
|
// connections
|
||||||
@@ -186,6 +190,7 @@ export class GameServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.mods.tick();
|
this.mods.tick();
|
||||||
|
this.mods.check_watched_containers();
|
||||||
|
|
||||||
for (const [conn, pending] of this.#pending) {
|
for (const [conn, pending] of this.#pending) {
|
||||||
if (this.#tick - pending.since > READY_TIMEOUT_TICKS) {
|
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);
|
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);
|
const old_id = this.world.get_block_id(x, y, z);
|
||||||
if (old_id !== AIR_ID) {
|
if (old_id !== AIR_ID) {
|
||||||
const components = this.mods.components_of(old_id).filter(({ component }) => component.on_break);
|
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) {
|
if (BLOCK_BEHAVIORS[id]?.create_tile) {
|
||||||
this.get_or_create_tile(x, y, z);
|
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);
|
this.#break_block(player, message.x, message.y, message.z);
|
||||||
}
|
}
|
||||||
break;
|
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":
|
case "use_block":
|
||||||
if (is_block_position(message.x, message.y, message.z) && faces.includes(message.face)) {
|
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);
|
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);
|
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) {
|
#use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) {
|
||||||
const offset = FACE_OFFSETS[face];
|
const offset = FACE_OFFSETS[face];
|
||||||
const [tx, ty, tz] = [x + offset.x, y + offset.y, z + offset.z];
|
const [tx, ty, tz] = [x + offset.x, y + offset.y, z + offset.z];
|
||||||
@@ -495,6 +550,12 @@ export class GameServer {
|
|||||||
return;
|
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
|
// place the held block against the face
|
||||||
const held_slot = player.inventory.get_slot(player.selected_slot);
|
const held_slot = player.inventory.get_slot(player.selected_slot);
|
||||||
const held = EverythingRegistry.get<ItemRegistry>("items", held_slot.type_id ?? "");
|
const held = EverythingRegistry.get<ItemRegistry>("items", held_slot.type_id ?? "");
|
||||||
@@ -596,6 +657,19 @@ export class GameServer {
|
|||||||
this.give(player, item_id, amount);
|
this.give(player, item_id, amount);
|
||||||
return;
|
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);
|
const mod_command = this.mods.commands.get(command);
|
||||||
if (mod_command) {
|
if (mod_command) {
|
||||||
run_guarded(mod_command.mod, `/${command}`, () => mod_command.command.run(args, this.mods.player(player)));
|
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) {
|
#replaceable(x: number, y: number, z: number) {
|
||||||
const nid = this.world.get_block_nid(x, y, z);
|
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) {
|
#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 {
|
function is_number(value: unknown): value is number {
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// runs mods' server scripts: builds each one's ServerContext and keeps what they register.
|
// 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
|
// see "Server scripts" in MODS.md. parts not built yet throw when used, saying so
|
||||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
import { ItemStack as EngineItemStack } from "$/common/inventory.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 {
|
import type {
|
||||||
BlockComponent,
|
BlockComponent,
|
||||||
BlockRef,
|
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 {
|
export function run_guarded<T>(mod: string, what: string, fn: () => T): T | undefined {
|
||||||
|
const previous = running_mod;
|
||||||
|
running_mod = mod;
|
||||||
try {
|
try {
|
||||||
return fn();
|
return fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[${mod}] ${what} threw:`, e);
|
console.error(`[${mod}] ${what} threw:`, e);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
running_mod = previous;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +102,8 @@ export class ModRuntime {
|
|||||||
storage: Record<string, Record<string, unknown>> = {};
|
storage: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
#timers = new Map<number, Timer>();
|
#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;
|
#next_timer = 1;
|
||||||
#setting_up = true;
|
#setting_up = true;
|
||||||
#players = new WeakMap<ServerPlayer, Player>();
|
#players = new WeakMap<ServerPlayer, Player>();
|
||||||
@@ -107,7 +117,12 @@ export class ModRuntime {
|
|||||||
if (typeof module.setup !== "function") {
|
if (typeof module.setup !== "function") {
|
||||||
throw new ModLoadError(mod, "the server script doesn't export a 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() {
|
tick() {
|
||||||
const now = this.game.current_tick;
|
const now = this.game.current_tick;
|
||||||
for (const [handle, timer] of [...this.#timers]) {
|
for (const [handle, timer] of [...this.#timers]) {
|
||||||
@@ -246,7 +291,7 @@ export class ModRuntime {
|
|||||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||||
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
|
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);
|
const existing = this.commands.get(name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
|
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);
|
game().set_block(x, y, z, id);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"),
|
get_state: (x, y, z, name) => {
|
||||||
set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"),
|
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,
|
get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never,
|
||||||
is_loaded: () => true,
|
is_loaded: () => true,
|
||||||
get seed() {
|
get seed() {
|
||||||
@@ -330,6 +391,19 @@ export class ModRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// each event's signal, as seen by one mod so errors say which mod threw
|
// 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 } }>(
|
function map_signals<T extends { [name: string]: { for_mod(mod: string): unknown } }>(
|
||||||
signals: T,
|
signals: T,
|
||||||
mod: string,
|
mod: string,
|
||||||
@@ -394,7 +468,7 @@ function player_api(game: GameServer, player: ServerPlayer): Player {
|
|||||||
const left = player.inventory.add_item(stack);
|
const left = player.inventory.add_item(stack);
|
||||||
return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined;
|
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 {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// runs the game in a worker without any permissions, the host does files and networking
|
// runs the game in a worker without any permissions, the host does files and networking
|
||||||
import type { GameServer } from "./game_server.ts";
|
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 { GameToHost, HostToGame } from "./host_protocol.ts";
|
||||||
import { data_url, start_game } from "./load_mods.ts";
|
import { data_url, start_game } from "./load_mods.ts";
|
||||||
|
|
||||||
@@ -68,7 +68,10 @@ async function init(message: Extract<HostToGame, { type: "init" }>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(() => game!.tick(), TICK_MS);
|
const loop = new GameLoop(() => game!.tick());
|
||||||
|
game.loop = loop;
|
||||||
|
loop.start();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (game!.world.dirty) {
|
if (game!.world.dirty) {
|
||||||
post({ type: "save", data: game!.save(), final: false });
|
post({ type: "save", data: game!.save(), final: false });
|
||||||
|
|||||||
+29
-14
@@ -3,7 +3,7 @@ import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.
|
|||||||
import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts";
|
import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts";
|
||||||
import { Container } from "$/common/inventory.ts";
|
import { Container } from "$/common/inventory.ts";
|
||||||
import { AIR_ID, BlockChange } from "$/common/protocol.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";
|
import { LruMap } from "./lru.ts";
|
||||||
|
|
||||||
// generation only, rebuilt when needed. 128 KB each
|
// generation only, rebuilt when needed. 128 KB each
|
||||||
@@ -31,6 +31,8 @@ export class ServerWorld {
|
|||||||
readonly seed: string;
|
readonly seed: string;
|
||||||
readonly block_ids: Record<string, number> = {};
|
readonly block_ids: Record<string, number> = {};
|
||||||
readonly worldgen: WorldgenSetup | undefined;
|
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
|
// what generation made, and the final blocks with neighbors' leaves and player changes applied
|
||||||
#raw = new LruMap<number, RawChunk>(RAW_CACHE_SIZE);
|
#raw = new LruMap<number, RawChunk>(RAW_CACHE_SIZE);
|
||||||
@@ -43,13 +45,14 @@ export class ServerWorld {
|
|||||||
// set when anything that gets saved changes
|
// set when anything that gets saved changes
|
||||||
dirty = false;
|
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) {
|
constructor(seed: string, worldgen?: WorldgenSetup) {
|
||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
this.worldgen = worldgen;
|
this.worldgen = worldgen;
|
||||||
EverythingRegistry.get_registry<BlockRegistry>("blocks").forEach((block, nid) => {
|
EverythingRegistry.get_registry<BlockRegistry>("blocks").forEach((block, nid) => {
|
||||||
this.block_ids[block.id] = 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;
|
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 {
|
get_block_info(x: number, y: number, z: number): BlockRegistry | undefined {
|
||||||
const nid = this.get_block_nid(x, y, z);
|
const nid = this.get_block_nid(x, y, z);
|
||||||
return nid === AIR ? undefined : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid);
|
return nid === AIR ? undefined : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// only changes the block, the game server runs behaviors and tiles
|
// 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) {
|
set_block(x: number, y: number, z: number, id: string, state?: number) {
|
||||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -82,15 +95,17 @@ export class ServerWorld {
|
|||||||
if (nid === undefined) {
|
if (nid === undefined) {
|
||||||
throw new Error(`Unknown block ${id}`);
|
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_x = Math.floor(x / CHUNK_SIZE);
|
||||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
const blocks = this.#get_chunk(chunk_x, chunk_z);
|
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.dirty = true;
|
||||||
this.on_block_change?.(x, y, z, id);
|
this.on_block_change?.(x, y, z, id, state_bits);
|
||||||
}
|
}
|
||||||
|
|
||||||
all_changes(): BlockChange[] {
|
all_changes(): BlockChange[] {
|
||||||
@@ -102,8 +117,8 @@ export class ServerWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load_changes(changes: BlockChange[]) {
|
load_changes(changes: BlockChange[]) {
|
||||||
for (const [x, y, z, id] of changes) {
|
for (const [x, y, z, id, state] of changes) {
|
||||||
this.#record_change(x, y, z, id);
|
this.#record_change(x, y, z, id, state ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,21 +136,21 @@ export class ServerWorld {
|
|||||||
this.dirty = true;
|
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));
|
const key = chunk_key(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||||
let chunk_changes = this.#changes.get(key);
|
let chunk_changes = this.#changes.get(key);
|
||||||
if (!chunk_changes) {
|
if (!chunk_changes) {
|
||||||
chunk_changes = new Map();
|
chunk_changes = new Map();
|
||||||
this.#changes.set(key, chunk_changes);
|
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) {
|
#get_raw(chunk_x: number, chunk_z: number) {
|
||||||
const key = chunk_key(chunk_x, chunk_z);
|
const key = chunk_key(chunk_x, chunk_z);
|
||||||
let raw = this.#raw.get(key);
|
let raw = this.#raw.get(key);
|
||||||
if (!raw) {
|
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);
|
this.#raw.set(key, raw);
|
||||||
}
|
}
|
||||||
return 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];
|
const nid = id === AIR_ID ? AIR : this.block_ids[id];
|
||||||
if (nid !== undefined) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import type { ServerContext } from "bworld/server";
|
|||||||
export function setup(ctx: ServerContext) {
|
export function setup(ctx: ServerContext) {
|
||||||
// used by blocks/example_block.json, which passes { message } as params
|
// used by blocks/example_block.json, which passes { message } as params
|
||||||
ctx.components.register_block<{ message: string }>("example_mod:announce", {
|
ctx.components.register_block<{ message: string }>("example_mod:announce", {
|
||||||
on_interact(_block, params, player) {
|
// block data is saved with the world. on_tick and on_second only run for blocks that have some
|
||||||
player.send_message(params.message);
|
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
|
// handled, so right clicking it doesn't place a block
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -83,7 +83,11 @@ Deno.test("a mod made from the template loads and runs", async () => {
|
|||||||
take(1);
|
take(1);
|
||||||
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
|
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
|
||||||
const messages = take(1);
|
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");
|
assertEquals(game.world.get_block_id(1, y + 2, 1), "bworld:air");
|
||||||
|
|
||||||
// the shaped recipe: 4 example items in a square
|
// the shaped recipe: 4 example items in a square
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user