From 1ccf4a760bd578a147a874e144dc0988d98b6e18 Mon Sep 17 00:00:00 2001 From: anima Date: Sun, 1 Nov 2026 16:18:38 +0100 Subject: [PATCH] add custom game data overrides --- .gitignore | 6 +- Dockerfile | 8 ++ README.md | 46 +++++++++ docker-compose.yml | 2 + docker/custom_game.js | 185 ++++++++++++++++++++++++++++++++++++ docker/patch-custom-game.js | 136 ++++++++++++++++++++++++++ files/custom/game.js | 77 +++++++++++++++ 7 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 docker/custom_game.js create mode 100644 docker/patch-custom-game.js create mode 100644 files/custom/game.js diff --git a/.gitignore b/.gitignore index 7d2cb50..93cbf94 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -files/ \ No newline at end of file +files/* +!files/config +!files/config/** +!files/custom +!files/custom/** diff --git a/Dockerfile b/Dockerfile index 255ddd5..773e42f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,14 @@ COPY --chown=node:node docker/attribution/de.json /app/languages/de/attribution. COPY docker/patch-attribution.js /tmp/patch-attribution.js RUN node /tmp/patch-attribution.js /app/htmls/contents/account_help.html +# Local patch: custom game values and elements (docker/patch-custom-game.js). +# Hooks the loader into main.js and the game server, makes the Lone Wolf rates +# data-driven, and merges phrase overrides into the language catalogs. The +# loader itself is copied to /app/docker/ in the runtime stage; the custom file +# is mounted at runtime from ./files/custom. +COPY docker/patch-custom-game.js /tmp/patch-custom-game.js +RUN node /tmp/patch-custom-game.js /app + FROM ${NODE_IMAGE} AS runtime diff --git a/README.md b/README.md index 552ed07..d87d7fb 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,52 @@ https://adventure.land. Die Texte liegen in `docker/attribution/en.js` und Auch dieser Patch bricht den Build ab, wenn die Upstream-Vorlage nicht mehr passt. +`docker/patch-custom-game.js` hakt den Loader für `files/custom/game.js` in +Backend und Game-Server ein und macht die Lone-Wolf-Raten datengetrieben (siehe +„Spieldaten anpassen“). Fehlt ein Anker, bricht der Build ebenfalls ab. + +## Spieldaten anpassen (`files/custom/game.js`) + +Werte bestehender Elemente und neue Elemente lassen sich ohne Quelltextänderung +über `files/custom/game.js` setzen. Die Datei wird in beide Spielcontainer als +`/app/custom/game.js` gemountet und beim Start gelesen; Änderungen brauchen nur +einen Neustart, keinen Rebuild: + +```sh +docker compose restart al-backend al-game +``` + +Aufbau (alle Angaben optional): + +- `items`, `monsters`, `npcs`, `maps`, `sets`, `craft`, `skills`, `conditions`, + `classes`, `levels`, `upgrades`, `compounds`, `multipliers`, `titles`, + `tokens`, `dismantle`: Objekte werden tief zusammengeführt, Arrays ersetzt. + Neue IDs sind erlaubt, wenn sie eine vorhandene Grafik aus + `design/dimensions.js` nutzen (Items und Monster brauchen ein `skin`). + Neue Karten gehen nicht: ihre Geometrie liegt in MongoDB (`MP_*`). +- `drops`: `{ main: [[0.001, "item_id"]] }` hängt Zeilen an die Tabelle an. +- `gold`: überschreibt `drops.gold`, z. B. `{ base: 0.8 }`. +- `phrases`: überschreibt Anzeigetexte. `"*"` gilt für alle Sprachen, ein + Sprachcode (`de`, `en`, ...) hat Vorrang. Nötig, wenn sich Zahlen in einem + Tooltip ändern. +- `apply(game)`: Funktion für Sonderfälle, läuft nach dem Zusammenführen. + +Beim Start loggen beide Container `custom-game: loaded ...`; neue IDs stehen +als `(new: ...)` dabei. Unbekannte Top-Level-Schlüssel, unbekannte Drop-Tabellen, +fehlende Items und fehlende Grafiken brechen den Start mit einer klaren Meldung +ab, statt das Spiel halb gepatcht zu starten. + +Enthaltenes Beispiel: Der Buff „Lone Wolf“ gibt 10× XP statt 3×. Die +Multiplikatoren liest der Game-Server aus der Condition-Definition (Patch in +`docker/patch-custom-game.js`), der Tooltip-Text kommt aus `phrases`. Die Datei +ist versioniert (`files/custom/`), `files/db` bleibt von Git ausgenommen. + +Grenzen: Alles, was in `design/*.js` als Datum steht, ist überschreibbar. +Konstanten, die im Code stehen (z. B. andere Encouragement-Raten), brauchen +weiterhin einen Patch wie den für Lone Wolf. Endet ein Season-Event zur +Laufzeit, können eventmodifizierte Monsterfelder auf den Stand vor der +Custom-Datei zurückgesetzt werden; dann den Game-Server neu starten. + ## Gut zu wissen - **E-Mail-Verifizierung:** AWS SES ist nicht konfiguriert. Beim Registrieren diff --git a/docker-compose.yml b/docker-compose.yml index f0973f6..017ac3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ x-app: &app volumes: # Editable keys/options from the host (replaces the cloned template). - ./files/config:/app/secretsandconfig:ro + # Editable game values and new elements; see files/custom/game.js. + - ./files/custom:/app/custom:ro services: diff --git a/docker/custom_game.js b/docker/custom_game.js new file mode 100644 index 0000000..a04b3d0 --- /dev/null +++ b/docker/custom_game.js @@ -0,0 +1,185 @@ +"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 }; diff --git a/docker/patch-custom-game.js b/docker/patch-custom-game.js new file mode 100644 index 0000000..7411f58 --- /dev/null +++ b/docker/patch-custom-game.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Local patch: custom game values and elements from custom/game.js. +// +// The loader (docker/custom_game.js) itself needs no source changes, but the +// game data is built inside eval() scope, so three small hooks are inserted at +// image build time: +// +// 1. main.js and node/server.js call the loader after the seasonal flags are +// applied, so custom values win over the shipped design and over events. +// 2. node/logic/encouragement.js reads the Lone Wolf rates from the condition +// definition instead of hardcoded 3s, so custom/game.js can tune them. +// 3. languages/index.js merges custom phrase overrides into the catalogs, so +// tooltips such as the Lone Wolf explanation match tuned numbers. +// +// Every edit is idempotent and fails loudly if the upstream anchor is gone, so +// an upstream update cannot silently drop the hooks. + +const fs = require("node:fs"); +const path = require("node:path"); + +const app = path.resolve(process.argv[2] || "/app"); + +function fail(message) { + console.error("patch-custom-game: " + message); + process.exit(1); +} + +function edit(file, marker, from, to, count) { + const full = path.join(app, file); + const source = fs.readFileSync(full, "utf8"); + if (source.includes(marker)) { + console.log("patch-custom-game: " + file + " already patched"); + return; + } + const occurrences = source.split(from).length - 1; + if (occurrences !== count) { + fail(file + ": expected " + count + " occurrence(s) of the anchor, found " + occurrences); + } + fs.writeFileSync(full, source.split(from).join(to)); + console.log("patch-custom-game: patched " + file); +} + +// 1a. Backend: design globals are module-scoped in main.js. +edit( + "main.js", + 'require("./docker/custom_game.js").apply', + `apply_event_flags({ maps: maps, items: items, monsters: monsters, drops: drops }, active_event_flags); + +// docs`, + `apply_event_flags({ maps: maps, items: items, monsters: monsters, drops: drops }, active_event_flags); + +// Local: values and new elements from the mounted custom/game.js. +require("./docker/custom_game.js").apply({ + items: items, monsters: monsters, npcs: npcs, maps: maps, sets: sets, craft: craft, + skills: skills, conditions: conditions, classes: classes, levels: levels, upgrades: upgrades, + compounds: compounds, multipliers: multipliers, titles: titles, tokens: tokens, dismantle: dismantle, + drops: drops, dimensions: dimensions, positions: positions, +}); + +// docs`, + 1, +); + +// 1b. Game server: the same globals are local to init_game() and reload_server(). +edit( + "node/server.js", + 'require("../docker/custom_game.js").apply', + `\t\tvar season_data = { maps: maps, items: items, monsters: monsters, drops: drops }; +\t\tapply_event_flags(season_data, active_event_flags); +\t\tseason_controls = { restore: season_data.event_restore || {}, end: end_event_flag };`, + `\t\tvar season_data = { maps: maps, items: items, monsters: monsters, drops: drops }; +\t\tapply_event_flags(season_data, active_event_flags); +\t\t// Local: values and new elements from the mounted custom/game.js. +\t\trequire("../docker/custom_game.js").apply({ +\t\t\titems: items, monsters: monsters, npcs: npcs, maps: maps, sets: sets, craft: craft, +\t\t\tskills: skills, conditions: conditions, classes: classes, levels: levels, upgrades: upgrades, +\t\t\tcompounds: compounds, multipliers: multipliers, titles: titles, tokens: tokens, dismantle: dismantle, +\t\t\tdrops: drops, dimensions: dimensions, positions: positions, +\t\t}); +\t\tseason_controls = { restore: season_data.event_restore || {}, end: end_event_flag };`, + 2, +); + +// 2. Lone Wolf rates become data-driven; the defaults keep upstream behaviour. +edit( + "node/logic/encouragement.js", + "lonewolf_definition", + `\t\t} else if (!blocked && name === "encouragement_lonewolf" && lonewolf) { +\t\t\tcondition = { gold_multiplier: 3, xp_multiplier: 3, luck_multiplier: 3 };`, + `\t\t} else if (!blocked && name === "encouragement_lonewolf" && lonewolf) { +\t\t\t// Local: read the rates from design/conditions.js so custom/game.js can tune them. +\t\t\tvar lonewolf_definition = (G.conditions && G.conditions[name]) || {}; +\t\t\tcondition = { +\t\t\t\tgold_multiplier: lonewolf_definition.gold_multiplier === undefined ? 3 : lonewolf_definition.gold_multiplier, +\t\t\t\txp_multiplier: lonewolf_definition.xp_multiplier === undefined ? 3 : lonewolf_definition.xp_multiplier, +\t\t\t\tluck_multiplier: lonewolf_definition.luck_multiplier === undefined ? 3 : lonewolf_definition.luck_multiplier, +\t\t\t};`, + 1, +); + +// 3a. Phrase lookup helper, next to the catalog loader. +edit( + "languages/index.js", + "function custom_phrases(language)", + `function create_catalog_loader(directory, domains) {`, + `// Local: phrase overrides from the mounted custom/game.js. +function custom_phrases(language) { + try { + return require("../docker/custom_game.js").phrases(language); + } catch (e) { + if (e && e.code === "MODULE_NOT_FOUND") return {}; + throw e; + } +} + +function create_catalog_loader(directory, domains) {`, + 1, +); + +// 3b. Apply the overrides after each language's files have been read. +edit( + "languages/index.js", + "var custom = custom_phrases(language);", + `\t\t} +\t\treturn result; +\t} +\treturn function catalog(language) {`, + `\t\t} +\t\t// Local: custom names and explanations win over the shipped text. +\t\tvar custom = custom_phrases(language); +\t\tfor (var custom_id of Object.keys(custom)) result[custom_id] = custom[custom_id]; +\t\treturn result; +\t} +\treturn function catalog(language) {`, + 1, +); diff --git a/files/custom/game.js b/files/custom/game.js new file mode 100644 index 0000000..3199877 --- /dev/null +++ b/files/custom/game.js @@ -0,0 +1,77 @@ +// Eigene Spieldaten für diese Instanz. +// +// Die Datei liegt auf dem Host unter files/custom/game.js und wird in die +// Container als /app/custom/game.js gemountet. Sie ist kein Teil des Images: +// Änderungen brauchen nur einen Neustart, keinen Rebuild. +// +// docker compose restart al-backend al-game +// +// Regeln: +// - Objekte werden tief zusammengeführt; Arrays ersetzen den bisherigen Wert. +// - Neue IDs sind erlaubt (Items, Monster, NPCs, Conditions, ...), solange +// sie eine vorhandene Grafik aus design/dimensions.js verwenden. +// - Neue Karten sind nicht möglich: ihre Geometrie liegt in MongoDB. +// - `drops` wird an die vorhandene Tabelle angehängt. +// - `phrases` überschreibt Anzeigetexte. "*" gilt für alle Sprachen, +// ein Sprachcode (de, en, ...) hat Vorrang. Das ist nötig, wenn sich +// Zahlen in einem Tooltip ändern. +// - `apply(game)` ist ein Notausgang für alles, was sich nicht als Daten +// ausdrücken lässt; `game` enthält die Design-Objekte und `drops`. +// +// Aktuelles Beispiel: "Lone Wolf" gibt 10× XP statt 3×. +// Der Game-Server liest die Multiplikatoren aus der Condition-Definition +// (siehe docker/patch-custom-game.js); der Tooltip-Text kommt aus `phrases`. +module.exports = { + conditions: { + encouragement_lonewolf: { + gold_multiplier: 3, + xp_multiplier: 10, + luck_multiplier: 3, + // Fallback für Stellen, die die Rohdaten ohne Phrase anzeigen. + explanation: + "3× Gold, 10× XP and 3× Luck while you run one non-merchant character. Merchants may stay online. Extra rewards follow your contribution.", + }, + }, + phrases: { + "*": { + "condition.encouragement_lonewolf.explanation": + "3× Gold, 10× XP and 3× Luck while you run one non-merchant character. Merchants may stay online. Extra rewards follow your contribution.", + }, + de: { + "condition.encouragement_lonewolf.explanation": + "3× Gold, 10× XP und 3× Glück, solange du nur einen Nicht-Händler-Charakter spielst. Händler dürfen online bleiben. Zusätzliche Belohnungen richten sich nach deinem Beitrag.", + }, + }, + + // Weitere Beispiele – einkommentieren und anpassen: + // + // items: { + // "training_sword": { + // type: "weapon", + // wtype: "short_sword", + // skin: "sword", // muss in design/dimensions.js existieren + // name: "Training Sword", + // g: 10, + // }, + // }, + // + // monsters: { + // "training_goo": { + // skin: "goo", + // name: "Training Goo", + // speed: 6, charge: 12, hp: 100, xp: 100, attack: 5, + // damage_type: "physical", respawn: 1, gold: 20, range: 15, + // frequency: 0.4, aggro: 0, aa: 1, + // // Zusätzlich muss das Monster in einer Karte auftauchen, z. B.: + // // maps: { main: { monsters: [{ type: "training_goo", position: [0, 0], radius: 100, count: 1 }] } }, + // }, + // }, + // + // drops: { + // main: [[0.001, "training_sword"]], // [Chance, Item-ID], wird angehängt + // }, + // + // gold: { + // base: 0.8, // Standard 0.64 + // }, +};