"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 };