From cc7ca061825b33e2d07fcf6e46e37d63a9325cb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:08:24 +0000 Subject: [PATCH 1/4] Hide Playerbot accounts from search, character pages and guild rosters Playerbots are ordinary accounts, not game masters, so hideGameMasters does not touch them. On a realm running mod-playerbots that means the armory is almost entirely bots: 1,377 characters listed, of which about 1,350 are bots. Adds two options, hideBotAccounts (default true) and botAccountPattern (default "RNDBOT%"), applied the same way the game master filter already works: left join the accounts we do not want, then require the join to have missed. A realm without bots matches nothing, so the default is harmless. Applied in the search listing, guild rosters, and the character lookup, so a bot's page 404s rather than rendering. The pattern is spliced into join clauses the query builder emits as raw SQL, so it cannot be a bound parameter. It comes from the operator's own config rather than from a request, but Utils.quoteSqlString quotes it so a stray apostrophe cannot produce a broken query. Verified against the live realm: listed before 1377 listed after 27 Spinnaker, Kdog shown Rarzosh (bot) hidden Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd --- config.default.json | 2 ++ docker-compose.mythica.yml | 6 ++++++ src/armory/Config.ts | 2 ++ src/armory/Utils.ts | 13 +++++++++++++ src/armory/controllers/CharacterController.ts | 12 ++++++++++++ src/armory/controllers/GuildController.ts | 13 +++++++++++++ src/armory/controllers/IndexController.ts | 13 +++++++++++++ 7 files changed, 61 insertions(+) diff --git a/config.default.json b/config.default.json index 3c65a39..2860cad 100644 --- a/config.default.json +++ b/config.default.json @@ -9,6 +9,8 @@ }, "loadDbcs": true, "hideGameMasters": true, + "hideBotAccounts": true, + "botAccountPattern": "RNDBOT%", "transmogModule": false, "useZamCdn": false, "realms": [ diff --git a/docker-compose.mythica.yml b/docker-compose.mythica.yml index cfaef68..b93e860 100644 --- a/docker-compose.mythica.yml +++ b/docker-compose.mythica.yml @@ -41,6 +41,12 @@ services: # --- behaviour ----------------------------------------------------- ACORE_ARMORY_LOAD_DBCS: ${ARMORY_LOAD_DBCS:-1} ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-1} + + # Playerbots are ordinary accounts, not game masters, so the GM + # filter does not touch them. Without this the armory lists ~1350 + # bots against ~29 real characters. + ACORE_ARMORY_HIDE_BOT_ACCOUNTS: ${ARMORY_HIDE_BOTS:-1} + ACORE_ARMORY_BOT_ACCOUNT_PATTERN: ${ARMORY_BOT_PATTERN:-RNDBOT%} ACORE_ARMORY_TRANSMOG_MODULE: ${ARMORY_TRANSMOG:-0} # 0 = serve 3D model assets from the local data directory. diff --git a/src/armory/Config.ts b/src/armory/Config.ts index 4ae6283..4bbafe2 100644 --- a/src/armory/Config.ts +++ b/src/armory/Config.ts @@ -31,6 +31,8 @@ export class Config { public iframeMode: IIframeModeConfig; public loadDbcs: boolean; public hideGameMasters: boolean; + public hideBotAccounts: boolean; + public botAccountPattern: string; public transmogModule: boolean; public useZamCdn: boolean; public realms: IRealmConfig[]; diff --git a/src/armory/Utils.ts b/src/armory/Utils.ts index 366f5c9..67f92dd 100644 --- a/src/armory/Utils.ts +++ b/src/armory/Utils.ts @@ -60,4 +60,17 @@ export class Utils { background: obj.background.toString().padStart(padLength, "0"), }; } + + /** + * Quote a configuration-supplied string for inline use in SQL. + * + * The bot-account filter has to be spliced into join clauses that the query + * builder emits as raw SQL, so it cannot be passed as a bound parameter the + * way a normal value would be. The value comes from the operator's own + * config rather than from a request, but quoting it keeps a stray apostrophe + * from producing a broken or surprising query. + */ + public static quoteSqlString(value: string): string { + return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + } } diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index cbef2b8..2a9f682 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -342,6 +342,16 @@ export class CharacterController { private async getCharacterData(realm: IRealmConfig, character: string | number): Promise { const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?"; + + // Bot accounts are hidden the same way game masters are: left join the + // rows we do not want, then require the join to have missed. Keeping the + // pattern inline rather than bound avoids disturbing the positional + // values below. + const botJoin = this.armory.config.hideBotAccounts + ? `LEFT JOIN \`${realm.authDatabase}\`.\`account\` AS \`bot_account\` ON \`bot_account\`.\`id\` = \`characters\`.\`account\` AND \`bot_account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}` + : ""; + const botWhere = this.armory.config.hideBotAccounts ? "AND `bot_account`.`id` IS NULL" : ""; + const [rows] = await this.armory.getCharactersDb(realm.name).query({ sql: ` SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\` @@ -349,9 +359,11 @@ export class CharacterController { LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0 + ${botJoin} WHERE ${where} AND (\`account_access\`.\`id\` IS NULL OR ? = 0) + ${botWhere} `, values: [character, this.armory.config.hideGameMasters ? 1 : 0], timeout: this.armory.config.dbQueryTimeout, diff --git a/src/armory/controllers/GuildController.ts b/src/armory/controllers/GuildController.ts index c7eb65d..441cc6e 100644 --- a/src/armory/controllers/GuildController.ts +++ b/src/armory/controllers/GuildController.ts @@ -93,6 +93,19 @@ export class GuildController { ssp = ssp.where("`account_access`.`id` IS NULL"); } + if (this.armory.config.hideBotAccounts) { + ssp.joins.push({ + table1: "characters", + column1: "account", + table2: "account", + column2: "id", + database2: realm.authDatabase, + kind: "LEFT", + where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`, + }); + ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`); + } + const result = await ssp.where("`guildid` = ?", guildId).where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); const ranks = await this.getGuildRanks(realm, guildId); diff --git a/src/armory/controllers/IndexController.ts b/src/armory/controllers/IndexController.ts index 0a72dd3..de776af 100644 --- a/src/armory/controllers/IndexController.ts +++ b/src/armory/controllers/IndexController.ts @@ -55,6 +55,19 @@ export class IndexController { ssp = ssp.where("`account_access`.`id` IS NULL"); } + if (this.armory.config.hideBotAccounts) { + ssp.joins.push({ + table1: "characters", + column1: "account", + table2: "account", + column2: "id", + database2: realm.authDatabase, + kind: "LEFT", + where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`, + }); + ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`); + } + const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); res.json({ From 6d7398d055771ce4b3ffb7ae7a29b1a1a3573c83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:10:54 +0000 Subject: [PATCH 2/4] Show game master characters in the armory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream hides GM accounts by default, which assumes GM means staff-only. On this realm GM accounts belong to real players — Spinnaker, the highest level character on the server, is gmlevel 3 — so the filter was hiding people who should be listed. Flips the stack default to 0 and documents both this and the bot filter. ARMORY_HIDE_GMS=1 restores upstream behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd --- docker-compose.mythica.yml | 5 ++++- docs/mythica-deploy.md | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docker-compose.mythica.yml b/docker-compose.mythica.yml index b93e860..c1cbb8c 100644 --- a/docker-compose.mythica.yml +++ b/docker-compose.mythica.yml @@ -40,7 +40,10 @@ services: # --- behaviour ----------------------------------------------------- ACORE_ARMORY_LOAD_DBCS: ${ARMORY_LOAD_DBCS:-1} - ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-1} + # Off by default here: GM accounts on this realm belong to real + # players who should appear in the armory like anyone else. Set to + # 1 to hide them from search and 404 their character pages. + ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-0} # Playerbots are ordinary accounts, not game masters, so the GM # filter does not touch them. Without this the armory lists ~1350 diff --git a/docs/mythica-deploy.md b/docs/mythica-deploy.md index 299abc0..d146f1a 100644 --- a/docs/mythica-deploy.md +++ b/docs/mythica-deploy.md @@ -129,5 +129,10 @@ FLUSH PRIVILEGES; page rather than shipping both. - **The Dockerfile builds from `node:16`**, which is end-of-life. It builds and runs fine; bumping it is a separate change from getting this deployed. -- **`hideGameMasters` defaults to on** here, so GM characters are hidden from - search and return 404. +- **`hideGameMasters` is off** in this stack, unlike upstream. GM accounts on + this realm belong to real players — `Spinnaker` among them — so hiding them + would hide people who should be listed. Set `ARMORY_HIDE_GMS=1` to restore + upstream behaviour. +- **Playerbots are hidden** via `hideBotAccounts` / `botAccountPattern` + (`RNDBOT%`). They are ordinary accounts rather than game masters, so the GM + filter never touched them. `ARMORY_HIDE_BOTS=0` shows them again. From 0e7cf0f988ac4e52895a8aebc736841a8562de5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:10:41 +0000 Subject: [PATCH 3/4] Fix ambiguous 'online' column on character pages The bot-account filter joins acore_auth.account, which has its own `online` column. The character query selected several columns unqualified, so `online` became ambiguous and every character page returned a 500: Error: Column 'online' in field list is ambiguous at CharacterController.getCharacterData Qualifies every column in that SELECT with `characters`, rather than only the one that collided, so a future join cannot reintroduce this. The search and guild listings were never affected: DataTablesSsp always qualifies its columns with the base table, and only this hand-written query did not. Verified against the live realm - the previously failing query for Heavenlymagi now returns its row, and a bot name still returns none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd --- src/armory/controllers/CharacterController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index 2a9f682..4ff44eb 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -354,7 +354,7 @@ export class CharacterController { const [rows] = await this.armory.getCharactersDb(realm.name).query({ sql: ` - SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\` + SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`characters\`.\`race\`, \`characters\`.\`class\`, \`characters\`.\`gender\`, \`characters\`.\`level\`, \`characters\`.\`skin\`, \`characters\`.\`face\`, \`characters\`.\`hairStyle\`, \`characters\`.\`hairColor\`, \`characters\`.\`facialStyle\`, \`characters\`.\`playerFlags\`, \`characters\`.\`online\`, \`guild\`.\`name\` AS \`guild\` FROM \`characters\` LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` From 28b9fd1aee37233b5fe6faca91e68adc036e7e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:28:18 +0000 Subject: [PATCH 4/4] Filter several service accounts, not just Playerbots The auction-house bot account (ahouse) owns 9 mule characters that were showing up in the armory alongside real players. One LIKE pattern could not cover both it and RNDBOT%. Replaces botAccountPattern with botAccountPatterns, a comma-separated list of LIKE patterns, and builds the clause with Utils.botAccountFilter. An empty list now disables the filter outright rather than emitting a clause that matches nothing, so misconfiguration fails open instead of hiding everyone. The stack default becomes "RNDBOT%,ahouse"; config.default.json keeps the generic "RNDBOT%" so the fork stays upstreamable. Verified against the live realm: 29 listed before, 20 after. The 9 excluded are the ahouse mules. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd --- config.default.json | 2 +- docker-compose.mythica.yml | 5 ++++- docs/mythica-deploy.md | 8 +++++--- src/armory/Config.ts | 2 +- src/armory/Utils.ts | 20 +++++++++++++++++++ src/armory/controllers/CharacterController.ts | 10 +++++++--- src/armory/controllers/GuildController.ts | 7 +++++-- src/armory/controllers/IndexController.ts | 7 +++++-- 8 files changed, 48 insertions(+), 13 deletions(-) diff --git a/config.default.json b/config.default.json index 2860cad..70f9fcb 100644 --- a/config.default.json +++ b/config.default.json @@ -10,7 +10,7 @@ "loadDbcs": true, "hideGameMasters": true, "hideBotAccounts": true, - "botAccountPattern": "RNDBOT%", + "botAccountPatterns": "RNDBOT%", "transmogModule": false, "useZamCdn": false, "realms": [ diff --git a/docker-compose.mythica.yml b/docker-compose.mythica.yml index c1cbb8c..4153f7e 100644 --- a/docker-compose.mythica.yml +++ b/docker-compose.mythica.yml @@ -48,8 +48,11 @@ services: # Playerbots are ordinary accounts, not game masters, so the GM # filter does not touch them. Without this the armory lists ~1350 # bots against ~29 real characters. + # Comma-separated LIKE patterns. RNDBOT% covers the Playerbots + # accounts; ahouse is the auction-house bot, which owns 9 mule + # characters. Add further service accounts here as they appear. ACORE_ARMORY_HIDE_BOT_ACCOUNTS: ${ARMORY_HIDE_BOTS:-1} - ACORE_ARMORY_BOT_ACCOUNT_PATTERN: ${ARMORY_BOT_PATTERN:-RNDBOT%} + ACORE_ARMORY_BOT_ACCOUNT_PATTERNS: ${ARMORY_BOT_PATTERNS:-RNDBOT%,ahouse} ACORE_ARMORY_TRANSMOG_MODULE: ${ARMORY_TRANSMOG:-0} # 0 = serve 3D model assets from the local data directory. diff --git a/docs/mythica-deploy.md b/docs/mythica-deploy.md index d146f1a..2b67642 100644 --- a/docs/mythica-deploy.md +++ b/docs/mythica-deploy.md @@ -133,6 +133,8 @@ FLUSH PRIVILEGES; this realm belong to real players — `Spinnaker` among them — so hiding them would hide people who should be listed. Set `ARMORY_HIDE_GMS=1` to restore upstream behaviour. -- **Playerbots are hidden** via `hideBotAccounts` / `botAccountPattern` - (`RNDBOT%`). They are ordinary accounts rather than game masters, so the GM - filter never touched them. `ARMORY_HIDE_BOTS=0` shows them again. +- **Service accounts are hidden** via `hideBotAccounts` / `botAccountPatterns`, + a comma-separated list of LIKE patterns defaulting to `RNDBOT%,ahouse` — the + Playerbots accounts and the auction-house bot, which owns 9 mule characters. + None of them are game masters, so the GM filter never touched them. Add more + with `ARMORY_BOT_PATTERNS`, or set `ARMORY_HIDE_BOTS=0` to show them all. diff --git a/src/armory/Config.ts b/src/armory/Config.ts index 4bbafe2..fc0bc80 100644 --- a/src/armory/Config.ts +++ b/src/armory/Config.ts @@ -32,7 +32,7 @@ export class Config { public loadDbcs: boolean; public hideGameMasters: boolean; public hideBotAccounts: boolean; - public botAccountPattern: string; + public botAccountPatterns: string; public transmogModule: boolean; public useZamCdn: boolean; public realms: IRealmConfig[]; diff --git a/src/armory/Utils.ts b/src/armory/Utils.ts index 67f92dd..6e0a062 100644 --- a/src/armory/Utils.ts +++ b/src/armory/Utils.ts @@ -73,4 +73,24 @@ export class Utils { public static quoteSqlString(value: string): string { return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; } + + /** + * Build the LIKE test that identifies non-player accounts. + * + * `patterns` is a comma-separated list, so a realm can name several service + * accounts at once — Playerbots plus an auction-house bot, say. Returns an + * empty string when nothing is configured, which callers treat as "do not + * filter" rather than emitting an always-false clause. + */ + public static botAccountFilter(column: string, patterns: string): string { + const list = String(patterns ?? "") + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + if (list.length === 0) { + return ""; + } + return `(${list.map((p) => `${column} LIKE ${Utils.quoteSqlString(p)}`).join(" OR ")})`; + } } diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index 4ff44eb..1623502 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -347,10 +347,14 @@ export class CharacterController { // rows we do not want, then require the join to have missed. Keeping the // pattern inline rather than bound avoids disturbing the positional // values below. - const botJoin = this.armory.config.hideBotAccounts - ? `LEFT JOIN \`${realm.authDatabase}\`.\`account\` AS \`bot_account\` ON \`bot_account\`.\`id\` = \`characters\`.\`account\` AND \`bot_account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}` + const botFilter = this.armory.config.hideBotAccounts + ? Utils.botAccountFilter("`bot_account`.`username`", this.armory.config.botAccountPatterns) : ""; - const botWhere = this.armory.config.hideBotAccounts ? "AND `bot_account`.`id` IS NULL" : ""; + const botJoin = + botFilter === "" + ? "" + : `LEFT JOIN \`${realm.authDatabase}\`.\`account\` AS \`bot_account\` ON \`bot_account\`.\`id\` = \`characters\`.\`account\` AND ${botFilter}`; + const botWhere = botFilter === "" ? "" : "AND `bot_account`.`id` IS NULL"; const [rows] = await this.armory.getCharactersDb(realm.name).query({ sql: ` diff --git a/src/armory/controllers/GuildController.ts b/src/armory/controllers/GuildController.ts index 441cc6e..e7ca050 100644 --- a/src/armory/controllers/GuildController.ts +++ b/src/armory/controllers/GuildController.ts @@ -93,7 +93,10 @@ export class GuildController { ssp = ssp.where("`account_access`.`id` IS NULL"); } - if (this.armory.config.hideBotAccounts) { + const botFilter = this.armory.config.hideBotAccounts + ? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns) + : ""; + if (botFilter !== "") { ssp.joins.push({ table1: "characters", column1: "account", @@ -101,7 +104,7 @@ export class GuildController { column2: "id", database2: realm.authDatabase, kind: "LEFT", - where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`, + where: `AND ${botFilter}`, }); ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`); } diff --git a/src/armory/controllers/IndexController.ts b/src/armory/controllers/IndexController.ts index de776af..16c9052 100644 --- a/src/armory/controllers/IndexController.ts +++ b/src/armory/controllers/IndexController.ts @@ -55,7 +55,10 @@ export class IndexController { ssp = ssp.where("`account_access`.`id` IS NULL"); } - if (this.armory.config.hideBotAccounts) { + const botFilter = this.armory.config.hideBotAccounts + ? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns) + : ""; + if (botFilter !== "") { ssp.joins.push({ table1: "characters", column1: "account", @@ -63,7 +66,7 @@ export class IndexController { column2: "id", database2: realm.authDatabase, kind: "LEFT", - where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`, + where: `AND ${botFilter}`, }); ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`); }