Actually implement mod code into server
This commit is contained in:
@@ -394,6 +394,7 @@ interface ServerContext {
|
||||
world: ServerWorld;
|
||||
players: PlayerList;
|
||||
containers: ContainerApi;
|
||||
items: ItemApi;
|
||||
recipes: RecipeApi;
|
||||
ui: ServerUi; // see GUIs
|
||||
net: ServerNet; // see Mod channels
|
||||
@@ -477,6 +478,7 @@ interface ServerWorld {
|
||||
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;
|
||||
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;
|
||||
readonly seed: string;
|
||||
}
|
||||
@@ -612,8 +614,8 @@ code sees it.
|
||||
### 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
|
||||
go to the server, which applies them. The chest and furnace would be rebuilt this way instead of their hand-written
|
||||
`GuiChest` / `GuiFurnace` classes.
|
||||
go to the server, which applies them. The base game's chest and furnace are built this way, see
|
||||
`mods/bworld/scripts/server.ts`.
|
||||
|
||||
```ts
|
||||
ctx.components.register_block("copper_tools:smelter", {
|
||||
@@ -623,36 +625,50 @@ ctx.components.register_block("copper_tools:smelter", {
|
||||
on_interact(block, _params, player) {
|
||||
const container = ctx.containers.get(block.data.container)!;
|
||||
const screen = ctx.ui.open_container(player, {
|
||||
title: "Smelter",
|
||||
container,
|
||||
layout: [
|
||||
{ slot: 0, x: 3, y: 0, filter: "smeltable" },
|
||||
{ slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) },
|
||||
{ slot: 2, x: 5, y: 1, output_only: true },
|
||||
],
|
||||
container,
|
||||
player_inventory: true,
|
||||
bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }],
|
||||
bars: [{
|
||||
x: 4,
|
||||
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_max", 200);
|
||||
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
|
||||
below it when `player_inventory` is true.
|
||||
- The screen is drawn below the player's inventory and hotbar. `x` / `y` are in slot units in the screen's own area and
|
||||
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
|
||||
`"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
|
||||
with `labels: [{ x, y, property }]`.
|
||||
- The screen handle has `set_property`, `close()` and `on_close(fn)`. Changes to the container (from scripts, hoppers,
|
||||
`output_only` slots can only be taken from, all at once, like the furnace result.
|
||||
- `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`.
|
||||
`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.
|
||||
- Containers are saved with the world until they're deleted, so a block that has one deletes it in `on_break`.
|
||||
|
||||
```ts
|
||||
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;
|
||||
delete(id: string): void;
|
||||
delete(id: string): void; // closes screens showing it
|
||||
}
|
||||
|
||||
interface Container {
|
||||
@@ -663,8 +679,15 @@ interface Container {
|
||||
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
|
||||
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)
|
||||
|
||||
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) {
|
||||
const data = block.data as SmelterData;
|
||||
ctx.ui.open_container(player, {
|
||||
title: "Smelter",
|
||||
container: ctx.containers.get(data.container)!,
|
||||
layout: [
|
||||
{ slot: 0, x: 3, y: 0, filter: "smeltable" },
|
||||
{ slot: 1, x: 3, y: 2, filter: "fuel" },
|
||||
{ slot: 2, x: 5, y: 1, output_only: true },
|
||||
],
|
||||
player_inventory: true,
|
||||
bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }],
|
||||
bars: [{
|
||||
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);
|
||||
return true;
|
||||
},
|
||||
@@ -1126,10 +1155,10 @@ anything the base game does.
|
||||
|
||||
Not moved:
|
||||
|
||||
- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt
|
||||
as a component once server scripts exist, as a good first test of tile data plus ticking.
|
||||
- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves
|
||||
once tooltips exist.
|
||||
- **Carrots, potatoes, tomatoes and pumpkins** (`common/blocks/crops.ts`) are unfinished and mostly commented out.
|
||||
Wheat uses the `bworld:crop` component instead, and they can too once they have blocks.
|
||||
- **The watering can's lore** moved into its component's `get_lore`, but it isn't shown anywhere yet, because the game
|
||||
has no item tooltips.
|
||||
|
||||
### 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`
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -1180,14 +1210,17 @@ loading mods (the base game and the template), their scripts and worldgen, saves
|
||||
- Delete `common/blocks/` and `common/items/`.
|
||||
- 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
|
||||
`bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the
|
||||
`ScreenLayout`s the server sends now.
|
||||
- The block JSON lists the components. Delete `server/game/blocks.ts`.
|
||||
- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and
|
||||
`world_v2.json` still pass.
|
||||
- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, `bworld:crop` (bone
|
||||
meal on wheat) and `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, with
|
||||
the same layouts as before.
|
||||
- The block and item JSON lists the components. `server/game/blocks.ts` is deleted.
|
||||
- Saves went to version 3: tiles only keep `mod_data`, and containers are saved by id. Version 2 chests and furnaces are
|
||||
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).
|
||||
|
||||
@@ -1239,10 +1272,11 @@ working:
|
||||
joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Done._
|
||||
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
|
||||
`FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in
|
||||
`server/game/game_loop.ts`). `ctx.containers`, `ctx.ui` and `ctx.net` throw until steps 7 and 8, and so do the
|
||||
client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
|
||||
`server/game/game_loop.ts`). `ctx.net` throws until step 8, and so do the client's `ctx.ui`, `ctx.hud`,
|
||||
`ctx.input` and `ctx.net`.
|
||||
7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics`
|
||||
API (built on the existing renderer and debug UI widgets) and the HUD.
|
||||
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.
|
||||
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
|
||||
|
||||
+8
-4
@@ -49,7 +49,7 @@ export class ContainerSlot {
|
||||
#item_stack: ItemStack | undefined;
|
||||
|
||||
has_item() {
|
||||
return this.#item_stack !== undefined;
|
||||
return this.get_item() !== undefined;
|
||||
}
|
||||
|
||||
set_item(item_stack: ItemStack | undefined) {
|
||||
@@ -60,11 +60,15 @@ export class ContainerSlot {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
get type_id() {
|
||||
return this.#item_stack?.type_id;
|
||||
return this.get_item()?.type_id;
|
||||
}
|
||||
|
||||
set amount(new_amount: number) {
|
||||
@@ -77,11 +81,11 @@ export class ContainerSlot {
|
||||
}
|
||||
|
||||
get amount(): number | undefined {
|
||||
return this.#item_stack?.amount;
|
||||
return this.get_item()?.amount;
|
||||
}
|
||||
|
||||
get max_amount() {
|
||||
return this.#item_stack?.max_amount;
|
||||
return this.get_item()?.max_amount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ServerContext {
|
||||
world: ServerWorld;
|
||||
players: PlayerList;
|
||||
containers: ContainerApi;
|
||||
items: ItemApi;
|
||||
recipes: RecipeApi;
|
||||
ui: ServerUi;
|
||||
net: ServerNet;
|
||||
@@ -123,6 +124,8 @@ export interface ServerWorld {
|
||||
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;
|
||||
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;
|
||||
readonly seed: string;
|
||||
}
|
||||
@@ -177,6 +180,12 @@ export interface ContainerApi {
|
||||
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 {
|
||||
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
|
||||
@@ -214,20 +223,39 @@ export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: fal
|
||||
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
|
||||
|
||||
export interface ContainerScreenOptions {
|
||||
title: string;
|
||||
container: Container;
|
||||
// x and y in slot units
|
||||
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[];
|
||||
player_inventory?: boolean;
|
||||
bars?: { id: string; x: number; y: number; texture: Id }[];
|
||||
labels?: { x: number; y: number; property: string }[];
|
||||
// x and y in slot units, below the player's inventory. can be fractional
|
||||
layout: {
|
||||
slot: number;
|
||||
x: number;
|
||||
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> {
|
||||
readonly player: Player;
|
||||
readonly open: boolean;
|
||||
// numbers the bars show, synced to the player when they change
|
||||
set_property(id: string, value: number): void;
|
||||
update(props: Props): void; // custom screens only
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"tool": "axe"
|
||||
},
|
||||
"drops": "bworld:chest",
|
||||
"interactive": true
|
||||
"interactive": true,
|
||||
"components": {
|
||||
"bworld:storage": {
|
||||
"rows": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
"toughness": 2,
|
||||
"tool": "shovel"
|
||||
},
|
||||
"drops": "bworld:dirt"
|
||||
"drops": "bworld:dirt",
|
||||
"components": {
|
||||
"bworld:hoeable": {
|
||||
"tool": "bworld:hoe",
|
||||
"into": "bworld:hoed_dirt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"requires_tool": true
|
||||
},
|
||||
"drops": "bworld:furnace",
|
||||
"interactive": true
|
||||
"interactive": true,
|
||||
"components": {
|
||||
"bworld:furnace": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"tool": "shovel"
|
||||
},
|
||||
"drops": "bworld:dirt",
|
||||
"item": false
|
||||
"item": false,
|
||||
"components": {
|
||||
"bworld:hoeable": {
|
||||
"tool": "bworld:hoe",
|
||||
"into": "bworld:hoed_dirt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
"bits": 3,
|
||||
"default": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"components": {
|
||||
"bworld:crop": {
|
||||
"fertilizer": "bworld:bone_meal",
|
||||
"max_age": 7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:watering_can",
|
||||
"texture": "bworld:watering_can"
|
||||
"texture": "bworld:watering_can",
|
||||
"components": {
|
||||
"bworld:watering_can": {
|
||||
"max_water": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
"name": "bworld",
|
||||
"description": "The base game: its blocks, items, recipes and world.",
|
||||
"version": "0.1.0",
|
||||
"authors": ["paula"],
|
||||
"authors": [
|
||||
"paula"
|
||||
],
|
||||
"game_version": ">=0.1.0",
|
||||
"credits": "CREDITS.md"
|
||||
"credits": "CREDITS.md",
|
||||
"scripts": {
|
||||
"server": "scripts/server.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+109
-59
@@ -26,7 +26,6 @@ import {
|
||||
} from "$/common/protocol.ts";
|
||||
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
|
||||
import type { WorldgenSetup } from "$/common/generation.ts";
|
||||
import { BLOCK_BEHAVIORS } from "./blocks.ts";
|
||||
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
|
||||
import type { GameLoop } from "./game_loop.ts";
|
||||
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||
@@ -61,19 +60,12 @@ export interface GameHost {
|
||||
}
|
||||
|
||||
export interface SavedWorld {
|
||||
version: 2;
|
||||
version: 3;
|
||||
seed: string;
|
||||
changes: BlockChange[];
|
||||
tiles: {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
data: Record<string, unknown>;
|
||||
containers: Record<string, (ItemData | null)[]>;
|
||||
// a mod block's data, what BlockRef.data holds
|
||||
mod_data?: unknown;
|
||||
}[];
|
||||
tiles: SavedTile[];
|
||||
// every ctx.containers container, by id
|
||||
containers?: Record<string, (ItemData | null)[]>;
|
||||
players: Record<string, SavedPlayer>;
|
||||
// items on the ground
|
||||
entities?: SavedItemEntity[];
|
||||
@@ -81,6 +73,18 @@ export interface SavedWorld {
|
||||
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
|
||||
export interface GameMods {
|
||||
listings: ModListing[];
|
||||
@@ -102,6 +106,8 @@ export class GameServer {
|
||||
#entities = new Map<string, ItemEntity>();
|
||||
#next_entity_id = 1;
|
||||
#mods: GameMods;
|
||||
// ctx.containers, saved with the world
|
||||
containers = new Map<string, Container>();
|
||||
mods: ModRuntime;
|
||||
recipes: RecipeBook;
|
||||
// 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.mods.storage = saved?.mod_storage ?? {};
|
||||
this.world.load_changes(saved?.changes ?? []);
|
||||
for (const tile of saved?.tiles ?? []) {
|
||||
const containers: Record<string, Container> = {};
|
||||
for (const [name, items] of Object.entries(tile.containers)) {
|
||||
containers[name] = new Container(items.length);
|
||||
containers[name].load(items);
|
||||
for (const [id, items] of Object.entries(saved?.containers ?? {})) {
|
||||
const container = new Container(items.length);
|
||||
container.load(items);
|
||||
this.containers.set(id, container);
|
||||
}
|
||||
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
|
||||
for (const tile of saved?.tiles ?? []) {
|
||||
this.world.add_tile({ id: tile.id, x: tile.x, y: tile.y, z: tile.z, mod_data: this.#upgrade_tile(tile) });
|
||||
}
|
||||
for (const entity of saved?.entities ?? []) {
|
||||
const item = ItemEntity.load(this.#new_entity_id(), entity);
|
||||
this.#entities.set(item.id, item);
|
||||
}
|
||||
this.#saved_players = saved?.players ?? {};
|
||||
this.world.dirty = saved?.version !== 2;
|
||||
this.world.dirty = saved?.version !== 3;
|
||||
|
||||
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 });
|
||||
@@ -185,20 +191,14 @@ export class GameServer {
|
||||
const second = this.#tick % TICKS_PER_SECOND === 0;
|
||||
|
||||
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)
|
||||
.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;
|
||||
}
|
||||
if (!this.#near_any_player(tile.x, tile.z)) {
|
||||
continue;
|
||||
}
|
||||
behavior?.on_tick?.(this, tile);
|
||||
if (second) {
|
||||
behavior?.on_second?.(this, tile);
|
||||
}
|
||||
if (components.length > 0 && tile.mod_data !== undefined) {
|
||||
const block = this.mods.block_ref(tile.x, tile.y, tile.z, tile.id);
|
||||
for (const { mod, id, component, params } of components) {
|
||||
if (component.on_tick) {
|
||||
@@ -208,7 +208,6 @@ export class GameServer {
|
||||
run_guarded(mod, `${id} on_second`, () => component.on_second!(block, params, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
// tile data can change on any tick, saving is cheap enough to not track it exactly
|
||||
this.world.dirty = true;
|
||||
}
|
||||
@@ -235,7 +234,7 @@ export class GameServer {
|
||||
this.#saved_players[player.name] = player.save();
|
||||
}
|
||||
const saved: SavedWorld = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
seed: this.world.seed,
|
||||
changes: this.world.all_changes(),
|
||||
tiles: [...this.world.tiles.values()].map((tile) => ({
|
||||
@@ -243,12 +242,11 @@ export class GameServer {
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
z: tile.z,
|
||||
data: tile.data,
|
||||
containers: Object.fromEntries(
|
||||
Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]),
|
||||
),
|
||||
mod_data: tile.mod_data,
|
||||
})),
|
||||
containers: Object.fromEntries(
|
||||
[...this.containers].map(([id, container]) => [id, container.to_data()]),
|
||||
),
|
||||
players: this.#saved_players,
|
||||
entities: [...this.#entities.values()].map((entity) => entity.save()),
|
||||
mod_storage: this.mods.storage,
|
||||
@@ -365,24 +363,9 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
const old_tile = this.world.get_tile(x, y, z);
|
||||
if (old_tile) {
|
||||
BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player);
|
||||
this.world.remove_tile(x, y, z);
|
||||
for (const other of this.#players.values()) {
|
||||
if (other.screen?.tile === old_tile) {
|
||||
this.#close_screen(other);
|
||||
this.#send(other, { type: "close_screen" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.world.set_block(x, y, z, id, state);
|
||||
|
||||
if (BLOCK_BEHAVIORS[id]?.create_tile) {
|
||||
this.get_or_create_tile(x, y, z);
|
||||
}
|
||||
|
||||
if (id !== AIR_ID) {
|
||||
const block = this.mods.block_ref(x, y, z, id);
|
||||
for (const { mod, id: component_id, component, params } of this.mods.components_of(id)) {
|
||||
@@ -396,23 +379,72 @@ export class GameServer {
|
||||
get_or_create_tile(x: number, y: number, z: number): Tile {
|
||||
let tile = this.world.get_tile(x, y, z);
|
||||
if (!tile) {
|
||||
// blocks placed before tiles were saved have none yet
|
||||
const id = this.world.get_block_id(x, y, z);
|
||||
tile = { id, x, y, z, data: {}, containers: {} };
|
||||
BLOCK_BEHAVIORS[id]?.create_tile?.(tile);
|
||||
tile = { id: this.world.get_block_id(x, y, z), x, y, z };
|
||||
this.world.add_tile(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) {
|
||||
this.#close_screen(player);
|
||||
player.screen = screen;
|
||||
this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() });
|
||||
player.sent.set("properties", JSON.stringify(screen.properties()));
|
||||
this.#send(player, { type: "open_screen", layout: screen.layout, properties: { ...screen.properties } });
|
||||
player.sent.set("properties", JSON.stringify(screen.properties));
|
||||
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
|
||||
|
||||
// hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md
|
||||
@@ -623,7 +655,7 @@ export class GameServer {
|
||||
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)) {
|
||||
if (component.on_interact) {
|
||||
handled = run_guarded(mod, `${component_id} on_interact`, () =>
|
||||
@@ -715,6 +747,11 @@ export class GameServer {
|
||||
if (item && take_output(item, player.cursor)) {
|
||||
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 {
|
||||
click_slot(container, index, player.cursor, button);
|
||||
}
|
||||
@@ -722,6 +759,9 @@ export class GameServer {
|
||||
if (key === "crafting") {
|
||||
update_crafting_result(container, this.recipes.shaped);
|
||||
}
|
||||
if (key === "screen") {
|
||||
this.world.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
#chat(player: ServerPlayer, raw: unknown) {
|
||||
@@ -756,8 +796,16 @@ export class GameServer {
|
||||
this.#send(player, { type: "chat", text: "Usage: /give <item> [count]" });
|
||||
return;
|
||||
}
|
||||
// a name without a namespace works when only one mod has an item called that
|
||||
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);
|
||||
if (!EverythingRegistry.get("items", item_id)) {
|
||||
@@ -793,7 +841,9 @@ export class GameServer {
|
||||
}
|
||||
|
||||
#close_screen(player: ServerPlayer) {
|
||||
const screen = player.screen;
|
||||
player.screen = undefined;
|
||||
for (const fn of screen?.on_close ?? []) fn();
|
||||
player.sent.delete("screen");
|
||||
player.sent.delete("properties");
|
||||
|
||||
@@ -949,7 +999,7 @@ export class GameServer {
|
||||
if (player.screen) {
|
||||
const items = player.screen.container.to_data();
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { register_mod_data } from "$/common/mod_loader.ts";
|
||||
import type { AtlasListing, ModData, ModListing } from "$/common/mod_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 { ModRuntime } from "./mod_runtime.ts";
|
||||
|
||||
@@ -23,7 +22,6 @@ export async function start_game(
|
||||
mods: ServerModSource[],
|
||||
): Promise<GameServer> {
|
||||
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) =>
|
||||
mod.worldgen_url ? [{ mod: mod.listing.id, url: mod.worldgen_url }] : []
|
||||
|
||||
+142
-11
@@ -8,10 +8,12 @@ import type {
|
||||
BlockRef,
|
||||
Command,
|
||||
Container,
|
||||
ContainerScreenOptions,
|
||||
EventSignal,
|
||||
ItemComponent,
|
||||
ItemStack,
|
||||
Player,
|
||||
ScreenHandle,
|
||||
ServerAfterEvents,
|
||||
ServerBeforeEvents,
|
||||
ServerContext,
|
||||
@@ -19,7 +21,8 @@ import type {
|
||||
import { ModLoadError } from "$/common/mod_loader.ts";
|
||||
import { AIR_ID } from "$/common/protocol.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
|
||||
export class Signal<T> {
|
||||
@@ -107,6 +110,8 @@ export class ModRuntime {
|
||||
#next_timer = 1;
|
||||
#setting_up = true;
|
||||
#players = new WeakMap<ServerPlayer, Player>();
|
||||
// the other way, for apis that take a player
|
||||
#server_players = new WeakMap<Player, ServerPlayer>();
|
||||
game!: GameServer;
|
||||
|
||||
// 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
|
||||
const previous = item.on_create;
|
||||
item.on_create = (stack) => {
|
||||
@@ -231,6 +249,7 @@ export class ModRuntime {
|
||||
if (!api) {
|
||||
api = player_api(this.game, player);
|
||||
this.#players.set(player, api);
|
||||
this.#server_players.set(api, player);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
@@ -339,6 +358,7 @@ export class ModRuntime {
|
||||
return true;
|
||||
},
|
||||
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,
|
||||
get seed() {
|
||||
return game().world.seed;
|
||||
@@ -355,6 +375,14 @@ export class ModRuntime {
|
||||
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: {
|
||||
furnace_result: (input) => {
|
||||
const recipe = game().recipes.furnace.get(input);
|
||||
@@ -364,8 +392,27 @@ export class ModRuntime {
|
||||
is_fuel: (item) => game().recipes.fuel.has(item),
|
||||
is_smeltable: (item) => game().recipes.furnace.has(item),
|
||||
},
|
||||
containers: not_yet_object(mod, "containers", "step 7 in MODS.md"),
|
||||
ui: not_yet_object(mod, "ui", "step 7 in MODS.md"),
|
||||
containers: {
|
||||
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"),
|
||||
storage: {
|
||||
get: (key) => structuredClone(this.storage[mod]?.[key]) as never,
|
||||
@@ -383,6 +430,78 @@ export class ModRuntime {
|
||||
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) {
|
||||
const handle = this.#next_timer++;
|
||||
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;
|
||||
}
|
||||
|
||||
const MAX_CONTAINER_SIZE = 256;
|
||||
|
||||
// the engine's item stacks as mods see them: { id, count, data }
|
||||
export function item_api(stack: EngineItemStack): ItemStack {
|
||||
return {
|
||||
@@ -454,22 +575,32 @@ function new_stack(item: ItemStack): EngineItemStack {
|
||||
return stack;
|
||||
}
|
||||
|
||||
function player_api(game: GameServer, player: ServerPlayer): Player {
|
||||
const inventory: Container = {
|
||||
id: `player:${player.id}`,
|
||||
size: player.inventory.size,
|
||||
// an engine container as mods see it
|
||||
function container_api(game: GameServer, id: string, container: EngineContainer): Container {
|
||||
return {
|
||||
id,
|
||||
size: container.size,
|
||||
get: (slot) => {
|
||||
const stack = player.inventory.get_item(slot);
|
||||
const stack = container.get_item(slot);
|
||||
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) => {
|
||||
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;
|
||||
},
|
||||
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 {
|
||||
get id() {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts";
|
||||
import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts";
|
||||
import { Tile } from "./world.ts";
|
||||
|
||||
export const INVENTORY_SIZE = 9 * 4;
|
||||
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 {
|
||||
tile: Tile;
|
||||
container: Container;
|
||||
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.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 { block_value, chunk_key, default_block_value } from "$/common/utils.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
|
||||
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 {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
data: Record<string, unknown>;
|
||||
containers: Record<string, Container>;
|
||||
// a mod block's data, what BlockRef.data holds
|
||||
// what BlockRef.data holds
|
||||
mod_data?: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+436
@@ -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": {}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function mod_sources(mods_dir: string): ServerModSource[] {
|
||||
},
|
||||
data: {
|
||||
blocks: mod.blocks.map((b) => b.json),
|
||||
models: mod.models.map((m) => m.json),
|
||||
items: mod.items.map((i) => i.json),
|
||||
recipes: mod.recipes.map((r) => r.json),
|
||||
ores: mod.ores.map((o) => o.json),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AIR, CHUNK_HEIGHT } from "$/common/constants.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 { generate_raw_chunk } from "$/common/generation.ts";
|
||||
import { get_state_value } from "$/common/utils.ts";
|
||||
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
||||
import { create_mod } from "$/tools/new_mod.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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await test_game("mods");
|
||||
for (const file of Deno.readDirSync("mods/bworld/blocks")) {
|
||||
|
||||
@@ -24,6 +24,28 @@ export function setup(ctx: ServerContext) {
|
||||
on_tick() { throw new Error("broken on purpose"); },
|
||||
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", {
|
||||
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`,
|
||||
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(
|
||||
`${mod}/blocks/lamp.json`,
|
||||
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);
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user