Actually implement mod code into server
This commit is contained in:
@@ -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
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user