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
-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";
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 [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 ?? []) {
const containers: Record<string, Container> = {};
for (const [name, items] of Object.entries(tile.containers)) {
containers[name] = new Container(items.length);
containers[name].load(items);
}
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
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,28 +191,21 @@ 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) {
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));
}
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
@@ -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.remove_tile(x, y, z);
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 }));
}
}
-2
View File
@@ -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
View File
@@ -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() {
+6 -4
View File
@@ -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
+2 -5
View File
@@ -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;
}