From d589dd4d646c7804b5ecb27f45108759806cafe4 Mon Sep 17 00:00:00 2001
From: anima
Date: Mon, 2 Nov 2026 02:49:23 +0100
Subject: [PATCH] fix rewards and bypass ip limits
---
Dockerfile | 15 +++
README.md | 104 +++++++++++++++--
deploy/Caddyfile.example | 9 +-
docker/patch-multi-account-limits.js | 165 +++++++++++++++++++++++++++
docker/patch-tutorial-reward-auth.js | 47 ++++++++
files/config/options.js | 14 ++-
6 files changed, 344 insertions(+), 10 deletions(-)
create mode 100644 docker/patch-multi-account-limits.js
create mode 100644 docker/patch-tutorial-reward-auth.js
diff --git a/Dockerfile b/Dockerfile
index dc5756e..1034ba6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -74,6 +74,21 @@ RUN node /tmp/patch-custom-game.js /app
COPY docker/patch-auto-verify-email.js /tmp/patch-auto-verify-email.js
RUN node /tmp/patch-auto-verify-email.js /app
+# Local patch: make the Steam/MAS auth-id requirement for tutorial and class
+# rewards optional (docker/patch-tutorial-reward-auth.js). Controlled at runtime
+# by options.tutorial_reward_requires_auth_id.
+COPY docker/patch-tutorial-reward-auth.js /tmp/patch-tutorial-reward-auth.js
+RUN node /tmp/patch-tutorial-reward-auth.js /app
+
+# Local patch: make the merchant and non-merchant multi-account limits
+# configurable (docker/patch-multi-account-limits.js). Upstream hard-codes one
+# merchant per IP/owner and three non-merchant characters per IP/owner in the
+# backend enforcement cron, while login uses options.ip_limit/character_limit.
+# The patch makes both places use options.merchant_ip_limit,
+# options.merchant_owner_limit, options.ip_limit and options.character_limit.
+COPY docker/patch-multi-account-limits.js /tmp/patch-multi-account-limits.js
+RUN node /tmp/patch-multi-account-limits.js /app
+
FROM ${NODE_IMAGE} AS runtime
diff --git a/README.md b/README.md
index fd9d954..a4215ad 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,12 @@ und Game-Server. Bei späteren Starts überspringt sich der Seed selbst.
[Zum Entwickeln]“** klicken, E-Mail + Passwort eingeben. Danach Charakter
erstellen und auf den Charakter klicken – man landet direkt im Spiel.
+**Charakter Limits anheben** Folgendes Kommando setzt für alle existierenden Spieler das Charakter Limit auf 18.
+```sh
+docker compose exec al-mongo mongosh adventureland --eval \
+ 'db.user.updateMany({}, { $set: { "info.slots": 18 } })'
+```
+
**Admin werden:** Die Admin-Seiten (`/admin/executor`, `/admin/renderer`) sind
standardmäßig gesperrt. `is_admin` in `common/admin.js` prüft das Feld
`user.admin`; setzen lässt es sich direkt in MongoDB:
@@ -40,6 +46,37 @@ Alternativ in `config/options.js` `unsecure_admin: true` setzen – dann gilt
**jeder** Besucher als Admin (`/admin/executor` erlaubt Code-Ausführung), daher
nur bei rein lokalem Zugriff sinnvoll.
+**GM werden (Game):** Der Web-Admin ist **kein** Game-GM. Die GM-Rolle hängt am
+Charakter, nicht am Benutzerkonto: Der Game-Server prüft `player.role` bzw.
+`player.p.role` auf `"gm"`. Charakter vorher ausloggen, dann in MongoDB setzen:
+
+```sh
+docker compose exec al-mongo mongosh adventureland --eval \
+ 'db.character.updateOne(
+ { name: "CharName" },
+ { $set: { role: "gm", "info.p.role": "gm" } }
+ )'
+```
+
+Danach den Charakter neu einloggen. `role: "gm"` gibt die In-Game-GM-Rechte und
+ist unabhängig von `user.admin` (Web-Admin). Wer **nur** von den Multi-Account-
+Limits ausgenommen werden will, aber keine GM-Rechte braucht, setzt stattdessen
+`info.p.free`:
+
+```sh
+docker compose exec al-mongo mongosh adventureland --eval \
+ 'db.character.updateOne(
+ { name: "CharName" },
+ { $set: { "info.p.free": true } }
+ )'
+```
+
+| Feld | Wirkung |
+| --- | --- |
+| `user.admin: true` | Web-Admin: `/admin/executor`, `/admin/renderer`; keine In-Game-GM-Rechte |
+| Charakter `role: "gm"` | In-Game-GM; wird außerdem von den Limits ausgenommen |
+| Charakter `info.p.free: true` | nur Limit-Ausnahme; keine GM-Rechte |
+
## Aufbau
| Service | Image | Aufgabe |
@@ -61,7 +98,8 @@ ersetzt das Template aus dem Upstream-Repo:
- `config/keys.js` – MongoDB-URI, Master-Keys, Stripe/Steam/SES (alles leer bzw.
feste Entwicklungs-Defaults). Die Werte sind absichtlich stabil, damit
Sitzungen und Serverdatensätze Neustarts überleben.
-- `config/options.js` – `base_url`, Ports, Serverdefinition `local`.
+- `config/options.js` – `base_url`, Ports, Serverdefinition `local`,
+ Multi-Account-Limits.
Beide Dateien sind normale Node-Module; nach Änderungen Container neu starten:
@@ -73,6 +111,39 @@ docker compose restart backend game
> `docker-compose.yml` (`ports`), `config/options.js` (`port`, `local_port`,
> `address`) und `base_url` (inkl. Port).
+### Multi-Account-Limits
+
+`config/options.js` steuert, wie viele Charaktere gleichzeitig online sein
+dürfen:
+
+| Option | Bedeutung | aktuell |
+| --- | --- | --- |
+| `ip_limit` | Nicht-Händler pro IP (mal `ipx` einer IP-Ausnahme) | `9` |
+| `character_limit` | Nicht-Händler pro Konto | `3` |
+| `merchant_ip_limit` | Händler pro IP | `3` |
+| `merchant_owner_limit` | Händler pro Konto | `1` |
+
+Die Werte gelten beim Login (`is_player_allowed`) und im Backend-Cron
+(`enforce_limitations`). Der Patch
+`docker/patch-multi-account-limits.js` ersetzt dabei die früher fest codierten
+Grenzen (3 Nicht-Händler, 1 Händler). Mit den aktuellen Werten können z. B. drei
+Konten von derselben IP jeweils drei Nicht-Händler-Charaktere online haben
+(9 insgesamt); ein zehntes Nicht-Händler-Charakter bzw. ein vierter Händler wird
+abgewiesen. `character_limit` ist die Zahl der **gleichzeitig online** erlaubten
+Charaktere, nicht die Zahl der Charakterplätze (`info.slots`); `ip_limit` ist
+ebenfalls keine Slotzahl. Eine `IP_`-Ausnahme mit
+`{ exception: true, info: { limit: N } }` multipliziert nur `ip_limit` für
+Nicht-Händler, nicht die Händler-Limits. Nicht-freie Händler zählen; `p.free`
+und GM-Charaktere fallen heraus. `merchant_owner_limit: 1` erlaubt einen Händler
+pro Konto; Admin- und User-Händler auf getrennten Konten sind damit kein
+Problem. Sollen beide auf **demselben** Konto laufen, `merchant_owner_limit` auf
+`2` setzen.
+
+> Hinter einem Reverse-Proxy (Caddy) muss die echte Client-IP als
+> `X-Forwarded-For` ankommen. Sonst sieht der Server nur die Proxy-/Docker-IP
+> und alle Spieler teilen sich dieselben Limits. Siehe
+> `deploy/Caddyfile.example`.
+
### MongoDB-Zugriff
Die Datenbank ist nur im Compose-Netz erreichbar. Für einen Blick hinein:
@@ -145,6 +216,21 @@ Login als verifiziert und überspringt die Verifikationsmail. Ohne den Schalter
bleibt das Upstream-Verhalten unverändert. Auch dieser Patch bricht den Build
ab, wenn ein Anker in `api.js` fehlt.
+`docker/patch-tutorial-reward-auth.js` macht die `auth_id`-Pflicht der
+Tutorial- und Klassenbelohnungen abschaltbar. Bei
+`options.tutorial_reward_requires_auth_id: false` können auch reine Web-Accounts
+`c0` und Klassenbelohnungen abholen; fehlt die Option oder steht sie auf `true`,
+gilt das Upstream-Verhalten. Fehlt einer der beiden Anker in `node/server.js`,
+bricht der Build ab.
+
+`docker/patch-multi-account-limits.js` macht die Händler- und Nicht-Händler-
+Limits konfigurierbar. Upstream prüft beim Login `options.ip_limit` und
+`options.character_limit`, würgt im Backend-Cron aber pauschal nach 3
+Nicht-Händlern bzw. 1 Händler ab. Der Patch liest beide Stellen aus
+`config/options.js` (`ip_limit`, `character_limit`, `merchant_ip_limit`,
+`merchant_owner_limit`). Fehlt ein Anker in `node/server_functions.js` oder
+`adventure_functions.js`, bricht der Build ab.
+
## Spieldaten anpassen (`files/custom/game.js`)
Werte bestehender Elemente und neue Elemente lassen sich ohne Quelltextänderung
@@ -207,15 +293,17 @@ Custom-Datei zurückgesetzt werden; dann den Game-Server neu starten.
auf `true` setzen (siehe Admin-Befehl oben). Die `/ev`-Route bleibt in beiden
Fällen funktionsfähig.
-- **Tutorial-Belohnung (`c0`):** Jedes Konto mit `auth_id` (Steam/MAS) kann nach
+- **Tutorial-Belohnung (`c0`):** Jedes verifizierte Konto kann nach
abgeschlossenem Tutorial einmal `c0` abholen. Die Belohnung ist ein zufälliger
kosmetischer Look aus dem `cosmo0`-Pool (über 100 Varianten). Der Game-Server
- prüft dafür `player.verified` **und** `player.auth_id`
- (`node/server.js:5272`). Bei reinen Web-Accounts ohne Steam/MAS bleibt
- `auth_id` leer, deshalb ist die Belohnung trotz Auto-Verifizierung weiterhin
- gesperrt. Die alten Klassen-Belohnungen (`creward`) sind in
- `design/classes.js` auskommentiert und werden vom Client nicht angefragt;
- Hardcore-Rewards gelten nur auf dem Hardcore-Server.
+ prüft `player.verified` und normalerweise `player.auth_id` (Steam/MAS,
+ `node/server.js`, `ureward`/`creward`). Bei reinen Web-Accounts ist `auth_id`
+ leer; deshalb steht `options.tutorial_reward_requires_auth_id` hier auf
+ `false`, damit auch sie die Belohnung erhalten. Auf `true` gestellt, gilt
+ wieder die Upstream-Sperre für Web-Accounts. Die alten Klassen-Belohnungen
+ (`creward`) nutzen denselben Schalter, sind aber in `design/classes.js`
+ auskommentiert und werden vom Client nicht angefragt; Hardcore-Rewards gelten
+ nur auf dem Hardcore-Server.
- **„Authorization Failure“ (Gold/Glück −85 %, XP −20 %):** Das ist die
DRM-Prüfung des Game-Servers. Sie verlangt für jedes Konto ab 2019 eine
diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example
index d3f7c0c..a91ea9d 100644
--- a/deploy/Caddyfile.example
+++ b/deploy/Caddyfile.example
@@ -20,7 +20,12 @@ al.example.com {
# /server.api/ backend -> game (server_eval, protected by ACCESS_MASTER)
@game path /socket.io/* /socket.io-msgpack/* /server.api/*
handle @game {
- reverse_proxy 10.10.16.28:7192
+ reverse_proxy 10.10.16.28:7192 {
+ # Overwrite, not append: Adventure Land reads the first
+ # X-Forwarded-For entry for its IP limits. Without this a client
+ # could send a fake header and bypass the limits.
+ header_up X-Forwarded-For {remote_host}
+ }
}
# --- Web backend (container port 8090) ------------------------------
@@ -30,6 +35,8 @@ al.example.com {
# Keep the public Host: the backend derives base_url and the
# auth cookie domain from it.
header_up Host {host}
+ # Overwrite the client IP; see the game handler above.
+ header_up X-Forwarded-For {remote_host}
}
}
diff --git a/docker/patch-multi-account-limits.js b/docker/patch-multi-account-limits.js
new file mode 100644
index 0000000..1b2003d
--- /dev/null
+++ b/docker/patch-multi-account-limits.js
@@ -0,0 +1,165 @@
+#!/usr/bin/env node
+// Local patch: make the multi-account limits configurable.
+//
+// Upstream hard-codes:
+// - one merchant per IP and per owner,
+// - three non-merchant characters per IP and per owner,
+// and only checks some of those values at login.
+//
+// This patch reads the limits from options.js in both places:
+// - is_player_allowed() (login, node/server_functions.js)
+// - enforce_limitations() (backend cron, adventure_functions.js)
+//
+// New options (defaults match upstream):
+// merchant_ip_limit: 1
+// merchant_owner_limit: 1
+// ip_limit: 3
+// character_limit: 3
+//
+// The patch is idempotent and fails loudly if an anchor is missing.
+
+const fs = require("node:fs");
+const path = require("node:path");
+
+const app = path.resolve(process.argv[2] || "/app");
+const serverFunctionsFile = path.join(app, "node", "server_functions.js");
+const adventureFunctionsFile = path.join(app, "adventure_functions.js");
+
+function fail(message) {
+ console.error("patch-multi-account-limits: " + message);
+ process.exit(1);
+}
+
+function replaceOnce(source, from, to, label) {
+ const count = source.split(from).length - 1;
+ if (count !== 1) {
+ fail(label + ": expected 1 anchor, found " + count);
+ }
+ return source.replace(from, to);
+}
+
+function patchServerFunctions(source) {
+ const marker = "function get_multi_account_identity(player) {";
+ const oldMerchant =
+ '\tif (player.type == "merchant") {\n' +
+ "\t\tfor (var id in players) {\n" +
+ "\t\t\tif ((players[id].stones && 0) || id == player.socket.id || is_free(player)) {\n" +
+ "\t\t\t\tcontinue;\n" +
+ "\t\t\t}\n" +
+ '\t\t\tif (players[id].owner == player.owner && players[id].type == "merchant") {\n' +
+ "\t\t\t\treturn false;\n" +
+ "\t\t\t}\n" +
+ "\t\t}\n" +
+ "\t\treturn true;\n" +
+ "\t}";
+
+ if (source.includes(marker)) {
+ if (source.includes(oldMerchant)) {
+ fail("server_functions.js has a partial merchant patch");
+ }
+ console.log("patch-multi-account-limits: server_functions.js already patched");
+ return source;
+ }
+
+ source = replaceOnce(
+ source,
+ 'function get_limit_identity(player) {\n\treturn player && player.mainframe === true ? "mainframe:" + player.owner : get_ip_server(player);\n}',
+ 'function get_limit_identity(player) {\n\treturn player && player.mainframe === true ? "mainframe:" + player.owner : get_ip_server(player);\n}\n\n' +
+ "// Multi-account identity; mirrors the auth/temp_auth handling in enforce_limitations().\n" +
+ "function get_multi_account_identity(player) {\n" +
+ '\tif (player.temp_auth) return "owner:" + player.owner;\n' +
+ '\tif (player.auth_id) return "auth:" + player.auth_id;\n' +
+ "\treturn get_limit_identity(player);\n" +
+ "}",
+ "get_limit_identity",
+ );
+
+ const newMerchant =
+ "\t// Multi-account limits: configured in options.js; the backend cron uses the same values.\n" +
+ '\tif (player.type == "merchant") {\n' +
+ "\t\tvar merchant_limit = options.merchant_owner_limit === undefined ? 1 : options.merchant_owner_limit;\n" +
+ "\t\tvar merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;\n" +
+ "\t\tvar merchants = 0;\n" +
+ "\t\tvar merchant_ips = 0;\n" +
+ "\t\tvar merchant_identity = get_multi_account_identity(player);\n" +
+ "\t\tfor (var id in players) {\n" +
+ '\t\t\tif (id == player.socket.id || is_free(players[id]) || players[id].type != "merchant") {\n' +
+ "\t\t\t\tcontinue;\n" +
+ "\t\t\t}\n" +
+ "\t\t\tif (players[id].owner == player.owner) {\n" +
+ "\t\t\t\tmerchants++;\n" +
+ "\t\t\t}\n" +
+ "\t\t\tif (get_multi_account_identity(players[id]) == merchant_identity) {\n" +
+ "\t\t\t\tmerchant_ips++;\n" +
+ "\t\t\t}\n" +
+ "\t\t}\n" +
+ "\t\tif (merchants >= merchant_limit || merchant_ips >= merchant_ip_limit) {\n" +
+ "\t\t\treturn false;\n" +
+ "\t\t}\n" +
+ "\t\treturn true;\n" +
+ "\t}";
+
+ source = replaceOnce(source, oldMerchant, newMerchant, "merchant branch");
+ return source;
+}
+
+function patchAdventureFunctions(source) {
+ const marker = "var merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;";
+ const oldMerchant =
+ '\t\t\t\tif (player.type === "merchant" && (mips[player.ip] > 1 || mowners[player.owner] > 1) && to_disconnect.indexOf(player.name) === -1) {';
+ const oldOther =
+ '\t\t\t\tif (player.type !== "merchant" && (ips[player.ip] > 3 * (ipx[player.ip] || 1) || owners[player.owner] > 3) && to_disconnect.indexOf(player.name) === -1) {';
+
+ if (source.includes(marker)) {
+ if (source.includes(oldMerchant) || source.includes(oldOther)) {
+ fail("adventure_functions.js has a partial limits patch");
+ }
+ console.log("patch-multi-account-limits: adventure_functions.js already patched");
+ return source;
+ }
+
+ source = replaceOnce(
+ source,
+ "\t\t\tvar to_disconnect = [];",
+ "\t\t\tvar to_disconnect = [];\n" +
+ "\t\t\tvar merchant_ip_limit = options.merchant_ip_limit === undefined ? 1 : options.merchant_ip_limit;\n" +
+ "\t\t\tvar merchant_owner_limit = options.merchant_owner_limit === undefined ? 1 : options.merchant_owner_limit;\n" +
+ "\t\t\tvar ip_limit = options.ip_limit === undefined ? 3 : options.ip_limit;\n" +
+ "\t\t\tvar character_limit = options.character_limit === undefined ? 3 : options.character_limit;",
+ "limit variables",
+ );
+
+ source = replaceOnce(
+ source,
+ oldMerchant,
+ "\t\t\t\tif (\n" +
+ '\t\t\t\t\tplayer.type === "merchant" &&\n' +
+ "\t\t\t\t\t(mips[player.ip] > merchant_ip_limit || mowners[player.owner] > merchant_owner_limit) &&\n" +
+ "\t\t\t\t\tto_disconnect.indexOf(player.name) === -1\n" +
+ "\t\t\t\t) {",
+ "merchant condition",
+ );
+
+ source = replaceOnce(
+ source,
+ oldOther,
+ "\t\t\t\tif (\n" +
+ '\t\t\t\t\tplayer.type !== "merchant" &&\n' +
+ "\t\t\t\t\t(ips[player.ip] > ip_limit * (ipx[player.ip] || 1) || owners[player.owner] > character_limit) &&\n" +
+ "\t\t\t\t\tto_disconnect.indexOf(player.name) === -1\n" +
+ "\t\t\t\t) {",
+ "non-merchant condition",
+ );
+
+ return source;
+}
+
+let serverFunctions = fs.readFileSync(serverFunctionsFile, "utf8");
+serverFunctions = patchServerFunctions(serverFunctions);
+fs.writeFileSync(serverFunctionsFile, serverFunctions);
+
+let adventureFunctions = fs.readFileSync(adventureFunctionsFile, "utf8");
+adventureFunctions = patchAdventureFunctions(adventureFunctions);
+fs.writeFileSync(adventureFunctionsFile, adventureFunctions);
+
+console.log("patch-multi-account-limits: wrote " + serverFunctionsFile + " and " + adventureFunctionsFile);
diff --git a/docker/patch-tutorial-reward-auth.js b/docker/patch-tutorial-reward-auth.js
new file mode 100644
index 0000000..1f0abb7
--- /dev/null
+++ b/docker/patch-tutorial-reward-auth.js
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+// Local patch: make the platform auth-id requirement for rewards optional.
+//
+// Adventure Land's tutorial (ureward) and class (creward) handlers require
+// player.verified and player.auth_id. auth_id is only populated for Steam/MAS
+// accounts; plain web accounts have an empty pid/auth_id and therefore can
+// never claim these rewards. On a self-hosted server that requirement is
+// usually unnecessary.
+//
+// options.tutorial_reward_requires_auth_id:
+// true/absent -> upstream behaviour (auth_id required)
+// false -> verified web accounts may claim the rewards too
+//
+// The patch is idempotent and fails loudly if the two anchors are gone.
+
+const fs = require("node:fs");
+const path = require("node:path");
+
+const app = path.resolve(process.argv[2] || "/app");
+const file = path.join(app, "node", "server.js");
+let source = fs.readFileSync(file, "utf8");
+
+const from = `\t\t\tif (!player.verified || !player.auth_id) {`;
+const to = `\t\t\tif (
+\t\t\t\t!player.verified ||
+\t\t\t\t((typeof options === "undefined" || options.tutorial_reward_requires_auth_id !== false) && !player.auth_id)
+\t\t\t) {`;
+
+function fail(message) {
+ console.error("patch-tutorial-reward-auth: " + message);
+ process.exit(1);
+}
+
+const unpatched = source.split(from).length - 1;
+const patched = source.split(to).length - 1;
+
+if (unpatched === 2) {
+ source = source.split(from).join(to);
+ console.log("patch-tutorial-reward-auth: patched ureward and creward gates");
+} else if (unpatched === 0 && patched === 2) {
+ console.log("patch-tutorial-reward-auth: already patched");
+} else {
+ fail("expected 2 reward gates, found " + unpatched + " unpatched and " + patched + " patched; refusing partial patch");
+}
+
+fs.writeFileSync(file, source);
+console.log("patch-tutorial-reward-auth: wrote " + file);
diff --git a/files/config/options.js b/files/config/options.js
index 5ed5620..611957a 100644
--- a/files/config/options.js
+++ b/files/config/options.js
@@ -49,12 +49,24 @@ module.exports = {
merchant_map: "main",
port: 8090,
close_timeout: 4000,
- ip_limit: 3,
+ // Max concurrent non-merchant characters per IP (multiplied by an IP
+ // exception's limit) and per account. Login and the backend cron both use
+ // these values; 9 here allows 3 accounts with 3 characters each from one IP.
+ ip_limit: 9,
character_limit: 3,
+ // Max concurrent merchant characters per IP and per account. Upstream is
+ // hard-coded to 1/1; this local build reads these options in login and in
+ // the backend cron. 3 here means three merchant accounts may share one IP.
+ merchant_ip_limit: 3,
+ merchant_owner_limit: 1,
// Without an email service, mark new accounts as verified at signup and
// keep existing accounts verified on login. Set to false to restore the
// upstream verification flow (requires AWS SES).
auto_verify_email: true,
+ // Tutorial/class rewards normally require a platform auth id (Steam/MAS)
+ // as an anti-alt measure. Web-only accounts have no auth id; set to false
+ // to let verified web accounts claim them too. Default is true.
+ tutorial_reward_requires_auth_id: false,
fast_sdk: 0,
machines: machines,
servers: servers,