Actually implement mod code into server

This commit is contained in:
2026-09-25 23:13:46 -03:00
parent 099811670f
commit 9237152aa1
22 changed files with 1359 additions and 457 deletions
+65 -31
View File
@@ -394,6 +394,7 @@ interface ServerContext {
world: ServerWorld; world: ServerWorld;
players: PlayerList; players: PlayerList;
containers: ContainerApi; containers: ContainerApi;
items: ItemApi;
recipes: RecipeApi; recipes: RecipeApi;
ui: ServerUi; // see GUIs ui: ServerUi; // see GUIs
net: ServerNet; // see Mod channels net: ServerNet; // see Mod channels
@@ -477,6 +478,7 @@ interface ServerWorld {
get_state(x: number, y: number, z: number, name: string): number | undefined; get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean; set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<T>(x: number, y: number, z: number): T | undefined; get_block_data<T>(x: number, y: number, z: number): T | undefined;
drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops
is_loaded(x: number, z: number): boolean; is_loaded(x: number, z: number): boolean;
readonly seed: string; readonly seed: string;
} }
@@ -612,8 +614,8 @@ code sees it.
### 2. Container screens (server only) ### 2. Container screens (server only)
For inventories and machines. The server owns the container's contents and syncs them to every player viewing it. Clicks For inventories and machines. The server owns the container's contents and syncs them to every player viewing it. Clicks
go to the server, which applies them. The chest and furnace would be rebuilt this way instead of their hand-written go to the server, which applies them. The base game's chest and furnace are built this way, see
`GuiChest` / `GuiFurnace` classes. `mods/bworld/scripts/server.ts`.
```ts ```ts
ctx.components.register_block("copper_tools:smelter", { ctx.components.register_block("copper_tools:smelter", {
@@ -623,36 +625,50 @@ ctx.components.register_block("copper_tools:smelter", {
on_interact(block, _params, player) { on_interact(block, _params, player) {
const container = ctx.containers.get(block.data.container)!; const container = ctx.containers.get(block.data.container)!;
const screen = ctx.ui.open_container(player, { const screen = ctx.ui.open_container(player, {
title: "Smelter", container,
layout: [ layout: [
{ slot: 0, x: 3, y: 0, filter: "smeltable" }, { slot: 0, x: 3, y: 0, filter: "smeltable" },
{ slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) }, { slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) },
{ slot: 2, x: 5, y: 1, output_only: true }, { slot: 2, x: 5, y: 1, output_only: true },
], ],
container, bars: [{
player_inventory: true, x: 4,
bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], y: 1,
value: "progress",
max: "progress_max",
direction: "right",
empty_texture: "bworld:arrow_empty",
full_texture: "bworld:arrow_full",
}],
}); });
screen.set_property("progress", block.data.progress); screen.set_property("progress", block.data.progress);
screen.set_property("progress_max", 200);
return true; return true;
}, },
on_break(block) {
ctx.containers.delete(block.data.container);
},
}); });
``` ```
- `x` / `y` are in slot units on a grid. The client scales and centers the screen, and draws the player's inventory - The screen is drawn below the player's inventory and hotbar. `x` / `y` are in slot units in the screen's own area and
below it when `player_inventory` is true. can be fractional. `rows` sets its height, by default it fits the slots and bars.
- `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and - `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and
`"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client. `"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client.
- `bars` are progress bars filled from 0 to 1 by `screen.set_property(id, value)`. Properties can also be shown as text `output_only` slots can only be taken from, all at once, like the furnace result.
with `labels: [{ x, y, property }]`. - `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`.
- The screen handle has `set_property`, `close()` and `on_close(fn)`. Changes to the container (from scripts, hoppers, `direction` is `"up"` (like the furnace's fire) or `"right"` (like its arrow). Properties are synced when they change,
so a component can update them every tick.
- The screen handle has `player`, `open`, `set_property`, `close()` and `on_close(fn)`. It closes when the player closes
it, leaves, or opens another screen, and when its container is deleted. Changes to the container (from scripts,
other players) show up for everyone viewing it. other players) show up for everyone viewing it.
- Containers are saved with the world until they're deleted, so a block that has one deletes it in `on_break`.
```ts ```ts
interface ContainerApi { interface ContainerApi {
create(size: number): Container; // saved with the world create(size: number): Container; // 1 to 256 slots, saved with the world
get(id: string): Container | undefined; get(id: string): Container | undefined;
delete(id: string): void; delete(id: string): void; // closes screens showing it
} }
interface Container { interface Container {
@@ -663,8 +679,15 @@ interface Container {
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
on_change(fn: (slot: number) => void): () => void; on_change(fn: (slot: number) => void): () => void;
} }
interface ItemApi {
exists(id: string): boolean;
max_stack(id: string): number;
}
``` ```
Items handed to scripts are live: setting `count` changes the stack, and a stack counted down to 0 is gone.
### 3. Custom screens (client code) ### 3. Custom screens (client code)
For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a
@@ -1036,15 +1059,21 @@ export function setup(ctx: ServerContext) {
on_interact(block, _params, player) { on_interact(block, _params, player) {
const data = block.data as SmelterData; const data = block.data as SmelterData;
ctx.ui.open_container(player, { ctx.ui.open_container(player, {
title: "Smelter",
container: ctx.containers.get(data.container)!, container: ctx.containers.get(data.container)!,
layout: [ layout: [
{ slot: 0, x: 3, y: 0, filter: "smeltable" }, { slot: 0, x: 3, y: 0, filter: "smeltable" },
{ slot: 1, x: 3, y: 2, filter: "fuel" }, { slot: 1, x: 3, y: 2, filter: "fuel" },
{ slot: 2, x: 5, y: 1, output_only: true }, { slot: 2, x: 5, y: 1, output_only: true },
], ],
player_inventory: true, bars: [{
bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], x: 4,
y: 1,
value: "progress",
max: "progress_max",
direction: "right",
empty_texture: "bworld:arrow_empty",
full_texture: "bworld:arrow_full",
}],
}).set_property("progress", data.progress); }).set_property("progress", data.progress);
return true; return true;
}, },
@@ -1126,10 +1155,10 @@ anything the base game does.
Not moved: Not moved:
- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt - **Carrots, potatoes, tomatoes and pumpkins** (`common/blocks/crops.ts`) are unfinished and mostly commented out.
as a component once server scripts exist, as a good first test of tile data plus ticking. Wheat uses the `bworld:crop` component instead, and they can too once they have blocks.
- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves - **The watering can's lore** moved into its component's `get_lore`, but it isn't shown anywhere yet, because the game
once tooltips exist. has no item tooltips.
### What stays in the engine ### What stays in the engine
@@ -1150,7 +1179,8 @@ Worlds saved before the move must load afterwards with nothing changed:
3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main` 3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main`
plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved
by the current code load into them. by the current code load into them.
4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod. 4. **The protocol doesn't change, and old saves load.** Clients receive `mods/bworld` like any other mod. The save
format may change when it has to, as long as older saves are upgraded when loading (phase 2 did, to version 3).
### Phases ### Phases
@@ -1180,14 +1210,17 @@ loading mods (the base game and the template), their scripts and worldgen, saves
- Delete `common/blocks/` and `common/items/`. - Delete `common/blocks/` and `common/items/`.
- Check: the registry built from JSON equals `registry.json`, and all tests pass. - Check: the registry built from JSON equals `registry.json`, and all tests pass.
**Phase 2: behavior** (after step 6, and container screens from step 7). **Phase 2: behavior** (after step 6, and container screens from step 7). _Done._
- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and - `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, `bworld:crop` (bone
`bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the meal on wheat) and `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, with
`ScreenLayout`s the server sends now. the same layouts as before.
- The block JSON lists the components. Delete `server/game/blocks.ts`. - The block and item JSON lists the components. `server/game/blocks.ts` is deleted.
- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and - Saves went to version 3: tiles only keep `mod_data`, and containers are saved by id. Version 2 chests and furnaces are
`world_v2.json` still pass. upgraded when loaded: their `data` fields stay and their `containers.main` becomes a container whose id is in
`container`, which is the shape the components use.
- Check: `tests/base_game_test.ts` (hoeing, chest, furnace smelting, breaking a chest gives its contents back, bone meal,
the watering can) and `tests/fixtures/world_v2.json`, saved by the engine code before the move, pass.
**Phase 3: world generation** (after step 9). **Phase 3: world generation** (after step 9).
@@ -1239,10 +1272,11 @@ working:
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. _Done_ (`server/game/mod_runtime.ts`, with the loop in `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 `server/game/game_loop.ts`). `ctx.net` throws until step 8, and so do the client's `ctx.ui`, `ctx.hud`,
client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`. `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. _Container screens done_ (`ctx.containers`,
`ctx.ui.open_container`), forms and custom screens throw until they're built.
8. **Mod channels** and keybinds. 8. **Mod channels** and keybinds.
9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing 9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing
before generating anything. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base before generating anything. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base
+8 -4
View File
@@ -49,7 +49,7 @@ export class ContainerSlot {
#item_stack: ItemStack | undefined; #item_stack: ItemStack | undefined;
has_item() { has_item() {
return this.#item_stack !== undefined; return this.get_item() !== undefined;
} }
set_item(item_stack: ItemStack | undefined) { set_item(item_stack: ItemStack | undefined) {
@@ -60,11 +60,15 @@ export class ContainerSlot {
} }
get_item() { get_item() {
// scripts can count a stack down to nothing, it's gone then
if (this.#item_stack && this.#item_stack.amount <= 0) {
this.#item_stack = undefined;
}
return this.#item_stack; return this.#item_stack;
} }
get type_id() { get type_id() {
return this.#item_stack?.type_id; return this.get_item()?.type_id;
} }
set amount(new_amount: number) { set amount(new_amount: number) {
@@ -77,11 +81,11 @@ export class ContainerSlot {
} }
get amount(): number | undefined { get amount(): number | undefined {
return this.#item_stack?.amount; return this.get_item()?.amount;
} }
get max_amount() { get max_amount() {
return this.#item_stack?.max_amount; return this.get_item()?.max_amount;
} }
} }
+34 -6
View File
@@ -12,6 +12,7 @@ export interface ServerContext {
world: ServerWorld; world: ServerWorld;
players: PlayerList; players: PlayerList;
containers: ContainerApi; containers: ContainerApi;
items: ItemApi;
recipes: RecipeApi; recipes: RecipeApi;
ui: ServerUi; ui: ServerUi;
net: ServerNet; net: ServerNet;
@@ -123,6 +124,8 @@ export interface ServerWorld {
get_state(x: number, y: number, z: number, name: string): number | undefined; get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean; set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<T>(x: number, y: number, z: number): T | undefined; get_block_data<T>(x: number, y: number, z: number): T | undefined;
// pops an item out of the block at x y z, like a broken block's drops
drop_item(x: number, y: number, z: number, item: ItemStack): void;
is_loaded(x: number, z: number): boolean; is_loaded(x: number, z: number): boolean;
readonly seed: string; readonly seed: string;
} }
@@ -177,6 +180,12 @@ export interface ContainerApi {
delete(id: string): void; delete(id: string): void;
} }
export interface ItemApi {
exists(id: Id): boolean;
// how many fit in one slot
max_stack(id: Id): number;
}
export interface RecipeApi { export interface RecipeApi {
furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined; furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined;
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
@@ -214,20 +223,39 @@ export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: fal
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean); export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
export interface ContainerScreenOptions { export interface ContainerScreenOptions {
title: string;
container: Container; container: Container;
// x and y in slot units // x and y in slot units, below the player's inventory. can be fractional
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[]; layout: {
player_inventory?: boolean; slot: number;
bars?: { id: string; x: number; y: number; texture: Id }[]; x: number;
labels?: { x: number; y: number; property: string }[]; y: number;
// what can be put in, checked on the server
filter?: SlotFilter;
// can only be taken from, all at once, like the furnace result
output_only?: boolean;
}[];
// height of the screen's area in slots, fits the layout and bars when not set
rows?: number;
// progress bars, filled with the property value divided by the property max
bars?: {
x: number;
y: number;
value: string;
max: string;
direction: "up" | "right";
empty_texture: Id;
full_texture: Id;
}[];
} }
export interface ScreenHandle<Props = unknown> { export interface ScreenHandle<Props = unknown> {
readonly player: Player; readonly player: Player;
readonly open: boolean;
// numbers the bars show, synced to the player when they change
set_property(id: string, value: number): void; set_property(id: string, value: number): void;
update(props: Props): void; // custom screens only update(props: Props): void; // custom screens only
close(): void; close(): void;
// when it closes for any reason: the player closed it, left, opened another screen, or it was closed
on_close(fn: () => void): void; on_close(fn: () => void): void;
} }
+6 -1
View File
@@ -8,6 +8,11 @@
"tool": "axe" "tool": "axe"
}, },
"drops": "bworld:chest", "drops": "bworld:chest",
"interactive": true "interactive": true,
"components": {
"bworld:storage": {
"rows": 3
}
}
} }
} }
+7 -1
View File
@@ -7,6 +7,12 @@
"toughness": 2, "toughness": 2,
"tool": "shovel" "tool": "shovel"
}, },
"drops": "bworld:dirt" "drops": "bworld:dirt",
"components": {
"bworld:hoeable": {
"tool": "bworld:hoe",
"into": "bworld:hoed_dirt"
}
}
} }
} }
+4 -1
View File
@@ -12,6 +12,9 @@
"requires_tool": true "requires_tool": true
}, },
"drops": "bworld:furnace", "drops": "bworld:furnace",
"interactive": true "interactive": true,
"components": {
"bworld:furnace": {}
}
} }
} }
+7 -1
View File
@@ -12,6 +12,12 @@
"tool": "shovel" "tool": "shovel"
}, },
"drops": "bworld:dirt", "drops": "bworld:dirt",
"item": false "item": false,
"components": {
"bworld:hoeable": {
"tool": "bworld:hoe",
"into": "bworld:hoed_dirt"
}
}
} }
} }
+7 -1
View File
@@ -61,6 +61,12 @@
"bits": 3, "bits": 3,
"default": 0 "default": 0
} }
] ],
"components": {
"bworld:crop": {
"fertilizer": "bworld:bone_meal",
"max_age": 7
}
}
} }
} }
+6 -1
View File
@@ -2,6 +2,11 @@
"format_version": 1, "format_version": 1,
"item": { "item": {
"id": "bworld:watering_can", "id": "bworld:watering_can",
"texture": "bworld:watering_can" "texture": "bworld:watering_can",
"components": {
"bworld:watering_can": {
"max_water": 32
}
}
} }
} }
+7 -2
View File
@@ -4,7 +4,12 @@
"name": "bworld", "name": "bworld",
"description": "The base game: its blocks, items, recipes and world.", "description": "The base game: its blocks, items, recipes and world.",
"version": "0.1.0", "version": "0.1.0",
"authors": ["paula"], "authors": [
"paula"
],
"game_version": ">=0.1.0", "game_version": ">=0.1.0",
"credits": "CREDITS.md" "credits": "CREDITS.md",
"scripts": {
"server": "scripts/server.ts"
}
} }
+234
View File
@@ -0,0 +1,234 @@
// the base game's block and item behavior, written against the same api as any other mod
import type { BlockRef, Container, ScreenHandle, ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
register_hoeable(ctx);
register_storage(ctx);
register_furnace(ctx);
register_crop(ctx);
register_watering_can(ctx);
}
// right clicking with a tool turns the block into another, like a hoe on grass
function register_hoeable(ctx: ServerContext) {
ctx.components.register_block<{ tool: string; into: string }>("bworld:hoeable", {
on_interact(block, params, player) {
if (player.held_item?.id !== params.tool) {
return false;
}
ctx.world.set_block(block.x, block.y, block.z, params.into);
return true;
},
});
}
// the container a block keeps its items in, made the first time it's needed. blocks placed before tile data
// existed don't have one yet
function block_container(ctx: ServerContext, block: BlockRef, size: number): Container {
const existing = block.data?.container && ctx.containers.get(block.data.container);
if (existing) {
return existing;
}
const container = ctx.containers.create(size);
block.data = { ...block.data, container: container.id };
return container;
}
// what was inside falls out, like minecraft's Containers.dropContents
function drop_contents(ctx: ServerContext, block: BlockRef) {
const container = block.data?.container && ctx.containers.get(block.data.container);
if (!container) {
return;
}
for (let slot = 0; slot < container.size; slot++) {
const item = container.get(slot);
if (item) {
ctx.world.drop_item(block.x, block.y, block.z, item);
}
}
ctx.containers.delete(container.id);
}
// a chest
function register_storage(ctx: ServerContext) {
ctx.components.register_block<{ rows: number }>("bworld:storage", {
on_create(block, params) {
block_container(ctx, block, params.rows * 9);
},
on_interact(block, params, player) {
const size = params.rows * 9;
ctx.ui.open_container(player, {
container: block_container(ctx, block, size),
layout: Array.from({ length: size }, (_, slot) => ({ slot, x: slot % 9, y: Math.floor(slot / 9) })),
rows: params.rows,
});
return true;
},
on_break(block) {
drop_contents(ctx, block);
},
});
}
// furnace
// slot 0 is what's smelting, 1 the fuel, 2 the result
interface FurnaceData {
container: string;
// ticks
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
}
const FURNACE_BARS = [
{
x: 3.5,
y: 1,
value: "fuel",
max: "fuel_max",
direction: "up" as const,
empty_texture: "bworld:fire_empty",
full_texture: "bworld:fire_full",
},
{
x: 4.5,
y: 1,
value: "progress",
max: "progress_max",
direction: "right" as const,
empty_texture: "bworld:arrow_empty",
full_texture: "bworld:arrow_full",
},
];
function register_furnace(ctx: ServerContext) {
// who's looking at each furnace, by position, to keep their bars moving
const viewers = new Map<string, Set<ScreenHandle>>();
const key = (block: BlockRef) => `${block.x},${block.y},${block.z}`;
const furnace_data = (block: BlockRef): FurnaceData => {
const container = block_container(ctx, block, 3);
block.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0, ...block.data, container: container.id };
return block.data;
};
const show = (screen: ScreenHandle, data: FurnaceData) => {
screen.set_property("progress", data.progress);
screen.set_property("progress_max", data.progress_max);
screen.set_property("fuel", data.fuel);
screen.set_property("fuel_max", data.fuel_max);
};
// there's somewhere for the result to go
const can_smelt = (container: Container, output: { id: string; count: number }) => {
const result = container.get(2);
return !result || (result.id === output.id && result.count < ctx.items.max_stack(result.id));
};
ctx.components.register_block("bworld:furnace", {
on_create(block) {
furnace_data(block);
},
on_interact(block, _params, player) {
const data = furnace_data(block);
const screen = ctx.ui.open_container(player, {
container: ctx.containers.get(data.container)!,
layout: [
{ slot: 0, x: 3.5, y: 0 },
{ slot: 1, x: 3.5, y: 2, filter: "fuel" },
{ slot: 2, x: 5.5, y: 1, output_only: true },
],
rows: 3,
bars: FURNACE_BARS,
});
show(screen, data);
const screens = viewers.get(key(block)) ?? new Set();
viewers.set(key(block), screens);
screens.add(screen);
screen.on_close(() => screens.delete(screen));
return true;
},
on_break(block) {
drop_contents(ctx, block);
},
on_tick(block) {
const data = furnace_data(block);
const container = ctx.containers.get(data.container)!;
const input = container.get(0);
const recipe = input && ctx.recipes.furnace_result(input.id);
if (data.fuel > 0) {
data.fuel -= 1;
}
if (!recipe || !can_smelt(container, recipe.output)) {
data.progress = 0;
} else {
// refuel
const fuel = container.get(1);
if (data.fuel === 0 && fuel && ctx.recipes.is_fuel(fuel.id)) {
data.fuel = ctx.recipes.fuel_value(fuel.id);
data.fuel_max = data.fuel;
fuel.count -= 1;
}
// cook
if (data.fuel > 0) {
data.progress_max = recipe.cook_time;
data.progress += 1;
if (data.progress >= recipe.cook_time) {
data.progress = 0;
const result = container.get(2);
if (result) {
result.count += recipe.output.count;
} else {
container.set(2, recipe.output);
}
input.count -= 1;
}
}
}
for (const screen of viewers.get(key(block)) ?? []) show(screen, data);
},
});
}
// grows one stage each time it's right clicked with the fertilizer, like bone meal on wheat
function register_crop(ctx: ServerContext) {
ctx.components.register_block<{ fertilizer: string; max_age: number }>("bworld:crop", {
on_interact(block, params, player) {
const held = player.held_item;
if (held?.id !== params.fertilizer) {
return false;
}
const age = ctx.world.get_state(block.x, block.y, block.z, "age") ?? 0;
// fully grown, keep the fertilizer
if (age >= params.max_age) {
return false;
}
ctx.world.set_state(block.x, block.y, block.z, "age", age + 1);
held.count -= 1;
return true;
},
});
}
interface WateringCanData {
water: number;
max_water: number;
}
function register_watering_can(ctx: ServerContext) {
ctx.components.register_item<{ max_water: number }>("bworld:watering_can", {
on_create(item, params) {
item.data = { water: 0, max_water: params.max_water } satisfies WateringCanData;
},
get_lore(item) {
const data = item.data as WateringCanData;
return `Water: ${data.water}/${data.max_water}`;
},
});
}
-287
View File
@@ -1,287 +0,0 @@
import { Container, ItemStack } from "$/common/inventory.ts";
import { ScreenLayout } from "$/common/protocol.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { get_state_value, set_state_value } from "$/common/utils.ts";
import type { RecipeBook } from "$/common/mod_loader.ts";
import type { GameServer } from "./game_server.ts";
import type { ServerPlayer } from "./player.ts";
import type { Tile } from "./world.ts";
// what blocks do, only on the server. blocks without an entry just sit there
export interface BlockBehavior {
// set up a tile for this block. blocks with this get a tile when placed
create_tile?(tile: Tile): void;
// right click, return true if it did something so no block gets placed
on_interact?(
game: GameServer,
block: { x: number; y: number; z: number; id: string },
player: ServerPlayer,
): boolean;
on_break?(game: GameServer, tile: Tile, player: ServerPlayer | undefined): void;
on_tick?(game: GameServer, tile: Tile): void;
on_second?(game: GameServer, tile: Tile): void;
}
export const BLOCK_BEHAVIORS: Record<string, BlockBehavior> = {};
// whatever was inside falls out, like minecraft's Containers.dropContents
function drop_container_contents(game: GameServer, tile: Tile) {
for (const container of Object.values(tile.containers)) {
for (let i = 0; i < container.size; i++) {
const item = container.get_item(i);
if (item) {
container.set_item(i, undefined);
game.pop_item(tile.x, tile.y, tile.z, item);
}
}
}
}
function hoe_into_hoed_dirt(
game: GameServer,
block: { x: number; y: number; z: number },
player: ServerPlayer,
): boolean {
if (player.held_item?.type_id !== "bworld:hoe") {
return false;
}
game.set_block(block.x, block.y, block.z, "bworld:hoed_dirt", player);
return true;
}
BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt };
BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt };
// crops
// minecraft's bone meal: a crop grows 2 to 5 stages at once, and the bone meal is used up
function grow_with_bone_meal(
game: GameServer,
block: { x: number; y: number; z: number; id: string },
player: ServerPlayer,
): boolean {
const held = player.held_item;
if (held?.type_id !== "bworld:bone_meal") {
return false;
}
const info = EverythingRegistry.get<BlockRegistry>("blocks", block.id)!;
const value = game.world.get_block_value(block.x, block.y, block.z);
const age = get_state_value(value, info, "age")!;
const max_age = 2 ** info.states!.find((s) => s.name === "age")!.bits - 1;
// fully grown, keep the bone meal
if (age >= max_age) {
return false;
}
const new_age = Math.min(age + 1, max_age);
game.set_block_state(block.x, block.y, block.z, set_state_value(value, info, "age", new_age)! >>> 16);
const slot = player.inventory.get_slot(player.selected_slot);
slot.amount = slot.amount! - 1;
return true;
}
BLOCK_BEHAVIORS["bworld:wheat"] = { on_interact: grow_with_bone_meal };
// chest
const CHEST_LAYOUT: ScreenLayout = {
rows: 3,
slots: Array.from({ length: 9 * 3 }, (_, index) => ({ index, x: index % 9, y: Math.floor(index / 9) })),
bars: [],
};
BLOCK_BEHAVIORS["bworld:chest"] = {
create_tile(tile) {
tile.containers.main = new Container(9 * 3);
},
on_interact(game, block, player) {
const tile = game.get_or_create_tile(block.x, block.y, block.z);
game.open_screen(player, {
tile,
container: tile.containers.main,
layout: CHEST_LAYOUT,
properties: () => ({}),
});
return true;
},
on_break(game, tile) {
drop_container_contents(game, tile);
},
};
// furnace
interface FurnaceData {
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
}
interface FurnaceRecipe {
output: { id: string; count: number };
cook_time: number;
}
function get_recipe(recipes: RecipeBook, input?: ItemStack | undefined): FurnaceRecipe | undefined {
if (!input) {
return;
}
return recipes.furnace.get(input.type_id);
}
function can_craft(container: Container, recipe?: FurnaceRecipe) {
if (!recipe) {
return false;
}
const output = container.get_item(2);
if (!output) {
return true;
}
if (output.type_id !== recipe.output.id) {
return false;
}
return output.amount < output.max_amount;
}
function get_fuel_value(recipes: RecipeBook, item?: ItemStack | undefined): number {
if (!item) {
return 0;
}
return recipes.fuel.get(item.type_id) ?? 0;
}
function has_fuel(recipes: RecipeBook, container: Container) {
return get_fuel_value(recipes, container.get_item(1)) > 0;
}
function consume_fuel(recipes: RecipeBook, container: Container): number {
const fuel = container.get_slot(1)!;
const value = get_fuel_value(recipes, fuel.get_item());
if (fuel.has_item()) {
fuel.amount! -= 1;
}
return value;
}
function craft(container: Container, recipe: FurnaceRecipe) {
const input = container.get_item(0)!;
const output = container.get_item(2);
if (!output) {
container.set_item(2, new ItemStack(recipe.output.id, recipe.output.count));
} else {
output.amount += recipe.output.count;
}
input.amount -= 1;
container.set_item(0, input.amount > 0 ? input : undefined);
}
const FURNACE_LAYOUT: ScreenLayout = {
rows: 3,
slots: [
{ index: 0, x: 3.5, y: 0 },
{ index: 1, x: 3.5, y: 2 },
{ index: 2, x: 5.5, y: 1, output: true },
],
bars: [
{
x: 3.5,
y: 1,
value: "fuel",
max: "fuel_max",
direction: "up",
empty_texture: "bworld:fire_empty",
full_texture: "bworld:fire_full",
},
{
x: 4.5,
y: 1,
value: "progress",
max: "progress_max",
direction: "right",
empty_texture: "bworld:arrow_empty",
full_texture: "bworld:arrow_full",
},
],
};
BLOCK_BEHAVIORS["bworld:furnace"] = {
create_tile(tile) {
tile.containers.main = new Container(3);
tile.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0 } satisfies FurnaceData;
},
on_interact(game, block, player) {
const tile = game.get_or_create_tile(block.x, block.y, block.z);
const data = tile.data as unknown as FurnaceData;
game.open_screen(player, {
tile,
container: tile.containers.main,
layout: FURNACE_LAYOUT,
properties: () => ({ ...data }),
});
return true;
},
on_break(game, tile) {
drop_container_contents(game, tile);
},
on_tick(game, tile) {
const data = tile.data as unknown as FurnaceData;
const container = tile.containers.main;
const input = container.get_item(0);
const recipe = get_recipe(game.recipes, input);
// burn fuel
if (data.fuel > 0) {
data.fuel -= 1;
}
if (!can_craft(container, recipe)) {
data.progress = 0;
return;
}
// refuel
if (data.fuel === 0 && has_fuel(game.recipes, container)) {
data.fuel = consume_fuel(game.recipes, container);
data.fuel_max = data.fuel;
}
// cook
if (data.fuel > 0 && recipe) {
data.progress_max = recipe.cook_time;
data.progress += 1;
if (data.progress >= recipe.cook_time) {
data.progress = 0;
craft(container, recipe);
}
}
},
};
// items that still need code, until they're components in mods/bworld (phase 2 in MODS.md)
export interface WateringCanData {
water: number;
max_water: number;
}
export function add_base_item_hooks() {
const watering_can = EverythingRegistry.get<ItemRegistry<WateringCanData>>("items", "bworld:watering_can");
if (watering_can) {
watering_can.on_create = (item) => {
item.data = { water: 0, max_water: 32 };
};
watering_can.get_lore = (item) => `Water: ${item.data?.water}/${item.data?.max_water}`;
}
}
+117 -67
View File
@@ -26,7 +26,6 @@ import {
} from "$/common/protocol.ts"; } from "$/common/protocol.ts";
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts"; import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { WorldgenSetup } from "$/common/generation.ts"; import type { WorldgenSetup } from "$/common/generation.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 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";
@@ -61,19 +60,12 @@ export interface GameHost {
} }
export interface SavedWorld { export interface SavedWorld {
version: 2; version: 3;
seed: string; seed: string;
changes: BlockChange[]; changes: BlockChange[];
tiles: { tiles: SavedTile[];
id: string; // every ctx.containers container, by id
x: number; containers?: Record<string, (ItemData | null)[]>;
y: number;
z: number;
data: Record<string, unknown>;
containers: Record<string, (ItemData | null)[]>;
// a mod block's data, what BlockRef.data holds
mod_data?: unknown;
}[];
players: Record<string, SavedPlayer>; players: Record<string, SavedPlayer>;
// items on the ground // items on the ground
entities?: SavedItemEntity[]; entities?: SavedItemEntity[];
@@ -81,6 +73,18 @@ export interface SavedWorld {
mod_storage?: Record<string, Record<string, unknown>>; mod_storage?: Record<string, Record<string, unknown>>;
} }
interface SavedTile {
id: string;
x: number;
y: number;
z: number;
// what BlockRef.data holds
mod_data?: unknown;
// version 2, when chests and furnaces were engine code: their fields and their items
data?: Record<string, unknown>;
containers?: Record<string, (ItemData | null)[]>;
}
// the loaded mods, see load_mods.ts // the loaded mods, see load_mods.ts
export interface GameMods { export interface GameMods {
listings: ModListing[]; listings: ModListing[];
@@ -102,6 +106,8 @@ export class GameServer {
#entities = new Map<string, ItemEntity>(); #entities = new Map<string, ItemEntity>();
#next_entity_id = 1; #next_entity_id = 1;
#mods: GameMods; #mods: GameMods;
// ctx.containers, saved with the world
containers = new Map<string, Container>();
mods: ModRuntime; mods: ModRuntime;
recipes: RecipeBook; recipes: RecipeBook;
// what runs tick(), for /tps. tests call tick() themselves // what runs tick(), for /tps. tests call tick() themselves
@@ -118,20 +124,20 @@ export class GameServer {
this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen); this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen);
this.mods.storage = saved?.mod_storage ?? {}; this.mods.storage = saved?.mod_storage ?? {};
this.world.load_changes(saved?.changes ?? []); this.world.load_changes(saved?.changes ?? []);
for (const [id, items] of Object.entries(saved?.containers ?? {})) {
const container = new Container(items.length);
container.load(items);
this.containers.set(id, container);
}
for (const tile of saved?.tiles ?? []) { for (const tile of saved?.tiles ?? []) {
const containers: Record<string, Container> = {}; this.world.add_tile({ id: tile.id, x: tile.x, y: tile.y, z: tile.z, mod_data: this.#upgrade_tile(tile) });
for (const [name, items] of Object.entries(tile.containers)) {
containers[name] = new Container(items.length);
containers[name].load(items);
}
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
} }
for (const entity of saved?.entities ?? []) { for (const entity of saved?.entities ?? []) {
const item = ItemEntity.load(this.#new_entity_id(), entity); const item = ItemEntity.load(this.#new_entity_id(), entity);
this.#entities.set(item.id, item); this.#entities.set(item.id, item);
} }
this.#saved_players = saved?.players ?? {}; this.#saved_players = saved?.players ?? {};
this.world.dirty = saved?.version !== 2; this.world.dirty = saved?.version !== 3;
this.world.on_block_change = (x, y, z, id, state) => 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 }); this.#broadcast(state ? { type: "set_block", x, y, z, id, state } : { type: "set_block", x, y, z, id });
@@ -185,28 +191,21 @@ export class GameServer {
const second = this.#tick % TICKS_PER_SECOND === 0; const second = this.#tick % TICKS_PER_SECOND === 0;
for (const tile of [...this.world.tiles.values()]) { for (const tile of [...this.world.tiles.values()]) {
const behavior = BLOCK_BEHAVIORS[tile.id]; if (tile.mod_data === undefined) {
continue;
}
const components = this.mods.components_of(tile.id) const components = this.mods.components_of(tile.id)
.filter(({ component }) => component.on_tick || component.on_second); .filter(({ component }) => component.on_tick || component.on_second);
if (!behavior?.on_tick && !behavior?.on_second && components.length === 0) { if (components.length === 0 || !this.#near_any_player(tile.x, tile.z)) {
continue; continue;
} }
if (!this.#near_any_player(tile.x, tile.z)) { const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id);
continue; for (const { mod, id, component, params } of components) {
} if (component.on_tick) {
behavior?.on_tick?.(this, tile); run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA));
if (second) { }
behavior?.on_second?.(this, tile); if (second && component.on_second) {
} run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1));
if (components.length > 0 && tile.mod_data !== undefined) {
const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id);
for (const { mod, id, component, params } of components) {
if (component.on_tick) {
run_guarded(mod, `${id} on_tick`, () => component.on_tick!(block, params, TICK_DELTA));
}
if (second && component.on_second) {
run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1));
}
} }
} }
// tile data can change on any tick, saving is cheap enough to not track it exactly // tile data can change on any tick, saving is cheap enough to not track it exactly
@@ -235,7 +234,7 @@ export class GameServer {
this.#saved_players[player.name] = player.save(); this.#saved_players[player.name] = player.save();
} }
const saved: SavedWorld = { const saved: SavedWorld = {
version: 2, version: 3,
seed: this.world.seed, seed: this.world.seed,
changes: this.world.all_changes(), changes: this.world.all_changes(),
tiles: [...this.world.tiles.values()].map((tile) => ({ tiles: [...this.world.tiles.values()].map((tile) => ({
@@ -243,12 +242,11 @@ export class GameServer {
x: tile.x, x: tile.x,
y: tile.y, y: tile.y,
z: tile.z, z: tile.z,
data: tile.data,
containers: Object.fromEntries(
Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]),
),
mod_data: tile.mod_data, mod_data: tile.mod_data,
})), })),
containers: Object.fromEntries(
[...this.containers].map(([id, container]) => [id, container.to_data()]),
),
players: this.#saved_players, players: this.#saved_players,
entities: [...this.#entities.values()].map((entity) => entity.save()), entities: [...this.#entities.values()].map((entity) => entity.save()),
mod_storage: this.mods.storage, mod_storage: this.mods.storage,
@@ -365,24 +363,9 @@ export class GameServer {
} }
} }
const old_tile = this.world.get_tile(x, y, z); this.world.remove_tile(x, y, z);
if (old_tile) {
BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player);
this.world.remove_tile(x, y, z);
for (const other of this.#players.values()) {
if (other.screen?.tile === old_tile) {
this.#close_screen(other);
this.#send(other, { type: "close_screen" });
}
}
}
this.world.set_block(x, y, z, id, state); this.world.set_block(x, y, z, id, state);
if (BLOCK_BEHAVIORS[id]?.create_tile) {
this.get_or_create_tile(x, y, z);
}
if (id !== AIR_ID) { if (id !== AIR_ID) {
const block = this.mods.block_ref(x, y, z, id); const block = this.mods.block_ref(x, y, z, id);
for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) { for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) {
@@ -396,23 +379,72 @@ export class GameServer {
get_or_create_tile(x: number, y: number, z: number): Tile { get_or_create_tile(x: number, y: number, z: number): Tile {
let tile = this.world.get_tile(x, y, z); let tile = this.world.get_tile(x, y, z);
if (!tile) { if (!tile) {
// blocks placed before tiles were saved have none yet tile = { id: this.world.get_block_id(x, y, z), x, y, z };
const id = this.world.get_block_id(x, y, z);
tile = { id, x, y, z, data: {}, containers: {} };
BLOCK_BEHAVIORS[id]?.create_tile?.(tile);
this.world.add_tile(tile); this.world.add_tile(tile);
} }
return tile; return tile;
} }
// containers
create_container(size: number): { id: string; container: Container } {
const id = crypto.randomUUID();
const container = new Container(size);
this.containers.set(id, container);
this.world.dirty = true;
return { id, container };
}
// anyone looking at it gets their screen closed
delete_container(id: string) {
const container = this.containers.get(id);
if (!container) {
return;
}
this.containers.delete(id);
this.world.dirty = true;
for (const player of this.#players.values()) {
if (player.screen?.container === container) {
this.close_screen(player);
}
}
}
// screens
open_screen(player: ServerPlayer, screen: OpenScreen) { open_screen(player: ServerPlayer, screen: OpenScreen) {
this.#close_screen(player); this.#close_screen(player);
player.screen = screen; player.screen = screen;
this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() }); this.#send(player, { type: "open_screen", layout: screen.layout, properties: { ...screen.properties } });
player.sent.set("properties", JSON.stringify(screen.properties())); player.sent.set("properties", JSON.stringify(screen.properties));
player.sent.delete("screen"); player.sent.delete("screen");
} }
// closes it for the player too, not just on the server
close_screen(player: ServerPlayer) {
if (!player.screen) {
return;
}
this.#close_screen(player);
this.#send(player, { type: "close_screen" });
}
// version 2 saves had chests and furnaces in the engine, with their fields in data and their items in
// containers.main. the components keep those fields and put the items in a ctx.containers container
#upgrade_tile(tile: SavedTile): unknown {
if (tile.mod_data !== undefined || (!tile.data && !tile.containers)) {
return tile.mod_data;
}
const data: Record<string, unknown> = { ...tile.data };
const items = tile.containers?.main;
if (items) {
const { id, container } = this.create_container(items.length);
container.load(items);
data.container = id;
}
return data;
}
// messages // messages
// hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md // hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md
@@ -623,7 +655,7 @@ export class GameServer {
return; return;
} }
let handled = BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player) ?? false; let handled = false;
for (const { mod, id: component_id, component, params } of this.mods.components_of(info.id)) { for (const { mod, id: component_id, component, params } of this.mods.components_of(info.id)) {
if (component.on_interact) { if (component.on_interact) {
handled = run_guarded(mod, `${component_id} on_interact`, () => handled = run_guarded(mod, `${component_id} on_interact`, () =>
@@ -715,6 +747,11 @@ export class GameServer {
if (item && take_output(item, player.cursor)) { if (item && take_output(item, player.cursor)) {
container.set_item(index, undefined); container.set_item(index, undefined);
} }
} else if (
key === "screen" && player.cursor.item && !(player.screen?.filters.get(index)?.(player.cursor.item) ?? true)
) {
// the slot doesn't take what the cursor holds
return;
} else { } else {
click_slot(container, index, player.cursor, button); click_slot(container, index, player.cursor, button);
} }
@@ -722,6 +759,9 @@ export class GameServer {
if (key === "crafting") { if (key === "crafting") {
update_crafting_result(container, this.recipes.shaped); update_crafting_result(container, this.recipes.shaped);
} }
if (key === "screen") {
this.world.dirty = true;
}
} }
#chat(player: ServerPlayer, raw: unknown) { #chat(player: ServerPlayer, raw: unknown) {
@@ -756,8 +796,16 @@ export class GameServer {
this.#send(player, { type: "chat", text: "Usage: /give <item> [count]" }); this.#send(player, { type: "chat", text: "Usage: /give <item> [count]" });
return; return;
} }
// a name without a namespace works when only one mod has an item called that
if (!item_id.includes(":")) { if (!item_id.includes(":")) {
item_id = `bworld:${item_id}`; const matches = EverythingRegistry.entries("items").map(([id]) => id).filter((id) =>
id.endsWith(`:${item_id}`)
);
if (matches.length > 1) {
this.#send(player, { type: "chat", text: `Which one? ${matches.join(", ")}` });
return;
}
item_id = matches[0] ?? item_id;
} }
const amount = count === undefined ? 1 : Number(count); const amount = count === undefined ? 1 : Number(count);
if (!EverythingRegistry.get("items", item_id)) { if (!EverythingRegistry.get("items", item_id)) {
@@ -793,7 +841,9 @@ export class GameServer {
} }
#close_screen(player: ServerPlayer) { #close_screen(player: ServerPlayer) {
const screen = player.screen;
player.screen = undefined; player.screen = undefined;
for (const fn of screen?.on_close ?? []) fn();
player.sent.delete("screen"); player.sent.delete("screen");
player.sent.delete("properties"); player.sent.delete("properties");
@@ -949,7 +999,7 @@ export class GameServer {
if (player.screen) { if (player.screen) {
const items = player.screen.container.to_data(); const items = player.screen.container.to_data();
sync("screen", items, () => ({ type: "container", container: "screen", items })); sync("screen", items, () => ({ type: "container", container: "screen", items }));
const properties = player.screen.properties(); const properties = { ...player.screen.properties };
sync("properties", properties, () => ({ type: "screen_properties", properties })); sync("properties", properties, () => ({ type: "screen_properties", properties }));
} }
} }
-2
View File
@@ -3,7 +3,6 @@
import { register_mod_data } from "$/common/mod_loader.ts"; import { register_mod_data } from "$/common/mod_loader.ts";
import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts"; import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { add_base_item_hooks } from "./blocks.ts";
import { GameHost, GameServer } from "./game_server.ts"; import { GameHost, GameServer } from "./game_server.ts";
import { ModRuntime } from "./mod_runtime.ts"; import { ModRuntime } from "./mod_runtime.ts";
@@ -23,7 +22,6 @@ export async function start_game(
mods: ServerModSource[], mods: ServerModSource[],
): Promise<GameServer> { ): Promise<GameServer> {
const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data }))); const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data })));
add_base_item_hooks();
const worldgen_scripts = mods.flatMap((mod) => const worldgen_scripts = mods.flatMap((mod) =>
mod.worldgen_url ? [{ mod: mod.listing.id, url: mod.worldgen_url }] : [] mod.worldgen_url ? [{ mod: mod.listing.id, url: mod.worldgen_url }] : []
+142 -11
View File
@@ -8,10 +8,12 @@ import type {
BlockRef, BlockRef,
Command, Command,
Container, Container,
ContainerScreenOptions,
EventSignal, EventSignal,
ItemComponent, ItemComponent,
ItemStack, ItemStack,
Player, Player,
ScreenHandle,
ServerAfterEvents, ServerAfterEvents,
ServerBeforeEvents, ServerBeforeEvents,
ServerContext, ServerContext,
@@ -19,7 +21,8 @@ import type {
import { ModLoadError } from "$/common/mod_loader.ts"; import { ModLoadError } from "$/common/mod_loader.ts";
import { AIR_ID } from "$/common/protocol.ts"; import { AIR_ID } from "$/common/protocol.ts";
import type { GameServer } from "./game_server.ts"; import type { GameServer } from "./game_server.ts";
import type { ServerPlayer } from "./player.ts"; import type { OpenScreen, ServerPlayer } from "./player.ts";
import type { ScreenLayout } from "$/common/protocol.ts";
// a list of handlers that can't break each other: one throwing is logged under its mod and the rest still run // a list of handlers that can't break each other: one throwing is logged under its mod and the rest still run
export class Signal<T> { export class Signal<T> {
@@ -107,6 +110,8 @@ export class ModRuntime {
#next_timer = 1; #next_timer = 1;
#setting_up = true; #setting_up = true;
#players = new WeakMap<ServerPlayer, Player>(); #players = new WeakMap<ServerPlayer, Player>();
// the other way, for apis that take a player
#server_players = new WeakMap<Player, ServerPlayer>();
game!: GameServer; game!: GameServer;
// imports each server script and calls its setup, in load order // imports each server script and calls its setup, in load order
@@ -152,6 +157,19 @@ export class ModRuntime {
); );
} }
} }
const lore = components.filter(([component_id]) =>
this.item_components.get(component_id)!.component.get_lore
);
if (lore.length > 0) {
item.get_lore = (stack) =>
lore.map(([component_id, params]) => {
const { mod, component } = this.item_components.get(component_id)!;
return run_guarded(mod, `${component_id} get_lore`, () =>
component.get_lore!(item_api(stack), params));
}).filter((line) =>
typeof line === "string"
).join("\n");
}
// items are created all over the engine, hook their creation once here // items are created all over the engine, hook their creation once here
const previous = item.on_create; const previous = item.on_create;
item.on_create = (stack) => { item.on_create = (stack) => {
@@ -231,6 +249,7 @@ export class ModRuntime {
if (!api) { if (!api) {
api = player_api(this.game, player); api = player_api(this.game, player);
this.#players.set(player, api); this.#players.set(player, api);
this.#server_players.set(api, player);
} }
return api; return api;
} }
@@ -339,6 +358,7 @@ export class ModRuntime {
return true; 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,
drop_item: (x, y, z, item) => void game().pop_item(x, y, z, new_stack(item)),
is_loaded: () => true, is_loaded: () => true,
get seed() { get seed() {
return game().world.seed; return game().world.seed;
@@ -355,6 +375,14 @@ export class ModRuntime {
return p && this.player(p); return p && this.player(p);
}, },
}, },
items: {
exists: (id) => EverythingRegistry.get("items", id) !== undefined,
max_stack: (id) => {
const item = EverythingRegistry.get<ItemRegistry>("items", id);
if (!item) throw new Error(`unknown item ${id}`);
return item.max_stack ?? 64;
},
},
recipes: { recipes: {
furnace_result: (input) => { furnace_result: (input) => {
const recipe = game().recipes.furnace.get(input); const recipe = game().recipes.furnace.get(input);
@@ -364,8 +392,27 @@ export class ModRuntime {
is_fuel: (item) => game().recipes.fuel.has(item), is_fuel: (item) => game().recipes.fuel.has(item),
is_smeltable: (item) => game().recipes.furnace.has(item), is_smeltable: (item) => game().recipes.furnace.has(item),
}, },
containers: not_yet_object(mod, "containers", "step 7 in MODS.md"), containers: {
ui: not_yet_object(mod, "ui", "step 7 in MODS.md"), create: (size) => {
if (!Number.isInteger(size) || size < 1 || size > MAX_CONTAINER_SIZE) {
throw new Error(`a container has 1 to ${MAX_CONTAINER_SIZE} slots, not ${size}`);
}
const { id, container } = game().create_container(size);
return container_api(game(), id, container);
},
get: (id) => {
const container = game().containers.get(id);
return container && container_api(game(), id, container);
},
delete: (id) => game().delete_container(id),
},
ui: {
open_container: (player, options) => this.#open_container(mod, player, options),
message_form: not_yet(mod, "ui.message_form", "step 7 in MODS.md"),
action_form: not_yet(mod, "ui.action_form", "step 7 in MODS.md"),
modal_form: not_yet(mod, "ui.modal_form", "step 7 in MODS.md"),
open_screen: not_yet(mod, "ui.open_screen", "step 7 in MODS.md"),
},
net: not_yet_object(mod, "net", "step 8 in MODS.md"), net: not_yet_object(mod, "net", "step 8 in MODS.md"),
storage: { storage: {
get: (key) => structuredClone(this.storage[mod]?.[key]) as never, get: (key) => structuredClone(this.storage[mod]?.[key]) as never,
@@ -383,6 +430,78 @@ export class ModRuntime {
return ctx; return ctx;
} }
#open_container(mod: string, player: Player, options: ContainerScreenOptions): ScreenHandle {
const game = this.game;
const server_player = this.#server_players.get(player);
if (!server_player || !game.players().includes(server_player)) {
throw new Error(`${player.name} isn't online`);
}
const container = game.containers.get(options.container.id);
if (!container) {
throw new Error("open_container needs a container from ctx.containers");
}
const filters = new Map<number, (item: EngineItemStack) => boolean>();
for (const { slot, filter } of options.layout) {
if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) {
throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`);
}
if (filter === "smeltable") {
filters.set(slot, (item) => game.recipes.furnace.has(item.type_id));
} else if (filter === "fuel") {
filters.set(slot, (item) => game.recipes.fuel.has(item.type_id));
} else if (typeof filter === "function") {
filters.set(slot, (item) => run_guarded(mod, "a slot filter", () => filter(item_api(item))) === true);
}
}
const bars = options.bars ?? [];
const bottom = Math.max(0, ...options.layout.map((s) => s.y + 1), ...bars.map((b) => b.y + 1));
const layout: ScreenLayout = {
rows: options.rows ?? Math.ceil(bottom),
slots: options.layout.map(({ slot, x, y, output_only }) =>
output_only ? { index: slot, x, y, output: true } : { index: slot, x, y }
),
bars: bars.map(({ x, y, value, max, direction, empty_texture, full_texture }) => ({
x,
y,
value,
max,
direction,
empty_texture,
full_texture,
})),
};
let open = true;
const screen: OpenScreen = { container, layout, properties: {}, filters, on_close: [() => open = false] };
game.open_screen(server_player, screen);
return {
player,
get open() {
return open;
},
set_property(id, value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`property ${id} must be a number, not ${value}`);
}
screen.properties[id] = value;
},
update: not_yet(mod, "update", "custom screens are step 7 in MODS.md"),
close() {
if (server_player.screen === screen) game.close_screen(server_player);
},
on_close(fn) {
if (!open) {
run_guarded(mod, "a screen on_close", fn);
return;
}
screen.on_close.push(() => run_guarded(mod, "a screen on_close", fn));
},
};
}
#add_timer(mod: string, fn: () => void, ticks: number, every?: number) { #add_timer(mod: string, fn: () => void, ticks: number, every?: number) {
const handle = this.#next_timer++; const handle = this.#next_timer++;
this.#timers.set(handle, { mod, fn, at: this.game.current_tick + Math.max(0, ticks), every }); this.#timers.set(handle, { mod, fn, at: this.game.current_tick + Math.max(0, ticks), every });
@@ -426,6 +545,8 @@ function not_yet_object<T>(mod: string, name: string, where: string): T {
}) as T; }) as T;
} }
const MAX_CONTAINER_SIZE = 256;
// the engine's item stacks as mods see them: { id, count, data } // the engine's item stacks as mods see them: { id, count, data }
export function item_api(stack: EngineItemStack): ItemStack { export function item_api(stack: EngineItemStack): ItemStack {
return { return {
@@ -454,22 +575,32 @@ function new_stack(item: ItemStack): EngineItemStack {
return stack; return stack;
} }
function player_api(game: GameServer, player: ServerPlayer): Player { // an engine container as mods see it
const inventory: Container = { function container_api(game: GameServer, id: string, container: EngineContainer): Container {
id: `player:${player.id}`, return {
size: player.inventory.size, id,
size: container.size,
get: (slot) => { get: (slot) => {
const stack = player.inventory.get_item(slot); const stack = container.get_item(slot);
return stack && item_api(stack); return stack && item_api(stack);
}, },
set: (slot, item) => player.inventory.set_item(slot, item ? new_stack(item) : undefined), set: (slot, item) => {
if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) {
throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`);
}
container.set_item(slot, item ? new_stack(item) : undefined);
},
add: (item) => { add: (item) => {
const stack = new_stack(item); const stack = new_stack(item);
const left = player.inventory.add_item(stack); const left = container.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: (fn) => game.mods.watch_container(running_mod || "unknown", player.inventory, fn), on_change: (fn) => game.mods.watch_container(running_mod || "unknown", container, fn),
}; };
}
function player_api(game: GameServer, player: ServerPlayer): Player {
const inventory = container_api(game, `player:${player.id}`, player.inventory);
return { return {
get id() { get id() {
+6 -4
View File
@@ -1,16 +1,18 @@
import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts"; import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts";
import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts"; import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts";
import { Tile } from "./world.ts";
export const INVENTORY_SIZE = 9 * 4; export const INVENTORY_SIZE = 9 * 4;
export const CRAFTING_SIZE = 10; export const CRAFTING_SIZE = 10;
// a screen the server opened for this player, showing a tile's container // a container screen a mod opened for this player, see ctx.ui.open_container
export interface OpenScreen { export interface OpenScreen {
tile: Tile;
container: Container; container: Container;
layout: ScreenLayout; layout: ScreenLayout;
properties(): Record<string, number>; // what the bars show, synced when they change
properties: Record<string, number>;
// what can go into each slot, checked when the player clicks
filters: Map<number, (item: ItemStack) => boolean>;
on_close: (() => void)[];
} }
// what's saved about a player between sessions, by name // what's saved about a player between sessions, by name
+2 -5
View File
@@ -1,7 +1,6 @@
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
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 { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import { block_value, chunk_key, default_block_value } 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";
@@ -12,15 +11,13 @@ const CHUNK_CACHE_SIZE = 256;
// how far from the middle of the world to look for dry land to spawn on, in chunks // how far from the middle of the world to look for dry land to spawn on, in chunks
const SPAWN_SEARCH_CHUNKS = 32; const SPAWN_SEARCH_CHUNKS = 32;
// a block with state the server keeps, like a chest's items. never sent to clients as is // a block with data the server keeps, like where a chest's items are. never sent to clients
export interface Tile { export interface Tile {
id: string; id: string;
x: number; x: number;
y: number; y: number;
z: number; z: number;
data: Record<string, unknown>; // what BlockRef.data holds
containers: Record<string, Container>;
// a mod block's data, what BlockRef.data holds
mod_data?: unknown; mod_data?: unknown;
} }
+197
View File
@@ -0,0 +1,197 @@
// the base game's block and item behavior: hoeing, chests, furnaces, crops and the watering can
import { assert, assertEquals } from "@std/assert";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { get_state_value } from "$/common/utils.ts";
import { test_game } from "./helpers.ts";
// deno-lint-ignore no-explicit-any
type Msg = any;
type Game = Awaited<ReturnType<typeof test_game>>;
const last_container = (messages: Msg[], container: string) =>
messages.filter((m) => m.type === "container" && m.container === container).at(-1)?.items;
// a player standing on a stone floor at y 99, high above the terrain, with the items given in their inventory
async function setup(items: [string, number][] = []) {
const t = await test_game("mods");
t.join(1, "alice");
for (let x = -3; x <= 3; x++) {
for (let z = -3; z <= 3; z++) t.game.set_block(x, 99, z, "bworld:stone");
}
t.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 });
for (const [id, count] of items) t.send(1, { type: "chat", text: `/give ${id} ${count}` });
return t;
}
// the inventory slot holding an item, from what the server sent last
function slot_of(messages: Msg[], id: string) {
const slot = (last_container(messages, "inventory") ?? []).findIndex((i: Msg) => i?.id === id);
assert(slot >= 0, `no ${id} in the inventory`);
return slot;
}
// picks a whole inventory stack up and puts it into a slot of the open screen
function move_to_screen(t: Game, from: number, to: number) {
t.send(1, { type: "click", container: "inventory", index: from, button: 0 });
t.send(1, { type: "click", container: "screen", index: to, button: 0 });
}
Deno.test("a hoe turns grass and dirt into hoed dirt, anything else places nothing", async () => {
const t = await setup([["bworld:hoe", 1]]);
t.game.set_block(1, 100, 0, "bworld:grass");
t.game.set_block(2, 100, 0, "bworld:dirt");
t.send(1, { type: "select_slot", slot: slot_of(t.take(1), "bworld:hoe") });
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
t.send(1, { type: "use_block", x: 2, y: 100, z: 0, face: "top" });
assertEquals(t.game.world.get_block_id(1, 100, 0), "bworld:hoed_dirt");
assertEquals(t.game.world.get_block_id(2, 100, 0), "bworld:hoed_dirt");
// hoed dirt stays hoed dirt, and the hand doesn't do it
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
assertEquals(t.game.world.get_block_id(1, 100, 0), "bworld:hoed_dirt");
t.game.set_block(1, 100, 1, "bworld:grass");
t.send(1, { type: "select_slot", slot: 8 });
t.send(1, { type: "use_block", x: 1, y: 100, z: 1, face: "top" });
assertEquals(t.game.world.get_block_id(1, 100, 1), "bworld:grass");
});
Deno.test("a chest keeps items, shows them to everyone, and drops them when broken", async () => {
const t = await setup([["bworld:chest", 1], ["bworld:coal", 10]]);
t.game.set_block(1, 100, 0, "bworld:chest");
const coal = slot_of(t.take(1), "bworld:coal");
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
const opened = t.take(1).find((m) => m.type === "open_screen");
assertEquals(opened.layout.rows, 3);
assertEquals(opened.layout.slots.length, 27);
// nothing got placed on top
assertEquals(t.game.world.get_block_id(1, 101, 0), "bworld:air");
move_to_screen(t, coal, 4);
assertEquals(last_container(t.take(1), "screen")[4], { id: "bworld:coal", count: 10 });
t.send(1, { type: "close_screen" });
// still there after opening it again, and after saving and loading
const loaded = await test_game("mods", t.game.save());
loaded.join(1, "alice");
loaded.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 });
loaded.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
assertEquals(last_container(loaded.take(1), "screen")[4], { id: "bworld:coal", count: 10 });
// breaking it closes the screen and drops the chest and what was inside
loaded.send(1, { type: "break_block", x: 1, y: 100, z: 0 });
const messages = loaded.take(1);
assert(messages.some((m) => m.type === "close_screen"));
const dropped = loaded.game.item_entities().map((e) => e.item.to_data());
assertEquals(
dropped.sort((a, b) => a.id.localeCompare(b.id)),
[{ id: "bworld:chest", count: 1 }, { id: "bworld:coal", count: 10 }],
);
});
Deno.test("a furnace burns fuel to smelt, shows its progress, and only lets the result be taken", async () => {
const t = await setup([["bworld:iron_ore", 2], ["bworld:coal", 1]]);
t.game.set_block(1, 100, 0, "bworld:furnace");
const messages = t.take(1);
const ore = slot_of(messages, "bworld:iron_ore");
const coal = slot_of(messages, "bworld:coal");
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
const opened = t.take(1).find((m) => m.type === "open_screen");
assertEquals(opened.layout.slots.map((s: Msg) => s.index), [0, 1, 2]);
assertEquals(opened.layout.slots[2].output, true);
assertEquals(opened.layout.bars.map((b: Msg) => b.value), ["fuel", "progress"]);
move_to_screen(t, ore, 0);
move_to_screen(t, coal, 1);
t.take(1);
// iron takes 200 ticks
for (let i = 0; i < 100; i++) t.game.tick();
const halfway = t.take(1).filter((m) => m.type === "screen_properties").at(-1).properties;
assertEquals(halfway.progress_max, 200);
assert(halfway.progress >= 98 && halfway.progress <= 100, `progress ${halfway.progress}`);
assertEquals(halfway.fuel_max, 1000);
for (let i = 0; i < 110; i++) t.game.tick();
const screen = last_container(t.take(1), "screen");
assertEquals(screen[0], { id: "bworld:iron_ore", count: 1 });
assertEquals(screen[1], null, "the coal got used");
assertEquals(screen[2], { id: "bworld:iron_ingot", count: 1 });
// nothing can be put into the result slot, but the result can be taken
t.send(1, { type: "click", container: "screen", index: 0, button: 0 });
t.send(1, { type: "click", container: "screen", index: 2, button: 0 });
assertEquals(last_container(t.take(1), "screen")[2], { id: "bworld:iron_ingot", count: 1 });
t.send(1, { type: "click", container: "screen", index: 0, button: 0 });
t.send(1, { type: "click", container: "screen", index: 2, button: 0 });
const after = t.take(1);
assertEquals(last_container(after, "screen")[2], null);
assertEquals(after.filter((m) => m.type === "cursor").at(-1).item, { id: "bworld:iron_ingot", count: 1 });
});
Deno.test("bone meal grows wheat until it's fully grown", async () => {
const t = await setup([["bworld:bone_meal", 8]]);
t.game.set_block(1, 100, 0, "bworld:wheat");
const wheat = EverythingRegistry.get<BlockRegistry>("blocks", "bworld:wheat")!;
const age = () => get_state_value(t.game.world.get_block_value(1, 100, 0), wheat, "age")!;
assertEquals(age(), 0);
const slot = slot_of(t.take(1), "bworld:bone_meal");
t.send(1, { type: "select_slot", slot });
// one stage at a time, and once it's fully grown it stops using bone meal
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
assertEquals(age(), 1);
for (let i = 0; i < 7; i++) t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
assertEquals(age(), 7);
assertEquals(last_container(t.take(1), "inventory")[slot]?.count, 1);
assertEquals(t.game.world.get_block_id(1, 101, 0), "bworld:air");
});
Deno.test("the last bone meal is used up", async () => {
const t = await setup([["bworld:bone_meal", 1]]);
t.game.set_block(1, 100, 0, "bworld:wheat");
const slot = slot_of(t.take(1), "bworld:bone_meal");
t.send(1, { type: "select_slot", slot });
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
assertEquals(last_container(t.take(1), "inventory")[slot], null);
});
Deno.test("a new watering can starts empty", async () => {
const t = await setup([["bworld:watering_can", 1]]);
const can = last_container(t.take(1), "inventory").find((i: Msg) => i?.id === "bworld:watering_can");
assertEquals(can.data, { water: 0, max_water: 32 });
});
Deno.test("chests and furnaces from a world saved before they were mod code keep their items and keep smelting", async () => {
const t = await test_game("mods", Deno.readTextFileSync("tests/fixtures/world_v2.json"), "fixture-seed");
t.join(1, "alice");
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
const chest = last_container(t.take(1), "screen");
assertEquals(chest.length, 27);
assertEquals(chest[4], { id: "bworld:coal", count: 6 });
t.send(1, { type: "close_screen" });
t.send(1, { type: "use_block", x: 2, y: 100, z: 0, face: "top" });
let messages = t.take(1);
assertEquals(last_container(messages, "screen"), [
{ id: "bworld:iron_ore", count: 2 },
{ id: "bworld:coal", count: 2 },
{ id: "bworld:iron_ingot", count: 1 },
]);
const properties = messages.filter((m) => m.type === "screen_properties").at(-1).properties;
assertEquals(properties, { progress: 50, progress_max: 200, fuel: 751, fuel_max: 1000 });
// 150 more ticks finish the second ingot
for (let i = 0; i < 150; i++) t.game.tick();
messages = t.take(1);
assertEquals(last_container(messages, "screen")[2], { id: "bworld:iron_ingot", count: 2 });
// saved in the new format, nothing lost
const saved = JSON.parse(t.game.save());
assertEquals(saved.version, 3);
assert(saved.tiles.every((tile: Msg) => tile.data === undefined && tile.containers === undefined));
assertEquals(Object.keys(saved.containers).length, 2);
});
+436
View File
@@ -0,0 +1,436 @@
{
"version": 2,
"seed": "fixture-seed",
"changes": [
[
-3,
99,
-3,
"bworld:stone"
],
[
-3,
99,
-2,
"bworld:stone"
],
[
-3,
99,
-1,
"bworld:stone"
],
[
-2,
99,
-3,
"bworld:stone"
],
[
-2,
99,
-2,
"bworld:stone"
],
[
-2,
99,
-1,
"bworld:stone"
],
[
-1,
99,
-3,
"bworld:stone"
],
[
-1,
99,
-2,
"bworld:stone"
],
[
-1,
99,
-1,
"bworld:stone"
],
[
-3,
99,
0,
"bworld:stone"
],
[
-3,
99,
1,
"bworld:stone"
],
[
-3,
99,
2,
"bworld:stone"
],
[
-3,
99,
3,
"bworld:stone"
],
[
-2,
99,
0,
"bworld:stone"
],
[
-2,
99,
1,
"bworld:stone"
],
[
-2,
99,
2,
"bworld:stone"
],
[
-2,
99,
3,
"bworld:stone"
],
[
-1,
99,
0,
"bworld:stone"
],
[
-1,
99,
1,
"bworld:stone"
],
[
-1,
99,
2,
"bworld:stone"
],
[
-1,
99,
3,
"bworld:stone"
],
[
0,
99,
-3,
"bworld:stone"
],
[
0,
99,
-2,
"bworld:stone"
],
[
0,
99,
-1,
"bworld:stone"
],
[
1,
99,
-3,
"bworld:stone"
],
[
1,
99,
-2,
"bworld:stone"
],
[
1,
99,
-1,
"bworld:stone"
],
[
2,
99,
-3,
"bworld:stone"
],
[
2,
99,
-2,
"bworld:stone"
],
[
2,
99,
-1,
"bworld:stone"
],
[
3,
99,
-3,
"bworld:stone"
],
[
3,
99,
-2,
"bworld:stone"
],
[
3,
99,
-1,
"bworld:stone"
],
[
0,
99,
0,
"bworld:stone"
],
[
0,
99,
1,
"bworld:stone"
],
[
0,
99,
2,
"bworld:stone"
],
[
0,
99,
3,
"bworld:stone"
],
[
1,
99,
0,
"bworld:stone"
],
[
1,
99,
1,
"bworld:stone"
],
[
1,
99,
2,
"bworld:stone"
],
[
1,
99,
3,
"bworld:stone"
],
[
2,
99,
0,
"bworld:stone"
],
[
2,
99,
1,
"bworld:stone"
],
[
2,
99,
2,
"bworld:stone"
],
[
2,
99,
3,
"bworld:stone"
],
[
3,
99,
0,
"bworld:stone"
],
[
3,
99,
1,
"bworld:stone"
],
[
3,
99,
2,
"bworld:stone"
],
[
3,
99,
3,
"bworld:stone"
],
[
1,
100,
0,
"bworld:chest"
],
[
2,
100,
0,
"bworld:furnace"
]
],
"tiles": [
{
"id": "bworld:chest",
"x": 1,
"y": 100,
"z": 0,
"data": {},
"containers": {
"main": [
null,
null,
null,
null,
{
"id": "bworld:coal",
"count": 6
},
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
}
},
{
"id": "bworld:furnace",
"x": 2,
"y": 100,
"z": 0,
"data": {
"progress": 50,
"progress_max": 200,
"fuel": 751,
"fuel_max": 1000
},
"containers": {
"main": [
{
"id": "bworld:iron_ore",
"count": 2
},
{
"id": "bworld:coal",
"count": 2
},
{
"id": "bworld:iron_ingot",
"count": 1
}
]
}
}
],
"players": {
"alice": {
"x": 0.5,
"y": 100,
"z": 0.5,
"yaw": 0,
"pitch": 0,
"selected_slot": 0,
"inventory": [
{
"id": "bworld:coal",
"count": 2
},
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
}
},
"entities": [],
"mod_storage": {}
}
+1
View File
@@ -25,6 +25,7 @@ export function mod_sources(mods_dir: string): ServerModSource[] {
}, },
data: { data: {
blocks: mod.blocks.map((b) => b.json), blocks: mod.blocks.map((b) => b.json),
models: mod.models.map((m) => m.json),
items: mod.items.map((i) => i.json), items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json), recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json), ores: mod.ores.map((o) => o.json),
-32
View File
@@ -3,7 +3,6 @@ import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts"; import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts";
import { generate_raw_chunk } from "$/common/generation.ts"; import { generate_raw_chunk } from "$/common/generation.ts";
import { get_state_value } from "$/common/utils.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { create_mod } from "$/tools/new_mod.ts"; import { create_mod } from "$/tools/new_mod.ts";
import { PROTOCOL_VERSION } from "$/common/protocol.ts"; import { PROTOCOL_VERSION } from "$/common/protocol.ts";
@@ -35,37 +34,6 @@ Deno.test("the base game loads from mods/bworld", async () => {
assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create); assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create);
}); });
Deno.test("bone meal grows wheat until it's fully grown", async () => {
const { game, take, send } = await test_game("mods");
game.on_connect(1);
send(1, { type: "hello", name: "alice", protocol: PROTOCOL_VERSION });
send(1, { type: "ready" });
let y = CHUNK_HEIGHT - 1;
while (game.world.get_block_nid(1, y, 1) === AIR) y--;
y += 1;
game.set_block(1, y, 1, "bworld:wheat");
const wheat = EverythingRegistry.get<BlockRegistry>("blocks", "bworld:wheat")!;
const age = () => get_state_value(game.world.get_block_value(1, y, 1), wheat, "age")!;
assertEquals(age(), 0);
send(1, { type: "chat", text: "/give bworld:bone_meal 8" });
const slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "bworld:bone_meal");
send(1, { type: "select_slot", slot });
send(1, { type: "move", x: 2.5, y, z: 2.5, yaw: 0, pitch: 0 });
// 2 to 5 stages at a time
send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assert(age() >= 2 && age() <= 5, `age ${age()}`);
// at most 3 more uses to reach 7, then it stops using bone meal
for (let i = 0; i < 5; i++) send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assertEquals(age(), 7);
const left = inventory_of(take(1))[slot]?.count;
assert(left >= 4 && left <= 6, `${left} bone meal left`);
// nothing got placed on top
assertEquals(game.world.get_block_id(1, y + 1, 1), "bworld:air");
});
Deno.test("mods/bworld json survives going to the registry and back", async () => { Deno.test("mods/bworld json survives going to the registry and back", async () => {
await test_game("mods"); await test_game("mods");
for (const file of Deno.readDirSync("mods/bworld/blocks")) { for (const file of Deno.readDirSync("mods/bworld/blocks")) {
+73
View File
@@ -24,6 +24,28 @@ export function setup(ctx: ServerContext) {
on_tick() { throw new Error("broken on purpose"); }, on_tick() { throw new Error("broken on purpose"); },
on_click(block, _params, player) { log("click", [block.id, player.name]); }, on_click(block, _params, player) { log("click", [block.id, player.name]); },
}); });
// a box that only takes coal, counts how often it's opened, and gives up its items when broken
ctx.components.register_block("testmod:coal_box", {
on_interact(block, _params, player) {
block.data ??= { container: ctx.containers.create(2).id, opened: 0 };
block.data.opened += 1;
const screen = ctx.ui.open_container(player, {
container: ctx.containers.get(block.data.container)!,
layout: [{ slot: 0, x: 0, y: 0, filter: (item) => item.id === "bworld:coal" }, { slot: 1, x: 1, y: 0 }],
});
screen.set_property("opened", block.data.opened);
screen.on_close(() => log("closed", screen.open));
return true;
},
on_break(block) {
const container = ctx.containers.get(block.data.container)!;
for (let slot = 0; slot < container.size; slot++) {
const item = container.get(slot);
if (item) ctx.world.drop_item(block.x, block.y, block.z, item);
}
ctx.containers.delete(container.id);
},
});
ctx.components.register_item("testmod:wand", { ctx.components.register_item("testmod:wand", {
on_use(item, params: { power: number }, player) { log("use", [item.id, params.power, player.name]); }, on_use(item, params: { power: number }, player) { log("use", [item.id, params.power, player.name]); },
}); });
@@ -84,6 +106,10 @@ function test_mods() {
`${mod}/blocks/broken.json`, `${mod}/blocks/broken.json`,
block("testmod:broken", { components: { "testmod:broken": {} } }), block("testmod:broken", { components: { "testmod:broken": {} } }),
); );
Deno.writeTextFileSync(
`${mod}/blocks/coal_box.json`,
block("testmod:coal_box", { interactive: true, components: { "testmod:coal_box": {} } }),
);
Deno.writeTextFileSync( Deno.writeTextFileSync(
`${mod}/blocks/lamp.json`, `${mod}/blocks/lamp.json`,
block("testmod:lamp", { block("testmod:lamp", {
@@ -227,3 +253,50 @@ Deno.test("/tps reports the loop, and mods can't take engine commands", async ()
assert(error.includes("/tps belongs to the engine"), error); assert(error.includes("/tps belongs to the engine"), error);
Deno.removeSync(clash, { recursive: true }); Deno.removeSync(clash, { recursive: true });
}); });
Deno.test("container screens: filters, properties, closing, saving, and dropping what's inside", async () => {
const { game, send, take, log, dir } = await setup();
game.set_block(1, 100, 0, "testmod:coal_box");
send(1, { type: "chat", text: "/give bworld:coal 3" });
send(1, { type: "chat", text: "/give bworld:stone 1" });
take(1);
send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
let messages = take(1);
const opened = messages.find((m) => m.type === "open_screen");
assertEquals(opened.layout, { rows: 1, slots: [{ index: 0, x: 0, y: 0 }, { index: 1, x: 1, y: 0 }], bars: [] });
assertEquals(messages.find((m) => m.type === "screen_properties").properties, { opened: 1 });
// stone doesn't go into the coal slot, but does into the other one. coal goes in
const screen = (messages: { type: string; container?: string; items?: unknown[] }[]) =>
messages.filter((m) => m.type === "container" && m.container === "screen").at(-1)?.items;
send(1, { type: "click", container: "inventory", index: 1, button: 0 });
send(1, { type: "click", container: "screen", index: 0, button: 0 });
send(1, { type: "click", container: "screen", index: 1, button: 0 });
send(1, { type: "click", container: "inventory", index: 0, button: 0 });
send(1, { type: "click", container: "screen", index: 0, button: 0 });
messages = take(1);
assertEquals(screen(messages), [{ id: "bworld:coal", count: 3 }, { id: "bworld:stone", count: 1 }]);
send(1, { type: "close_screen" });
assertEquals(log().closed, [false]);
// the items and the block's data are saved
const loaded = await test_game(dir, game.save());
loaded.join(1, "alice");
loaded.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 });
loaded.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
messages = loaded.take(1);
assertEquals(screen(messages), [{ id: "bworld:coal", count: 3 }, { id: "bworld:stone", count: 1 }]);
assertEquals(messages.find((m) => m.type === "screen_properties").properties, { opened: 2 });
// breaking it closes the screen, runs on_close, and drops what was inside
loaded.send(1, { type: "break_block", x: 1, y: 100, z: 0 });
assert(loaded.take(1).some((m) => m.type === "close_screen"));
const logged = loaded.game.mods.storage.testmod.log as Record<string, unknown[]>;
assertEquals(logged.closed, [false, false]);
const dropped = loaded.game.item_entities().map((e) => e.item.to_data().id).sort();
assertEquals(dropped, ["bworld:coal", "bworld:stone"]);
assertEquals(loaded.game.containers.size, 0);
Deno.removeSync(dir, { recursive: true });
});