Implement main game as a mod
This commit is contained in:
+39
-59
@@ -1,5 +1,7 @@
|
||||
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||
import { ScreenLayout } from "$/common/protocol.ts";
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.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";
|
||||
@@ -80,50 +82,6 @@ BLOCK_BEHAVIORS["bworld:chest"] = {
|
||||
|
||||
// furnace
|
||||
|
||||
export interface FurnaceRecipe {
|
||||
input: string;
|
||||
output: ItemStack;
|
||||
cook_time: number;
|
||||
}
|
||||
|
||||
export const FURNACE_RECIPES: FurnaceRecipe[] = [
|
||||
{
|
||||
input: "bworld:log",
|
||||
output: new ItemStack("bworld:coal", 1),
|
||||
cook_time: 100,
|
||||
},
|
||||
{
|
||||
input: "bworld:coal_ore",
|
||||
output: new ItemStack("bworld:coal", 1),
|
||||
cook_time: 100,
|
||||
},
|
||||
{
|
||||
input: "bworld:iron_ore",
|
||||
output: new ItemStack("bworld:iron_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:tin_ore",
|
||||
output: new ItemStack("bworld:tin_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:copper_ore",
|
||||
output: new ItemStack("bworld:copper_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:gold_ore",
|
||||
output: new ItemStack("bworld:gold_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
];
|
||||
|
||||
export const FUEL_VALUES: Record<string, number> = {
|
||||
"bworld:coal": 1000,
|
||||
"bworld:log": 100,
|
||||
};
|
||||
|
||||
interface FurnaceData {
|
||||
progress: number;
|
||||
progress_max: number;
|
||||
@@ -131,12 +89,17 @@ interface FurnaceData {
|
||||
fuel_max: number;
|
||||
}
|
||||
|
||||
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
|
||||
interface FurnaceRecipe {
|
||||
output: { id: string; count: number };
|
||||
cook_time: number;
|
||||
}
|
||||
|
||||
function get_recipe(recipes: RecipeBook, input?: ItemStack | undefined): FurnaceRecipe | undefined {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
|
||||
return recipes.furnace.get(input.type_id);
|
||||
}
|
||||
|
||||
function can_craft(container: Container, recipe?: FurnaceRecipe) {
|
||||
@@ -150,27 +113,27 @@ function can_craft(container: Container, recipe?: FurnaceRecipe) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (output.type_id !== recipe.output.type_id) {
|
||||
if (output.type_id !== recipe.output.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return output.amount < output.max_amount;
|
||||
}
|
||||
|
||||
function get_fuel_value(item?: ItemStack | undefined): number {
|
||||
function get_fuel_value(recipes: RecipeBook, item?: ItemStack | undefined): number {
|
||||
if (!item) {
|
||||
return 0;
|
||||
}
|
||||
return FUEL_VALUES[item.type_id] ?? 0;
|
||||
return recipes.fuel.get(item.type_id) ?? 0;
|
||||
}
|
||||
|
||||
function has_fuel(container: Container) {
|
||||
return get_fuel_value(container.get_item(1)) > 0;
|
||||
function has_fuel(recipes: RecipeBook, container: Container) {
|
||||
return get_fuel_value(recipes, container.get_item(1)) > 0;
|
||||
}
|
||||
|
||||
function consume_fuel(container: Container): number {
|
||||
function consume_fuel(recipes: RecipeBook, container: Container): number {
|
||||
const fuel = container.get_slot(1)!;
|
||||
const value = get_fuel_value(fuel.get_item());
|
||||
const value = get_fuel_value(recipes, fuel.get_item());
|
||||
|
||||
if (fuel.has_item()) {
|
||||
fuel.amount! -= 1;
|
||||
@@ -184,9 +147,9 @@ function craft(container: Container, recipe: FurnaceRecipe) {
|
||||
const output = container.get_item(2);
|
||||
|
||||
if (!output) {
|
||||
container.set_item(2, recipe.output.clone());
|
||||
container.set_item(2, new ItemStack(recipe.output.id, recipe.output.count));
|
||||
} else {
|
||||
output.amount += recipe.output.amount;
|
||||
output.amount += recipe.output.count;
|
||||
}
|
||||
|
||||
input.amount -= 1;
|
||||
@@ -241,12 +204,12 @@ BLOCK_BEHAVIORS["bworld:furnace"] = {
|
||||
on_break(_game, tile, player) {
|
||||
give_container_contents(tile, player);
|
||||
},
|
||||
on_tick(_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(input);
|
||||
const recipe = get_recipe(game.recipes, input);
|
||||
|
||||
// burn fuel
|
||||
if (data.fuel > 0) {
|
||||
@@ -259,8 +222,8 @@ BLOCK_BEHAVIORS["bworld:furnace"] = {
|
||||
}
|
||||
|
||||
// refuel
|
||||
if (data.fuel === 0 && has_fuel(container)) {
|
||||
data.fuel = consume_fuel(container);
|
||||
if (data.fuel === 0 && has_fuel(game.recipes, container)) {
|
||||
data.fuel = consume_fuel(game.recipes, container);
|
||||
data.fuel_max = data.fuel;
|
||||
}
|
||||
|
||||
@@ -276,3 +239,20 @@ BLOCK_BEHAVIORS["bworld:furnace"] = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-77
@@ -1,80 +1,8 @@
|
||||
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||
import { CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||
import type { GridRecipe } from "$/common/mod_data.ts";
|
||||
|
||||
export interface CraftingRecipe {
|
||||
width: number;
|
||||
height: number;
|
||||
pattern: (string | undefined)[];
|
||||
result: { id: string; count: number };
|
||||
}
|
||||
|
||||
export const CRAFTING_RECIPES: CraftingRecipe[] = [
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
undefined,
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
],
|
||||
result: { id: "bworld:chest", count: 1 },
|
||||
},
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
undefined,
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
],
|
||||
result: { id: "bworld:furnace", count: 1 },
|
||||
},
|
||||
{
|
||||
width: 1,
|
||||
height: 1,
|
||||
pattern: [
|
||||
"bworld:log",
|
||||
],
|
||||
result: { id: "bworld:planks", count: 2 },
|
||||
},
|
||||
{
|
||||
width: 1,
|
||||
height: 2,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
],
|
||||
result: { id: "bworld:stick", count: 2 },
|
||||
},
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
undefined,
|
||||
"bworld:stick",
|
||||
undefined,
|
||||
undefined,
|
||||
"bworld:stick",
|
||||
undefined,
|
||||
],
|
||||
result: { id: "bworld:wood_pickaxe", count: 1 },
|
||||
},
|
||||
];
|
||||
// recipes come from mods, see RecipeBook in common/mod_loader.ts
|
||||
|
||||
function get_crafting_grid(crafting: Container): (string | undefined)[] {
|
||||
const grid: (string | undefined)[] = [];
|
||||
@@ -84,7 +12,7 @@ function get_crafting_grid(crafting: Container): (string | undefined)[] {
|
||||
return grid;
|
||||
}
|
||||
|
||||
function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): boolean {
|
||||
function matches_recipe(grid: (string | undefined)[], recipe: GridRecipe): boolean {
|
||||
for (let y = 0; y <= 3 - recipe.height; y++) {
|
||||
for (let x = 0; x <= 3 - recipe.width; x++) {
|
||||
let match = true;
|
||||
@@ -115,9 +43,9 @@ function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): b
|
||||
}
|
||||
|
||||
// puts what the grid makes in the result slot
|
||||
export function update_crafting_result(crafting: Container) {
|
||||
export function update_crafting_result(crafting: Container, recipes: GridRecipe[]) {
|
||||
const grid = get_crafting_grid(crafting);
|
||||
const recipe = CRAFTING_RECIPES.find((recipe) => matches_recipe(grid, recipe));
|
||||
const recipe = recipes.find((recipe) => matches_recipe(grid, recipe));
|
||||
crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined);
|
||||
}
|
||||
|
||||
|
||||
+166
-22
@@ -1,6 +1,3 @@
|
||||
import "$/common/blocks/mod.ts";
|
||||
import "$/common/items/mod.ts";
|
||||
|
||||
import {
|
||||
AIR,
|
||||
CHUNK_HEIGHT,
|
||||
@@ -23,7 +20,10 @@ import {
|
||||
MAX_NAME_LENGTH,
|
||||
ServerMessage,
|
||||
} from "$/common/protocol.ts";
|
||||
import type { 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 { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
||||
import { ServerWorld, Tile } from "./world.ts";
|
||||
@@ -52,8 +52,20 @@ export interface SavedWorld {
|
||||
z: number;
|
||||
data: Record<string, unknown>;
|
||||
containers: Record<string, (ItemData | null)[]>;
|
||||
// a mod block's data, what BlockRef.data holds
|
||||
mod_data?: unknown;
|
||||
}[];
|
||||
players: Record<string, SavedPlayer>;
|
||||
// ctx.storage of every mod, by mod id
|
||||
mod_storage?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// the loaded mods, see load_mods.ts
|
||||
export interface GameMods {
|
||||
listings: ModListing[];
|
||||
recipes: RecipeBook;
|
||||
worldgen?: WorldgenSetup;
|
||||
runtime: ModRuntime;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
@@ -62,13 +74,20 @@ export class GameServer {
|
||||
#players = new Map<number, ServerPlayer>();
|
||||
#saved_players: Record<string, SavedPlayer> = {};
|
||||
#tick = 0;
|
||||
#mods: GameMods;
|
||||
mods: ModRuntime;
|
||||
recipes: RecipeBook;
|
||||
|
||||
constructor(host: GameHost, save: string | undefined, default_seed: string) {
|
||||
constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) {
|
||||
this.#host = host;
|
||||
this.#mods = mods;
|
||||
this.mods = mods.runtime;
|
||||
this.recipes = mods.recipes;
|
||||
|
||||
// version 1 saves only had the seed and block changes
|
||||
const saved: Partial<SavedWorld> | undefined = save ? JSON.parse(save) : undefined;
|
||||
this.world = new ServerWorld(saved?.seed ?? default_seed);
|
||||
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> = {};
|
||||
@@ -97,6 +116,7 @@ export class GameServer {
|
||||
this.#saved_players[player.name] = player.save();
|
||||
this.world.dirty = true;
|
||||
this.#players.delete(conn);
|
||||
this.mods.after.player_leave.emit({ player: this.mods.player(player) });
|
||||
this.#broadcast({ type: "player_leave", id: player.id });
|
||||
this.#broadcast({ type: "chat", text: `${player.name} left` });
|
||||
console.log(`${player.name} left (${this.#players.size} online)`);
|
||||
@@ -133,20 +153,35 @@ export class GameServer {
|
||||
|
||||
for (const tile of [...this.world.tiles.values()]) {
|
||||
const behavior = BLOCK_BEHAVIORS[tile.id];
|
||||
if (!behavior?.on_tick && !behavior?.on_second) {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
if (!this.#near_any_player(tile.x, tile.z)) {
|
||||
continue;
|
||||
}
|
||||
behavior.on_tick?.(this, tile);
|
||||
behavior?.on_tick?.(this, tile);
|
||||
if (second) {
|
||||
behavior.on_second?.(this, tile);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
// tile data can change on any tick, saving is cheap enough to not track it exactly
|
||||
this.world.dirty = true;
|
||||
}
|
||||
|
||||
this.mods.tick();
|
||||
|
||||
for (const player of this.#players.values()) {
|
||||
this.#sync(player);
|
||||
}
|
||||
@@ -169,16 +204,60 @@ export class GameServer {
|
||||
containers: Object.fromEntries(
|
||||
Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]),
|
||||
),
|
||||
mod_data: tile.mod_data,
|
||||
})),
|
||||
players: this.#saved_players,
|
||||
mod_storage: this.mods.storage,
|
||||
};
|
||||
this.world.dirty = false;
|
||||
return JSON.stringify(saved);
|
||||
}
|
||||
|
||||
// used by block behaviors
|
||||
// used by block behaviors and mods
|
||||
|
||||
get current_tick() {
|
||||
return this.#tick;
|
||||
}
|
||||
|
||||
players(): ServerPlayer[] {
|
||||
return [...this.#players.values()];
|
||||
}
|
||||
|
||||
give(player: ServerPlayer, id: string, count: number, data?: unknown) {
|
||||
// split into stacks so max stack sizes are respected
|
||||
let left = count;
|
||||
while (left > 0) {
|
||||
const stack = new ItemStack(id);
|
||||
stack.amount = Math.min(left, stack.max_amount);
|
||||
if (data !== undefined) stack.data = structuredClone(data);
|
||||
left -= stack.amount;
|
||||
player.give(stack);
|
||||
}
|
||||
}
|
||||
|
||||
send_chat(player: ServerPlayer, text: string) {
|
||||
this.#send(player, { type: "chat", text });
|
||||
}
|
||||
|
||||
teleport(player: ServerPlayer, x: number, y: number, z: number) {
|
||||
Object.assign(player, { x, y, z });
|
||||
this.#send(player, { type: "teleport", x, y, z });
|
||||
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
|
||||
}
|
||||
|
||||
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) {
|
||||
const old_id = this.world.get_block_id(x, y, z);
|
||||
if (old_id !== AIR_ID) {
|
||||
const components = this.mods.components_of(old_id).filter(({ component }) => component.on_break);
|
||||
if (components.length > 0) {
|
||||
const block = this.mods.block_ref(x, y, z, old_id);
|
||||
const player_api = player && this.mods.player(player);
|
||||
for (const { mod, id: component_id, component, params } of components) {
|
||||
run_guarded(mod, `${component_id} on_break`, () => component.on_break!(block, params, player_api));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const old_tile = this.world.get_tile(x, y, z);
|
||||
if (old_tile) {
|
||||
BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player);
|
||||
@@ -196,6 +275,15 @@ export class GameServer {
|
||||
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)) {
|
||||
if (component.on_create) {
|
||||
run_guarded(mod, `${component_id} on_create`, () => component.on_create!(block, params));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get_or_create_tile(x: number, y: number, z: number): Tile {
|
||||
@@ -230,7 +318,9 @@ export class GameServer {
|
||||
this.#send(player, {
|
||||
type: "welcome",
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
seed: this.world.seed,
|
||||
mods: this.#mods.listings,
|
||||
players: [...this.#players.values()].map((p) => p.info()),
|
||||
changes: this.world.all_changes(),
|
||||
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
|
||||
@@ -241,6 +331,7 @@ export class GameServer {
|
||||
|
||||
this.#broadcast({ type: "player_join", player: player.info() }, player);
|
||||
this.#broadcast({ type: "chat", text: `${player.name} joined` });
|
||||
this.mods.after.player_join.emit({ player: this.mods.player(player) });
|
||||
console.log(`${player.name} joined (${this.#players.size} online)`);
|
||||
}
|
||||
|
||||
@@ -290,6 +381,18 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = {
|
||||
player: this.mods.player(player),
|
||||
block: this.mods.block_ref(x, y, z, info.id),
|
||||
item: this.mods.player(player).held_item,
|
||||
};
|
||||
const before = { ...event, cancel: false };
|
||||
this.mods.before.block_break.emit(before);
|
||||
if (before.cancel) {
|
||||
this.#correct(player, x, y, z);
|
||||
return;
|
||||
}
|
||||
|
||||
const held = EverythingRegistry.get<ItemRegistry>("items", player.held_item?.type_id ?? "");
|
||||
const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break);
|
||||
|
||||
@@ -297,6 +400,7 @@ export class GameServer {
|
||||
if (drops) {
|
||||
player.give(new ItemStack(info.drop_table!));
|
||||
}
|
||||
this.mods.after.block_break.emit(event);
|
||||
}
|
||||
|
||||
#use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) {
|
||||
@@ -310,7 +414,28 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) {
|
||||
const player_api = this.mods.player(player);
|
||||
const block = this.mods.block_ref(x, y, z, info.id);
|
||||
const interact = { player: player_api, block, item: player_api.held_item };
|
||||
const before_interact = { ...interact, cancel: false };
|
||||
this.mods.before.block_interact.emit(before_interact);
|
||||
if (before_interact.cancel) {
|
||||
this.#correct(player, tx, ty, tz);
|
||||
return;
|
||||
}
|
||||
|
||||
let handled = BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player) ?? 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`, () =>
|
||||
component.on_interact!(block, params, player_api)) ===
|
||||
true || handled;
|
||||
}
|
||||
}
|
||||
if (handled) {
|
||||
this.mods.after.block_interact.emit(interact);
|
||||
// the client didn't guess a placement for interactive blocks, but it might have for others
|
||||
this.#correct(player, tx, ty, tz);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,8 +447,22 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const place = {
|
||||
player: player_api,
|
||||
block: { id: held.block_id, x: tx, y: ty, z: tz },
|
||||
face,
|
||||
item: player_api.held_item,
|
||||
};
|
||||
const before_place = { ...place, cancel: false };
|
||||
this.mods.before.block_place.emit(before_place);
|
||||
if (before_place.cancel) {
|
||||
this.#correct(player, tx, ty, tz);
|
||||
return;
|
||||
}
|
||||
|
||||
this.set_block(tx, ty, tz, held.block_id, player);
|
||||
held_slot.amount = held_slot.amount! - 1;
|
||||
this.mods.after.block_place.emit(place);
|
||||
}
|
||||
|
||||
#click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) {
|
||||
@@ -350,7 +489,7 @@ export class GameServer {
|
||||
}
|
||||
|
||||
if (key === "crafting") {
|
||||
update_crafting_result(container);
|
||||
update_crafting_result(container, this.recipes.shaped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,8 +505,15 @@ export class GameServer {
|
||||
this.#command(player, text);
|
||||
return;
|
||||
}
|
||||
console.log(`<${player.name}> ${text}`);
|
||||
this.#broadcast({ type: "chat", from: player.name, text });
|
||||
const before = { player: this.mods.player(player), message: text, cancel: false };
|
||||
this.mods.before.chat_send.emit(before);
|
||||
const message = String(before.message).slice(0, MAX_CHAT_LENGTH);
|
||||
if (before.cancel || message.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log(`<${player.name}> ${message}`);
|
||||
this.#broadcast({ type: "chat", from: player.name, text: message });
|
||||
this.mods.after.chat_send.emit({ player: before.player, message });
|
||||
}
|
||||
|
||||
#command(player: ServerPlayer, text: string) {
|
||||
@@ -391,14 +537,12 @@ export class GameServer {
|
||||
this.#send(player, { type: "chat", text: `Count must be between 1 and ${MAX_GIVE}` });
|
||||
return;
|
||||
}
|
||||
// split into stacks so max stack sizes are respected
|
||||
let left = amount;
|
||||
while (left > 0) {
|
||||
const stack = new ItemStack(item_id);
|
||||
stack.amount = Math.min(left, stack.max_amount);
|
||||
left -= stack.amount;
|
||||
player.give(stack);
|
||||
}
|
||||
this.give(player, item_id, amount);
|
||||
return;
|
||||
}
|
||||
const mod_command = this.mods.commands.get(command);
|
||||
if (mod_command) {
|
||||
run_guarded(mod_command.mod, `/${command}`, () => mod_command.command.run(args, this.mods.player(player)));
|
||||
return;
|
||||
}
|
||||
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
|
||||
@@ -417,7 +561,7 @@ export class GameServer {
|
||||
player.crafting.set_item(i, undefined);
|
||||
}
|
||||
}
|
||||
update_crafting_result(player.crafting);
|
||||
update_crafting_result(player.crafting, this.recipes.shaped);
|
||||
if (player.cursor.item) {
|
||||
player.give(player.cursor.item);
|
||||
player.cursor.item = undefined;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { ModData, ModListing } from "$/common/mod_loader.ts";
|
||||
|
||||
// messages between server/main.ts (the host) and the game server worker
|
||||
|
||||
export type HostToGame =
|
||||
| { type: "init"; save: string | undefined; default_seed: string }
|
||||
| {
|
||||
type: "init";
|
||||
save: string | undefined;
|
||||
default_seed: string;
|
||||
// in load order, with the scripts' code since the worker can't read files
|
||||
mods: { listing: ModListing; data: ModData; server_code?: string; worldgen_code?: string }[];
|
||||
}
|
||||
| { type: "connect"; conn: number }
|
||||
| { type: "message"; conn: number; data: string }
|
||||
| { type: "disconnect"; conn: number }
|
||||
@@ -10,6 +18,7 @@ export type HostToGame =
|
||||
|
||||
export type GameToHost =
|
||||
| { type: "ready"; seed: string }
|
||||
| { type: "failed"; error: string }
|
||||
| { type: "send"; conn: number; data: string }
|
||||
| { type: "close"; conn: number }
|
||||
| { type: "save"; data: string; final: boolean };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// starts a game server with its mods: registers their data, then imports worldgen and server scripts.
|
||||
// the worker loads built mods, tests can load mods straight from their source folders
|
||||
import { register_mod_data } from "$/common/mod_loader.ts";
|
||||
import type { 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";
|
||||
|
||||
export interface ServerModSource {
|
||||
listing: ModListing;
|
||||
data: ModData;
|
||||
// importable urls of the scripts: data: urls in the worker, file urls in tests
|
||||
server_url?: string;
|
||||
worldgen_url?: string;
|
||||
}
|
||||
|
||||
export async function start_game(
|
||||
host: GameHost,
|
||||
save: string | undefined,
|
||||
default_seed: string,
|
||||
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 }] : []
|
||||
);
|
||||
const worldgen = await load_worldgen(worldgen_scripts, recipes.ores);
|
||||
|
||||
const runtime = new ModRuntime();
|
||||
const game = new GameServer(host, save, default_seed, {
|
||||
listings: mods.map((mod) => mod.listing),
|
||||
recipes,
|
||||
worldgen,
|
||||
runtime,
|
||||
});
|
||||
|
||||
await runtime.load_scripts(
|
||||
game,
|
||||
mods.flatMap((mod) =>
|
||||
mod.server_url ? [{ mod: mod.listing.id, version: mod.listing.version, url: mod.server_url }] : []
|
||||
),
|
||||
);
|
||||
runtime.finish_setup();
|
||||
runtime.after.server_start.emit({});
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function data_url(code: string) {
|
||||
const bytes = new TextEncoder().encode(code);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return `data:application/javascript;base64,${btoa(binary)}`;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// runs mods' server scripts: builds each one's ServerContext and keeps what they register.
|
||||
// see "Server scripts" in MODS.md. parts not built yet throw when used, saying so
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { ItemStack as EngineItemStack } from "$/common/inventory.ts";
|
||||
import type {
|
||||
BlockComponent,
|
||||
BlockRef,
|
||||
Command,
|
||||
Container,
|
||||
EventSignal,
|
||||
ItemComponent,
|
||||
ItemStack,
|
||||
Player,
|
||||
ServerAfterEvents,
|
||||
ServerBeforeEvents,
|
||||
ServerContext,
|
||||
} from "$/common/mod_api/server.ts";
|
||||
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";
|
||||
|
||||
// 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> {
|
||||
#handlers: { mod: string; handler: (event: T) => void }[] = [];
|
||||
|
||||
for_mod(mod: string): EventSignal<T> {
|
||||
return {
|
||||
subscribe: (handler) => {
|
||||
const entry = { mod, handler };
|
||||
this.#handlers.push(entry);
|
||||
return () => {
|
||||
this.#handlers = this.#handlers.filter((h) => h !== entry);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
emit(event: T) {
|
||||
for (const { mod, handler } of [...this.#handlers]) {
|
||||
run_guarded(mod, "an event handler", () => handler(event));
|
||||
}
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.#handlers.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function run_guarded<T>(mod: string, what: string, fn: () => T): T | undefined {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
console.error(`[${mod}] ${what} threw:`, e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type Before = {
|
||||
[K in keyof ServerBeforeEvents]: ServerBeforeEvents[K] extends EventSignal<infer T> ? Signal<T> : never;
|
||||
};
|
||||
type After = { [K in keyof ServerAfterEvents]: ServerAfterEvents[K] extends EventSignal<infer T> ? Signal<T> : never };
|
||||
|
||||
interface Timer {
|
||||
mod: string;
|
||||
fn: () => void;
|
||||
at: number;
|
||||
every?: number;
|
||||
}
|
||||
|
||||
export class ModRuntime {
|
||||
block_components = new Map<string, { mod: string; component: BlockComponent }>();
|
||||
item_components = new Map<string, { mod: string; component: ItemComponent }>();
|
||||
commands = new Map<string, { mod: string; name: string; command: Command }>();
|
||||
|
||||
before: Before = {
|
||||
block_break: new Signal(),
|
||||
block_place: new Signal(),
|
||||
block_interact: new Signal(),
|
||||
chat_send: new Signal(),
|
||||
};
|
||||
after: After = {
|
||||
block_break: new Signal(),
|
||||
block_place: new Signal(),
|
||||
block_interact: new Signal(),
|
||||
chat_send: new Signal(),
|
||||
player_join: new Signal(),
|
||||
player_leave: new Signal(),
|
||||
server_start: new Signal(),
|
||||
tick: new Signal(),
|
||||
};
|
||||
|
||||
// per mod key value storage, saved with the world
|
||||
storage: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
#timers = new Map<number, Timer>();
|
||||
#next_timer = 1;
|
||||
#setting_up = true;
|
||||
#players = new WeakMap<ServerPlayer, Player>();
|
||||
game!: GameServer;
|
||||
|
||||
// imports each server script and calls its setup, in load order
|
||||
async load_scripts(game: GameServer, scripts: { mod: string; version: string; url: string }[]) {
|
||||
this.game = game;
|
||||
for (const { mod, version, url } of scripts) {
|
||||
const module = await import(url);
|
||||
if (typeof module.setup !== "function") {
|
||||
throw new ModLoadError(mod, "the server script doesn't export a setup function");
|
||||
}
|
||||
await module.setup(this.#context(mod, version));
|
||||
}
|
||||
}
|
||||
|
||||
// after every setup ran: registration closes and every component blocks and items use must exist
|
||||
finish_setup() {
|
||||
this.#setting_up = false;
|
||||
|
||||
for (const [id, block] of EverythingRegistry.entries<{ components?: Record<string, unknown> }>("blocks")) {
|
||||
for (const component of Object.keys(block.components ?? {})) {
|
||||
if (!this.block_components.has(component)) {
|
||||
throw new ModLoadError(
|
||||
id.split(":")[0],
|
||||
`block ${id} uses component ${component}, which nothing registered`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, item] of EverythingRegistry.entries<ItemRegistry>("items")) {
|
||||
const components = Object.entries(item.components ?? {});
|
||||
if (components.length === 0) continue;
|
||||
for (const [component_id] of components) {
|
||||
if (!this.item_components.has(component_id)) {
|
||||
throw new ModLoadError(
|
||||
id.split(":")[0],
|
||||
`item ${id} uses component ${component_id}, which nothing registered`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// items are created all over the engine, hook their creation once here
|
||||
const previous = item.on_create;
|
||||
item.on_create = (stack) => {
|
||||
previous?.(stack);
|
||||
for (const [component_id, params] of components) {
|
||||
const { mod, component } = this.item_components.get(component_id)!;
|
||||
if (component.on_create) {
|
||||
run_guarded(
|
||||
mod,
|
||||
`${component_id} on_create`,
|
||||
() => component.on_create!(item_api(stack), params),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// components on a block, with their params from its json
|
||||
components_of(block_id: string): { mod: string; id: string; component: BlockComponent; params: unknown }[] {
|
||||
const block = EverythingRegistry.get<{ components?: Record<string, unknown> }>("blocks", block_id);
|
||||
return Object.entries(block?.components ?? {}).map(([id, params]) => ({
|
||||
...this.block_components.get(id)!,
|
||||
id,
|
||||
params,
|
||||
}));
|
||||
}
|
||||
|
||||
tick() {
|
||||
const now = this.game.current_tick;
|
||||
for (const [handle, timer] of [...this.#timers]) {
|
||||
if (timer.at > now) continue;
|
||||
if (timer.every) {
|
||||
timer.at = now + timer.every;
|
||||
} else {
|
||||
this.#timers.delete(handle);
|
||||
}
|
||||
run_guarded(timer.mod, "a timer", timer.fn);
|
||||
}
|
||||
if (!this.after.tick.empty) {
|
||||
this.after.tick.emit({ dt: 1 / 20 });
|
||||
}
|
||||
}
|
||||
|
||||
player(player: ServerPlayer): Player {
|
||||
let api = this.#players.get(player);
|
||||
if (!api) {
|
||||
api = player_api(this.game, player);
|
||||
this.#players.set(player, api);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
block_ref(x: number, y: number, z: number, id: string): BlockRef {
|
||||
const game = this.game;
|
||||
return {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
get data() {
|
||||
return game.world.get_tile(x, y, z)?.mod_data;
|
||||
},
|
||||
set data(value) {
|
||||
game.get_or_create_tile(x, y, z).mod_data = value;
|
||||
game.world.dirty = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#context(mod: string, version: string): ServerContext {
|
||||
const setup_only = (what: string) => {
|
||||
if (!this.#setting_up) {
|
||||
throw new Error(`[${mod}] ${what} can only be registered during setup`);
|
||||
}
|
||||
};
|
||||
const own = (id: string, what: string) => {
|
||||
if (!id.startsWith(`${mod}:`)) {
|
||||
throw new ModLoadError(mod, `${what} ${id} must be in the namespace "${mod}"`);
|
||||
}
|
||||
};
|
||||
const game = () => this.game;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
mod: { id: mod, version },
|
||||
components: {
|
||||
register_block: (id, component) => {
|
||||
setup_only("components");
|
||||
own(id, "component");
|
||||
if (this.block_components.has(id)) {
|
||||
throw new ModLoadError(mod, `component ${id} is registered twice`);
|
||||
}
|
||||
this.block_components.set(id, { mod, component: component as BlockComponent });
|
||||
},
|
||||
register_item: (id, component) => {
|
||||
setup_only("components");
|
||||
own(id, "component");
|
||||
if (this.item_components.has(id)) {
|
||||
throw new ModLoadError(mod, `component ${id} is registered twice`);
|
||||
}
|
||||
this.item_components.set(id, { mod, component: component as ItemComponent });
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
register: (name, command) => {
|
||||
setup_only("commands");
|
||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
|
||||
}
|
||||
if (name === "give") throw new ModLoadError(mod, `/give belongs to the base game`);
|
||||
const existing = this.commands.get(name);
|
||||
if (existing) {
|
||||
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
|
||||
}
|
||||
const entry = { mod, name, command };
|
||||
this.commands.set(name, entry);
|
||||
this.commands.set(`${mod}:${name}`, entry);
|
||||
},
|
||||
},
|
||||
events: {
|
||||
before: map_signals(this.before, mod),
|
||||
after: map_signals(this.after, mod),
|
||||
},
|
||||
system: {
|
||||
run_timeout: (fn, ticks) => this.#add_timer(mod, fn, ticks),
|
||||
run_interval: (fn, ticks) => this.#add_timer(mod, fn, ticks, Math.max(1, ticks)),
|
||||
clear_run: (handle) => void this.#timers.delete(handle),
|
||||
get current_tick() {
|
||||
return game().current_tick;
|
||||
},
|
||||
},
|
||||
world: {
|
||||
get_block: (x, y, z) => game().world.get_block_id(x, y, z),
|
||||
set_block: (x, y, z, id) => {
|
||||
if (id !== AIR_ID && !EverythingRegistry.get("blocks", id)) throw new Error(`unknown block ${id}`);
|
||||
game().set_block(x, y, z, id);
|
||||
return true;
|
||||
},
|
||||
get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"),
|
||||
set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"),
|
||||
get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never,
|
||||
is_loaded: () => true,
|
||||
get seed() {
|
||||
return game().world.seed;
|
||||
},
|
||||
},
|
||||
players: {
|
||||
all: () => game().players().map((p) => this.player(p)),
|
||||
get: (id) => {
|
||||
const p = game().players().find((p) => p.id === id);
|
||||
return p && this.player(p);
|
||||
},
|
||||
by_name: (name) => {
|
||||
const p = game().players().find((p) => p.name === name);
|
||||
return p && this.player(p);
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
furnace_result: (input) => {
|
||||
const recipe = game().recipes.furnace.get(input);
|
||||
return recipe && { output: { ...recipe.output }, cook_time: recipe.cook_time };
|
||||
},
|
||||
fuel_value: (item) => game().recipes.fuel.get(item) ?? 0,
|
||||
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"),
|
||||
net: not_yet_object(mod, "net", "step 8 in MODS.md"),
|
||||
storage: {
|
||||
get: (key) => structuredClone(this.storage[mod]?.[key]) as never,
|
||||
set: (key, value) => {
|
||||
(this.storage[mod] ??= {})[key] = structuredClone(value);
|
||||
game().world.dirty = true;
|
||||
},
|
||||
delete: (key) => {
|
||||
delete this.storage[mod]?.[key];
|
||||
game().world.dirty = true;
|
||||
},
|
||||
},
|
||||
log: (...args) => console.log(`[${mod}]`, ...args),
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
#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 });
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
// each event's signal, as seen by one mod so errors say which mod threw
|
||||
function map_signals<T extends { [name: string]: { for_mod(mod: string): unknown } }>(
|
||||
signals: T,
|
||||
mod: string,
|
||||
): { [K in keyof T]: ReturnType<T[K]["for_mod"]> } {
|
||||
return Object.fromEntries(Object.entries(signals).map(([name, signal]) => [name, signal.for_mod(mod)])) as {
|
||||
[K in keyof T]: ReturnType<T[K]["for_mod"]>;
|
||||
};
|
||||
}
|
||||
|
||||
function not_yet(mod: string, name: string, why: string) {
|
||||
return () => {
|
||||
throw new Error(`[${mod}] ctx.${name} isn't implemented yet: ${why}`);
|
||||
};
|
||||
}
|
||||
|
||||
// every method on it throws, saying what isn't built yet
|
||||
function not_yet_object<T>(mod: string, name: string, where: string): T {
|
||||
return new Proxy({}, {
|
||||
get: (_, prop) => not_yet(mod, `${name}.${String(prop)}`, where),
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// the engine's item stacks as mods see them: { id, count, data }
|
||||
export function item_api(stack: EngineItemStack): ItemStack {
|
||||
return {
|
||||
get id() {
|
||||
return stack.type_id;
|
||||
},
|
||||
get count() {
|
||||
return stack.amount;
|
||||
},
|
||||
set count(value) {
|
||||
stack.amount = value;
|
||||
},
|
||||
get data() {
|
||||
return stack.data;
|
||||
},
|
||||
set data(value) {
|
||||
stack.data = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function new_stack(item: ItemStack): EngineItemStack {
|
||||
if (!EverythingRegistry.get("items", item.id)) throw new Error(`unknown item ${item.id}`);
|
||||
const stack = new EngineItemStack(item.id, item.count);
|
||||
if (item.data !== undefined) stack.data = structuredClone(item.data);
|
||||
return stack;
|
||||
}
|
||||
|
||||
function player_api(game: GameServer, player: ServerPlayer): Player {
|
||||
const inventory: Container = {
|
||||
id: `player:${player.id}`,
|
||||
size: player.inventory.size,
|
||||
get: (slot) => {
|
||||
const stack = player.inventory.get_item(slot);
|
||||
return stack && item_api(stack);
|
||||
},
|
||||
set: (slot, item) => player.inventory.set_item(slot, item ? new_stack(item) : undefined),
|
||||
add: (item) => {
|
||||
const stack = new_stack(item);
|
||||
const left = player.inventory.add_item(stack);
|
||||
return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined;
|
||||
},
|
||||
on_change: not_yet("player", "inventory.on_change", "containers are step 7 in MODS.md"),
|
||||
};
|
||||
|
||||
return {
|
||||
get id() {
|
||||
return player.id;
|
||||
},
|
||||
get name() {
|
||||
return player.name;
|
||||
},
|
||||
get position() {
|
||||
return { x: player.x, y: player.y, z: player.z };
|
||||
},
|
||||
inventory,
|
||||
get selected_slot() {
|
||||
return player.selected_slot;
|
||||
},
|
||||
get held_item() {
|
||||
const stack = player.held_item;
|
||||
return stack && item_api(stack);
|
||||
},
|
||||
give_item(id, count = 1, data) {
|
||||
if (!EverythingRegistry.get("items", id)) throw new Error(`unknown item ${id}`);
|
||||
game.give(player, id, count, data);
|
||||
},
|
||||
send_message(text) {
|
||||
game.send_chat(player, String(text));
|
||||
},
|
||||
teleport(x, y, z) {
|
||||
game.teleport(player, x, y, z);
|
||||
},
|
||||
};
|
||||
}
|
||||
+40
-19
@@ -1,41 +1,30 @@
|
||||
/// <reference lib="deno.worker" />
|
||||
|
||||
// runs the game in a worker without any permissions, the host does files and networking
|
||||
import { GameServer, TICK_MS } from "./game_server.ts";
|
||||
import type { GameServer } from "./game_server.ts";
|
||||
import { TICK_MS } from "./game_server.ts";
|
||||
import { GameToHost, HostToGame } from "./host_protocol.ts";
|
||||
import { data_url, start_game } from "./load_mods.ts";
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000;
|
||||
|
||||
let game: GameServer | undefined;
|
||||
let starting: Promise<void> | undefined;
|
||||
|
||||
function post(message: GameToHost) {
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<HostToGame>) => {
|
||||
self.onmessage = async (event: MessageEvent<HostToGame>) => {
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === "init") {
|
||||
game = new GameServer(
|
||||
{
|
||||
send: (conn, data) => post({ type: "send", conn, data }),
|
||||
close: (conn) => post({ type: "close", conn }),
|
||||
},
|
||||
message.save,
|
||||
message.default_seed,
|
||||
);
|
||||
|
||||
setInterval(() => game!.tick(), TICK_MS);
|
||||
setInterval(() => {
|
||||
if (game!.world.dirty) {
|
||||
post({ type: "save", data: game!.save(), final: false });
|
||||
}
|
||||
}, SAVE_INTERVAL_MS);
|
||||
|
||||
post({ type: "ready", seed: game.world.seed });
|
||||
starting = init(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// players can connect while mods are still loading
|
||||
await starting;
|
||||
if (!game) {
|
||||
return;
|
||||
}
|
||||
@@ -55,3 +44,35 @@ self.onmessage = (event: MessageEvent<HostToGame>) => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
async function init(message: Extract<HostToGame, { type: "init" }>) {
|
||||
try {
|
||||
game = await start_game(
|
||||
{
|
||||
send: (conn, data) => post({ type: "send", conn, data }),
|
||||
close: (conn) => post({ type: "close", conn }),
|
||||
},
|
||||
message.save,
|
||||
message.default_seed,
|
||||
message.mods.map((mod) => ({
|
||||
listing: mod.listing,
|
||||
data: mod.data,
|
||||
// the worker has no file access, so the host sends the code and it's imported from memory
|
||||
server_url: mod.server_code ? data_url(mod.server_code) : undefined,
|
||||
worldgen_url: mod.worldgen_code ? data_url(mod.worldgen_code) : undefined,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
post({ type: "failed", error: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
|
||||
setInterval(() => game!.tick(), TICK_MS);
|
||||
setInterval(() => {
|
||||
if (game!.world.dirty) {
|
||||
post({ type: "save", data: game!.save(), final: false });
|
||||
}
|
||||
}, SAVE_INTERVAL_MS);
|
||||
|
||||
post({ type: "ready", seed: game.world.seed });
|
||||
}
|
||||
|
||||
@@ -1,6 +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 } from "$/common/generation.ts";
|
||||
import { generate_raw_chunk, RawChunk, WorldgenSetup } from "$/common/generation.ts";
|
||||
import { Container } from "$/common/inventory.ts";
|
||||
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||
import { chunk_key } from "$/common/utils.ts";
|
||||
@@ -18,6 +18,8 @@ export interface Tile {
|
||||
z: number;
|
||||
data: Record<string, unknown>;
|
||||
containers: Record<string, Container>;
|
||||
// a mod block's data, what BlockRef.data holds
|
||||
mod_data?: unknown;
|
||||
}
|
||||
|
||||
export function position_key(x: number, y: number, z: number) {
|
||||
@@ -28,6 +30,7 @@ export function position_key(x: number, y: number, z: number) {
|
||||
export class ServerWorld {
|
||||
readonly seed: string;
|
||||
readonly block_ids: Record<string, number> = {};
|
||||
readonly worldgen: WorldgenSetup | undefined;
|
||||
|
||||
// what generation made, and the final blocks with neighbors' leaves and player changes applied
|
||||
#raw = new LruMap<number, RawChunk>(RAW_CACHE_SIZE);
|
||||
@@ -42,8 +45,9 @@ export class ServerWorld {
|
||||
|
||||
on_block_change?: (x: number, y: number, z: number, id: string) => void;
|
||||
|
||||
constructor(seed: string) {
|
||||
constructor(seed: string, worldgen?: WorldgenSetup) {
|
||||
this.seed = seed;
|
||||
this.worldgen = worldgen;
|
||||
EverythingRegistry.get_registry<BlockRegistry>("blocks").forEach((block, nid) => {
|
||||
this.block_ids[block.id] = nid;
|
||||
});
|
||||
@@ -131,7 +135,7 @@ export class ServerWorld {
|
||||
const key = chunk_key(chunk_x, chunk_z);
|
||||
let raw = this.#raw.get(key);
|
||||
if (!raw) {
|
||||
raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids);
|
||||
raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids, this.worldgen);
|
||||
this.#raw.set(key, raw);
|
||||
}
|
||||
return raw;
|
||||
|
||||
+27
-2
@@ -1,9 +1,11 @@
|
||||
import { serveDir } from "@std/http/file-server";
|
||||
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
|
||||
import type { ServerModIndex } from "../build.ts";
|
||||
|
||||
const PORT = Number(Deno.env.get("PORT") ?? 8000);
|
||||
const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
|
||||
const STATIC_ROOT = "build";
|
||||
const STATIC_ROOT = Deno.env.get("BUILD_DIR") ?? "build";
|
||||
const SERVER_MODS_DIR = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
|
||||
const MAX_MESSAGE_SIZE = 4096;
|
||||
const SHUTDOWN_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -43,6 +45,10 @@ game.onmessage = (event: MessageEvent<GameToHost>) => {
|
||||
case "ready":
|
||||
console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`);
|
||||
break;
|
||||
case "failed":
|
||||
console.error(`Couldn't start the game: ${message.error}`);
|
||||
Deno.exit(1);
|
||||
break;
|
||||
case "send": {
|
||||
const socket = sockets.get(message.conn);
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
@@ -63,13 +69,32 @@ game.onmessage = (event: MessageEvent<GameToHost>) => {
|
||||
}
|
||||
};
|
||||
|
||||
// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods
|
||||
function read_mods(): Extract<HostToGame, { type: "init" }>["mods"] {
|
||||
let index: ServerModIndex;
|
||||
try {
|
||||
index = JSON.parse(Deno.readTextFileSync(`${SERVER_MODS_DIR}/index.json`));
|
||||
} catch {
|
||||
console.error(`No ${SERVER_MODS_DIR}/index.json, run deno task build first (it also reports mod errors)`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
return index.mods.map(({ listing, server }) => ({
|
||||
listing,
|
||||
data: JSON.parse(Deno.readTextFileSync(`${STATIC_ROOT}/${listing.data}`)),
|
||||
server_code: server ? Deno.readTextFileSync(server) : undefined,
|
||||
worldgen_code: listing.worldgen ? Deno.readTextFileSync(`${STATIC_ROOT}/${listing.worldgen}`) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
game.onerror = (event) => {
|
||||
console.error("Game server crashed:", event.message);
|
||||
Deno.exit(1);
|
||||
};
|
||||
|
||||
const mods = read_mods();
|
||||
console.log(`Mods: ${mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`);
|
||||
const save = read_world();
|
||||
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() });
|
||||
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID(), mods });
|
||||
|
||||
function handle_socket(socket: WebSocket) {
|
||||
const conn = next_conn++;
|
||||
|
||||
Reference in New Issue
Block a user