Files
anima 1ccf4a760b
build container / build (push) Failing after 5m13s
add custom game data overrides
2026-11-01 16:18:38 +01:00

186 lines
6.8 KiB
JavaScript

"use strict";
// Local loader for values and new elements from ./custom/game.js.
//
// The file is mounted read-only at /app/custom and read whenever the backend or
// game server starts. It is not part of the image, so edits need only a restart:
//
// docker compose restart al-backend al-game
//
// The loader is deliberately fail-loud: a malformed custom file aborts startup
// instead of silently producing a half-patched game. Unknown top-level keys,
// unknown drop tables, references to missing items and new maps (their geometry
// lives in MongoDB) are errors. New items/monsters are allowed when they use a
// skin that already exists in design/dimensions.js.
const fs = require("node:fs");
const path = require("node:path");
const file = path.resolve(__dirname, "..", "custom", "game.js");
// Sections that are deep-merged into the design globals. Maps are the exception
// handled below: only existing maps can be changed.
const sections = [
"items",
"monsters",
"npcs",
"maps",
"sets",
"craft",
"skills",
"conditions",
"classes",
"levels",
"upgrades",
"compounds",
"multipliers",
"titles",
"tokens",
"dismantle",
];
const allowed = new Set([...sections, "drops", "gold", "phrases", "apply"]);
function fail(message) {
throw new Error("custom-game: " + message);
}
function is_plain(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function clone(value) {
return structuredClone(value);
}
function load() {
if (!fs.existsSync(file)) return null;
const resolved = require.resolve(file);
// Re-read on every call: a restart of the game server reloads the mounted file.
delete require.cache[resolved];
const custom = require(resolved);
if (!is_plain(custom)) fail(file + " must export an object");
return custom;
}
function merge(target, source, where) {
for (const key of Object.keys(source)) {
const value = source[key];
if (value === undefined) continue;
if (is_plain(value)) {
if (!is_plain(target[key])) target[key] = {};
merge(target[key], value, where + "." + key);
} else if (Array.isArray(value)) {
target[key] = clone(value);
} else if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
fail(where + "." + key + " must be a plain value");
} else {
target[key] = value;
}
}
}
// A new element may only reuse an existing graphic, never introduce a new one.
// Monsters are drawn from design/dimensions.js (`dimensions`); items and
// condition icons are drawn from the sprite atlas (`positions`).
function check_graphics(section, id, entry, game) {
const dimensions = is_plain(game.dimensions) ? game.dimensions : {};
const positions = is_plain(game.positions) ? game.positions : {};
const exists = (section === "monsters" ? dimensions : positions);
if (section === "items") {
if (typeof entry.skin !== "string") fail("items." + id + ": new items need a skin (see design/dimensions.js)");
if (!exists[entry.skin]) fail("items." + id + ": skin \"" + entry.skin + "\" does not exist in design/dimensions.js");
}
if (section === "monsters") {
const skin = typeof entry.skin === "string" ? entry.skin : id;
if (!exists[skin]) fail("monsters." + id + ": skin \"" + skin + "\" does not exist in design/dimensions.js");
}
if (section === "conditions" && entry.skin !== undefined && !exists[entry.skin]) {
fail("conditions." + id + ": skin \"" + entry.skin + "\" does not exist in design/dimensions.js");
}
}
function merge_drops(game, drops) {
if (!is_plain(drops)) fail("drops must be an object of map -> rows");
for (const map of Object.keys(drops)) {
const rows = drops[map];
if (!Array.isArray(rows)) fail("drops." + map + " must be an array");
if (!Array.isArray(game.drops.maps[map])) fail("drops." + map + ": unknown drop table (see design/drops.js)");
for (const row of rows) {
if (!Array.isArray(row) || row.length < 2) fail("drops." + map + ": each row is [chance, item]");
if (typeof row[0] !== "number" || row[0] < 0) fail("drops." + map + ": chance must be a non-negative number");
if (typeof row[1] !== "string") fail("drops." + map + ": item must be a string");
if (!is_plain(game.items[row[1]])) fail("drops." + map + ": unknown item \"" + row[1] + "\"");
game.drops.maps[map].push(clone(row));
}
}
}
function apply(game) {
const custom = load();
if (!custom) return;
if (!is_plain(game)) fail("internal: game data is missing");
for (const key of Object.keys(custom)) {
if (!allowed.has(key)) fail("unknown top-level key \"" + key + "\"");
}
const created = [];
for (const section of sections) {
const entries = custom[section];
if (entries === undefined) continue;
if (!is_plain(entries)) fail(section + " must be an object of id -> values");
if (!is_plain(game[section])) fail("internal: " + section + " data is missing");
for (const id of Object.keys(entries)) {
const patch = entries[id];
if (!is_plain(patch)) fail(section + "." + id + " must be an object");
if (section === "maps" && !is_plain(game.maps[id])) {
fail("maps." + id + ": a new map needs geometry in MongoDB; only existing maps can be changed");
}
const is_new = !is_plain(game[section][id]);
if (is_new) {
game[section][id] = {};
created.push(section + "." + id);
}
merge(game[section][id], patch, section + "." + id);
if (is_new) check_graphics(section, id, game[section][id], game);
}
}
if (custom.drops !== undefined) merge_drops(game, custom.drops);
if (custom.gold !== undefined) {
if (!is_plain(game.drops.gold)) fail("internal: drops.gold data is missing");
if (!is_plain(custom.gold)) fail("gold must be an object");
merge(game.drops.gold, custom.gold, "gold");
}
if (custom.apply !== undefined) {
if (typeof custom.apply !== "function") fail("apply must be a function");
custom.apply(game);
}
console.log("custom-game: loaded " + file + (created.length ? " (new: " + created.join(", ") + ")" : ""));
}
function validate_phrases(where, map) {
if (map === undefined) return {};
if (!is_plain(map)) fail(where + " must be an object of phrase id -> text");
for (const id of Object.keys(map)) {
if (typeof map[id] !== "string") fail(where + "." + id + " must be a string");
}
return map;
}
// Phrase overrides reach both the server-rendered pages and the browser bundle.
// "*" applies to every language; a language entry takes precedence. This keeps
// numbers in a tooltip correct in all languages unless translated per language.
function phrases(language) {
const custom = load();
if (!custom || custom.phrases === undefined) return {};
if (!is_plain(custom.phrases)) fail("phrases must be an object of language -> phrase map");
const all = validate_phrases("phrases.*", custom.phrases["*"]);
const own = validate_phrases("phrases." + language, custom.phrases[language]);
return Object.assign({}, all, own);
}
module.exports = { apply: apply, phrases: phrases, file: file };