From cceb7bd0fc4690a89a4d527120313f056c35ef55 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:00:51 +0100 Subject: [PATCH 001/144] Fix: can't boat into AFK ally from radial menu (#3165) ## Description: Fixes issue where you can't boat into an AFK/disconnected ally from the radial menu: https://www.youtube.com/clip/UgkxRXy2Y9BrmCiQRSFJnhVFanR5NRsG9pzu ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/client/graphics/layers/RadialMenuElements.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index aeafdf8a1..4ced271d0 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -618,6 +618,7 @@ export const rootMenuElement: MenuElement = { color: COLORS.info, subMenu: (params: MenuElementParams) => { const isAllied = params.selected?.isAlliedWith(params.myPlayer); + const isDisconnected = isDisconnectedTarget(params); const tileOwner = params.game.owner(params.tile); const isOwnTerritory = @@ -629,9 +630,9 @@ export const rootMenuElement: MenuElement = { ...(isOwnTerritory ? [deleteUnitElement, allyRequestElement, buildMenuElement] : [ - isAllied ? allyBreakElement : boatMenuElement, + isAllied && !isDisconnected ? allyBreakElement : boatMenuElement, allyRequestElement, - isFriendlyTarget(params) && !isDisconnectedTarget(params) + isFriendlyTarget(params) && !isDisconnected ? donateGoldRadialElement : attackMenuElement, ]), From 3cd4ffff0c2a5a505bcb01d0205b3c87b08b271e Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Tue, 10 Feb 2026 00:07:50 +0100 Subject: [PATCH 002/144] Fix railroads dead pixels (#3166) ## Description: Fix railroads coordinates to remove dead pixels image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- src/client/graphics/layers/RailroadSprites.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts index d7a1b1097..8572ae68f 100644 --- a/src/client/graphics/layers/RailroadSprites.ts +++ b/src/client/graphics/layers/RailroadSprites.ts @@ -40,9 +40,9 @@ function horizontalRailroadRects(): number[][] { function verticalRailroadRects(): number[][] { // x/y/w/h const rects = [ - [-1, -2, 1, 2], - [1, -2, 1, 2], - [0, -1, 1, 1], + [-1, -1, 1, 2], + [1, -1, 1, 2], + [0, 0, 1, 1], ]; return rects; } @@ -50,9 +50,9 @@ function verticalRailroadRects(): number[][] { function topRightRailroadCornerRects(): number[][] { // x/y/w/h const rects = [ - [-1, -2, 1, 2], + [-1, -1, 1, 1], [0, -1, 1, 2], - [1, -2, 1, 4], + [1, -1, 1, 3], ]; return rects; } @@ -60,9 +60,9 @@ function topRightRailroadCornerRects(): number[][] { function topLeftRailroadCornerRects(): number[][] { // x/y/w/h const rects = [ - [-1, -2, 1, 4], + [-1, -1, 1, 3], [0, -1, 1, 2], - [1, -2, 1, 2], + [1, -1, 1, 1], ]; return rects; } @@ -70,9 +70,9 @@ function topLeftRailroadCornerRects(): number[][] { function bottomRightRailroadCornerRects(): number[][] { // x/y/w/h const rects = [ - [-1, 1, 1, 2], + [-1, 1, 1, 1], [0, 0, 1, 2], - [1, -1, 1, 4], + [1, -1, 1, 3], ]; return rects; } @@ -80,9 +80,9 @@ function bottomRightRailroadCornerRects(): number[][] { function bottomLeftRailroadCornerRects(): number[][] { // x/y/w/h const rects = [ - [-1, -1, 1, 4], + [-1, -1, 1, 3], [0, 0, 1, 2], - [1, 1, 1, 2], + [1, 1, 1, 1], ]; return rects; } @@ -109,8 +109,8 @@ function horizontalBridge(): number[][] { function verticalBridge(): number[][] { // x/y/w/h return [ - [-2, -2, 1, 3], - [2, -2, 1, 3], + [-2, -1, 1, 3], + [2, -1, 1, 3], ]; } // ⌞ From c212735f09deb0c2cf988acb7abe8f77961bb4ab Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:23:20 +0100 Subject: [PATCH 003/144] =?UTF-8?q?Orange=20betrayal=20button=20for=20no-d?= =?UTF-8?q?ebuff-betrayals=20=F0=9F=96=8C=EF=B8=8F=20(#3161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #1276 ## Description: Orange betrayal button if the player is a traitor or disconnected. So people can easier tell that this is a betrayal without consequences. The color changes back to red without reopening the menu (live) when the traitor debuff ends or the player reconnects. image ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --------- Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/client/graphics/layers/RadialMenu.ts | 23 +++++++++------ .../graphics/layers/RadialMenuElements.ts | 8 ++++-- .../graphics/RadialMenuElements.test.ts | 4 +++ tests/radialMenuElements.test.ts | 28 +++++++++++++++++-- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index ab4d0198f..119c7e1a3 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -11,6 +11,16 @@ import { } from "./RadialMenuElements"; import backIcon from "/images/BackIconWhite.svg?url"; +function resolveColor( + item: MenuElement, + params: MenuElementParams | null, +): string | undefined { + if (typeof item.color === "function") { + return params ? item.color(params) : undefined; + } + return item.color; +} + export class CloseRadialMenuEvent implements GameEvent { constructor() {} } @@ -322,7 +332,7 @@ export class RadialMenu implements Layer { const disabled = this.params === null || d.data.disabled(this.params); const color = disabled ? this.config.disabledColor - : (d.data.color ?? "#333333"); + : (resolveColor(d.data, this.params) ?? "#333333"); const opacity = disabled ? 0.5 : 0.7; if (d.data.id === this.selectedItemId && this.currentLevel > level) { @@ -365,7 +375,7 @@ export class RadialMenu implements Layer { const color = this.params === null || d.data.disabled(this.params) ? this.config.disabledColor - : (d.data.color ?? "#333333"); + : (resolveColor(d.data, this.params) ?? "#333333"); path.attr("fill", color); } }); @@ -431,7 +441,7 @@ export class RadialMenu implements Layer { path.attr("stroke-width", "2"); const color = disabled ? this.config.disabledColor - : (d.data.color ?? "#333333"); + : (resolveColor(d.data, this.params) ?? "#333333"); const opacity = disabled ? 0.5 : 0.7; path.attr( "fill", @@ -848,10 +858,7 @@ export class RadialMenu implements Layer { public disableAllButtons() { this.updateCenterButtonState("default"); - - for (const item of this.currentMenuItems) { - item.color = this.config.disabledColor; - } + this.refresh(); } public updateCenterButtonState(state: CenterButtonState) { @@ -1043,7 +1050,7 @@ export class RadialMenu implements Layer { const disabled = this.isItemDisabled(item); const color = disabled ? this.config.disabledColor - : (item.color ?? "#333333"); + : (resolveColor(item, this.params) ?? "#333333"); const opacity = disabled ? 0.5 : 0.7; // Update path appearance diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 4ced271d0..67ef1d05a 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -46,7 +46,7 @@ export interface MenuElement { id: string; name: string; displayed?: boolean | ((params: MenuElementParams) => boolean); - color?: string; + color?: string | ((params: MenuElementParams) => string); icon?: string; text?: string; fontSize?: string; @@ -76,6 +76,7 @@ export const COLORS = { boat: "#3f6ab1", ally: "#53ac75", breakAlly: "#c74848", + breakAllyNoDebuff: "#d4882b", delete: "#ff0000", info: "#64748B", target: "#ff0000", @@ -216,7 +217,10 @@ const allyBreakElement: MenuElement = { !params.playerActions?.interaction?.canBreakAlliance, displayed: (params: MenuElementParams) => !!params.playerActions?.interaction?.canBreakAlliance, - color: COLORS.breakAlly, + color: (params: MenuElementParams) => + params.selected?.isTraitor() || params.selected?.isDisconnected() + ? COLORS.breakAllyNoDebuff + : COLORS.breakAlly, icon: traitorIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleBreakAlliance( diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts index e1162f4eb..8f645737a 100644 --- a/tests/client/graphics/RadialMenuElements.test.ts +++ b/tests/client/graphics/RadialMenuElements.test.ts @@ -92,6 +92,8 @@ describe("RadialMenuElements", () => { id: () => 1, isAlliedWith: vi.fn(() => false), isPlayer: vi.fn(() => true), + isTraitor: vi.fn(() => false), + isDisconnected: vi.fn(() => false), } as unknown as PlayerView; mockGame = { @@ -339,6 +341,8 @@ describe("RadialMenuElements", () => { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), + isTraitor: vi.fn(() => false), + isDisconnected: vi.fn(() => false), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); diff --git a/tests/radialMenuElements.test.ts b/tests/radialMenuElements.test.ts index 15a8885fc..e5ac9d34f 100644 --- a/tests/radialMenuElements.test.ts +++ b/tests/radialMenuElements.test.ts @@ -19,13 +19,18 @@ import { } from "../src/client/graphics/layers/RadialMenuElements"; // Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions -const makePlayer = (id: string) => +const makePlayer = ( + id: string, + opts?: { isTraitor?: boolean; isDisconnected?: boolean }, +) => ({ id: () => id, isAlliedWith: (other: any) => other && typeof other.id === "function" && other.id() !== id ? true : true, + isTraitor: () => opts?.isTraitor ?? false, + isDisconnected: () => opts?.isDisconnected ?? false, }) as unknown as import("../src/core/game/GameView").PlayerView; const makeParams = (opts?: Partial): MenuElementParams => { @@ -82,7 +87,26 @@ describe("RadialMenuElements ally break", () => { const ally = findAllyBreak(items)!; expect(ally).toBeTruthy(); expect(ally.name).toBe("break"); - expect(ally.color).toBe(COLORS.breakAlly); + expect(typeof ally.color).toBe("function"); + expect(ally.color(params)).toBe(COLORS.breakAlly); + }); + + test("shows break option with orange color when allied to traitor", () => { + const params = makeParams({ + selected: makePlayer("p2", { isTraitor: true }), + }); + const items = rootMenuElement.subMenu!(params); + const ally = findAllyBreak(items)!; + expect(ally.color(params)).toBe(COLORS.breakAllyNoDebuff); + }); + + test("shows boat button instead of break when allied to disconnected player", () => { + const params = makeParams({ + selected: makePlayer("p2", { isDisconnected: true }), + }); + const items = rootMenuElement.subMenu!(params); + expect(findAllyBreak(items)).toBeUndefined(); + expect(items.find((i) => i.id === "boat")).toBeDefined(); }); test("break action calls handleBreakAlliance and closes menu", () => { From e93cab339219601b442ebb91d5c22244f992a5c5 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:09:48 +0000 Subject: [PATCH 004/144] sam missile immunity (#3167) ## Description: added sam missile immunity (was missing from the immunity list) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/core/execution/NukeExecution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 68db6bdf4..d35be7a39 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -310,7 +310,8 @@ export class NukeExecution implements Execution { unit.type() !== UnitType.AtomBomb && unit.type() !== UnitType.HydrogenBomb && unit.type() !== UnitType.MIRVWarhead && - unit.type() !== UnitType.MIRV + unit.type() !== UnitType.MIRV && + unit.type() !== UnitType.SAMMissile ) { if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) { unit.delete(true, this.player); From ebdd1a5664625dd3a5cd475b0214ca9859cae052 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:09:48 +0000 Subject: [PATCH 005/144] sam missile immunity (#3167) ## Description: added sam missile immunity (was missing from the immunity list) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/core/execution/NukeExecution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 277057c5f..638843826 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -324,7 +324,8 @@ export class NukeExecution implements Execution { unit.type() !== UnitType.AtomBomb && unit.type() !== UnitType.HydrogenBomb && unit.type() !== UnitType.MIRVWarhead && - unit.type() !== UnitType.MIRV + unit.type() !== UnitType.MIRV && + unit.type() !== UnitType.SAMMissile ) { if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) { unit.delete(true, this.player); From f7da20ddfd80a61a05e183a61860e31c2e181c32 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:18:13 +0100 Subject: [PATCH 006/144] =?UTF-8?q?Nation=20build=20order=20improvements?= =?UTF-8?q?=20+=20Nation=20structure=20upgrading=20=F0=9F=8F=A0=20(#3152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2997 ## Description: ### New stuff - Nations can upgrade structures now. They do it if they have too many structures compared to their territory size - They prefer to upgrade stuff thats protected by SAMs (based on difficulty) - Updated the build order, it also depends a bit on the difficulty now (easy nations build less SAMs) - Nations can handle extreme amounts of gold now. 500M starting gold? no problem. Previously they only built cities - They stop saving up for MIRV if they can afford it (in some old Enzo "impossible difficulty experiment" videos you could see nations with like 300M gold...) - The save-up-target changes when bombs / hydros / MIRVs are disabled - Added many checks for disabled units. For example: Don't build SAMs when missile silos are disabled, focus on factories when ports are disabled - Updated the `structureSpawnTileValue` method, SAM-placement depends a bit on the difficulty now ### Refactor - Moved all structure related nation code into `NationStructureBehavior.ts` - Split up the good old `structureSpawnTileValue` method to make it more readable - Cleaned up NationExecution a bit ### A screenshot Screenshot 2026-02-08 001108 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/NationExecution.ts | 248 ++---- .../nation/NationStructureBehavior.ts | 740 ++++++++++++++++++ .../nation/structureSpawnTileValue.ts | 171 ---- 3 files changed, 823 insertions(+), 336 deletions(-) create mode 100644 src/core/execution/nation/NationStructureBehavior.ts delete mode 100644 src/core/execution/nation/structureSpawnTileValue.ts diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 25cc06b5a..466adcdaa 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -2,39 +2,36 @@ import { Difficulty, Execution, Game, - GameMode, - Gold, Nation, Player, PlayerID, Relation, TerrainType, - UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { assertNever, simpleHash } from "../Util"; -import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; import { NationNukeBehavior } from "./nation/NationNukeBehavior"; -import { randTerritoryTileArray } from "./nation/NationUtils"; +import { NationStructureBehavior } from "./nation/NationStructureBehavior"; import { NationWarshipBehavior } from "./nation/NationWarshipBehavior"; -import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { SpawnExecution } from "./SpawnExecution"; import { AiAttackBehavior } from "./utils/AiAttackBehavior"; export class NationExecution implements Execution { private active = true; private random: PseudoRandom; - private emojiBehavior: NationEmojiBehavior | null = null; - private mirvBehavior: NationMIRVBehavior | null = null; - private attackBehavior: AiAttackBehavior | null = null; - private allianceBehavior: NationAllianceBehavior | null = null; - private warshipBehavior: NationWarshipBehavior | null = null; - private nukeBehavior: NationNukeBehavior | null = null; + private behaviorsInitialized = false; + private emojiBehavior!: NationEmojiBehavior; + private mirvBehavior!: NationMIRVBehavior; + private attackBehavior!: AiAttackBehavior; + private allianceBehavior!: NationAllianceBehavior; + private warshipBehavior!: NationWarshipBehavior; + private nukeBehavior!: NationNukeBehavior; + private structureBehavior!: NationStructureBehavior; private mg: Game; private player: Player | null = null; @@ -89,7 +86,7 @@ export class NationExecution implements Execution { tick(ticks: number) { // Ship tracking if ( - this.warshipBehavior !== null && + this.behaviorsInitialized && this.player !== null && this.player.isAlive() && this.mg.config().gameConfig().difficulty !== Difficulty.Easy @@ -98,6 +95,24 @@ export class NationExecution implements Execution { } if (ticks % this.attackRate !== this.attackTick) { + // Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval) + // Otherwise it is possible that we earn more gold than we can spend + // The alternative is placing multiple structures in handleStructures, but that causes problems + if ( + this.behaviorsInitialized && + this.player !== null && + this.player.isAlive() + ) { + const offset = ticks % this.attackRate; + const oneThird = + (this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate; + const twoThirds = + (this.attackTick + Math.floor((this.attackRate * 2) / 3)) % + this.attackRate; + if (offset === oneThird || offset === twoThirds) { + this.structureBehavior.handleStructures(); + } + } return; } @@ -133,56 +148,8 @@ export class NationExecution implements Execution { return; } - if ( - this.emojiBehavior === null || - this.mirvBehavior === null || - this.attackBehavior === null || - this.allianceBehavior === null || - this.warshipBehavior === null || - this.nukeBehavior === null - ) { - this.emojiBehavior = new NationEmojiBehavior( - this.random, - this.mg, - this.player, - ); - this.mirvBehavior = new NationMIRVBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.allianceBehavior = new NationAllianceBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.warshipBehavior = new NationWarshipBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.attackBehavior = new AiAttackBehavior( - this.random, - this.mg, - this.player, - this.triggerRatio, - this.reserveRatio, - this.expandRatio, - this.allianceBehavior, - this.emojiBehavior, - ); - this.nukeBehavior = new NationNukeBehavior( - this.random, - this.mg, - this.player, - this.attackBehavior, - this.emojiBehavior, - ); - - // Send an attack on the first tick + if (!this.behaviorsInitialized) { + this.initializeBehaviors(); this.attackBehavior.forceSendAttack(this.mg.terraNullius()); return; } @@ -192,13 +159,65 @@ export class NationExecution implements Execution { this.allianceBehavior.handleAllianceRequests(); this.allianceBehavior.handleAllianceExtensionRequests(); this.mirvBehavior.considerMIRV(); - this.handleUnits(); + this.structureBehavior.handleStructures(); + this.warshipBehavior.maybeSpawnWarship(); this.handleEmbargoesToHostileNations(); this.attackBehavior.maybeAttack(); this.warshipBehavior.counterWarshipInfestation(); this.nukeBehavior.maybeSendNuke(); } + private initializeBehaviors(): void { + if (this.player === null) throw new Error("Player not initialized"); + + this.emojiBehavior = new NationEmojiBehavior( + this.random, + this.mg, + this.player, + ); + this.mirvBehavior = new NationMIRVBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.allianceBehavior = new NationAllianceBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.warshipBehavior = new NationWarshipBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.attackBehavior = new AiAttackBehavior( + this.random, + this.mg, + this.player, + this.triggerRatio, + this.reserveRatio, + this.expandRatio, + this.allianceBehavior, + this.emojiBehavior, + ); + this.nukeBehavior = new NationNukeBehavior( + this.random, + this.mg, + this.player, + this.attackBehavior, + this.emojiBehavior, + ); + this.structureBehavior = new NationStructureBehavior( + this.random, + this.mg, + this.player, + ); + this.behaviorsInitialized = true; + } + private randomSpawnLand(): TileRef | null { if (this.nation.spawnCell === undefined) throw new Error("not initialized"); @@ -249,102 +268,6 @@ export class NationExecution implements Execution { }); } - private handleUnits() { - if (this.warshipBehavior === null) throw new Error("not initialized"); - const hasCoastalTiles = this.hasCoastalTiles(); - const isTeamGame = this.mg.config().gameConfig().gameMode === GameMode.Team; - return ( - this.maybeSpawnStructure(UnitType.City, (num) => num) || - this.maybeSpawnStructure(UnitType.Port, (num) => num) || - this.warshipBehavior.maybeSpawnWarship() || - this.maybeSpawnStructure(UnitType.Factory, (num) => - hasCoastalTiles ? num * 3 : num, - ) || - this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) || - this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => - isTeamGame ? num : num ** 2, - ) || - this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2) - ); - } - - private hasCoastalTiles(): boolean { - if (this.player === null) throw new Error("not initialized"); - for (const tile of this.player.borderTiles()) { - if (this.mg.isOceanShore(tile)) return true; - } - return false; - } - - private maybeSpawnStructure( - type: UnitType, - multiplier: (num: number) => number, - ) { - if (this.player === null) throw new Error("not initialized"); - const owned = this.player.unitsOwned(type); - const perceivedCostMultiplier = multiplier(owned + 1); - const realCost = this.cost(type); - const perceivedCost = realCost * BigInt(perceivedCostMultiplier); - if (this.player.gold() < perceivedCost) { - return false; - } - const tile = this.structureSpawnTile(type); - if (tile === null) { - return false; - } - const canBuild = this.player.canBuild(type, tile); - if (canBuild === false) { - return false; - } - this.mg.addExecution(new ConstructionExecution(this.player, type, tile)); - return true; - } - - private structureSpawnTile(type: UnitType): TileRef | null { - if (this.mg === undefined) throw new Error("Not initialized"); - if (this.player === null) throw new Error("Not initialized"); - const tiles = - type === UnitType.Port - ? this.randCoastalTileArray(25) - : randTerritoryTileArray(this.random, this.mg, this.player, 25); - if (tiles.length === 0) return null; - const valueFunction = structureSpawnTileValue(this.mg, this.player, type); - if (valueFunction === null) return null; - let bestTile: TileRef | null = null; - let bestValue = 0; - for (const t of tiles) { - const v = valueFunction(t); - if (v <= bestValue && bestTile !== null) continue; - if (!this.player.canBuild(type, t)) continue; - // Found a better tile - bestTile = t; - bestValue = v; - } - return bestTile; - } - - private randCoastalTileArray(numTiles: number): TileRef[] { - const tiles = Array.from(this.player!.borderTiles()).filter((t) => - this.mg.isOceanShore(t), - ); - return Array.from(this.arraySampler(tiles, numTiles)); - } - - private *arraySampler(a: T[], sampleSize: number): Generator { - if (a.length <= sampleSize) { - // Return all elements - yield* a; - } else { - // Sample `sampleSize` elements - const remaining = new Set(a); - while (sampleSize--) { - const t = this.random.randFromSet(remaining); - remaining.delete(t); - yield t; - } - } - } - private handleEmbargoesToHostileNations() { const player = this.player; if (player === null) return; @@ -375,11 +298,6 @@ export class NationExecution implements Execution { }); } - private cost(type: UnitType): Gold { - if (this.player === null) throw new Error("not initialized"); - return this.mg.unitInfo(type).cost(this.mg, this.player); - } - isActive(): boolean { return this.active; } diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts new file mode 100644 index 000000000..c6e937985 --- /dev/null +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -0,0 +1,740 @@ +import { + Difficulty, + Game, + Gold, + Player, + PlayerType, + Relation, + StructureTypes, + Unit, + UnitType, +} from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PseudoRandom } from "../../PseudoRandom"; +import { assertNever } from "../../Util"; +import { ConstructionExecution } from "../ConstructionExecution"; +import { UpgradeStructureExecution } from "../UpgradeStructureExecution"; +import { closestTile, closestTwoTiles } from "../Util"; +import { randTerritoryTileArray } from "./NationUtils"; + +/** + * Configuration for how many structures of each type a nation should build + * relative to the number of cities it owns. + */ +interface StructureRatioConfig { + /** How many of this structure per city (e.g., 0.75 means 3 ports for every 4 cities) */ + ratioPerCity: number; + /** Perceived cost increase percentage per owned structure (e.g., 0.1 = 10% more expensive per owned) */ + perceivedCostIncreasePerOwned: number; +} + +/** SAM launcher ratio per city, keyed by difficulty */ +const SAM_RATIO_BY_DIFFICULTY: Record = { + [Difficulty.Easy]: 0.15, + [Difficulty.Medium]: 0.2, + [Difficulty.Hard]: 0.25, + [Difficulty.Impossible]: 0.3, +}; + +/** + * Returns structure ratios relative to city count, adjusted by difficulty. + * Cities are always prioritized and built first. + * When cities are disabled, we use TILES_PER_CITY_EQUIVALENT. That's not ideal, nations won't properly upgrade structures, but it's better than nothing. Probably 99.9% of players won't disable cities anyway. + */ +function getStructureRatios( + difficulty: Difficulty, +): Partial> { + return { + [UnitType.Port]: { ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1 }, + [UnitType.Factory]: { + ratioPerCity: 0.75, + perceivedCostIncreasePerOwned: 1, + }, + [UnitType.DefensePost]: { + ratioPerCity: 0.25, + perceivedCostIncreasePerOwned: 1, + }, + [UnitType.SAMLauncher]: { + ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty], + perceivedCostIncreasePerOwned: 1, + }, + [UnitType.MissileSilo]: { + ratioPerCity: 0.2, + perceivedCostIncreasePerOwned: 1, + }, + }; +} + +/** Perceived cost increase percentage per city owned */ +const CITY_PERCEIVED_COST_INCREASE_PER_OWNED = 1; + +/** Factory ratio multiplier when the nation has coastal tiles */ +const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33; + +/** Maximum number of missile silos a nation will build */ +const MAX_MISSILE_SILOS = 3; + +/** If we have more than this many structures per tiles, prefer upgrading over building */ +const UPGRADE_DENSITY_THRESHOLD = 1 / 1500; + +/** Maximum density of defense posts (per tile owned) before no more can be built */ +const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000; + +/** Estimated number of tiles per city equivalent, used when cities are disabled */ +const TILES_PER_CITY_EQUIVALENT = 2000; + +export class NationStructureBehavior { + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + ) {} + + handleStructures(): boolean { + const config = this.game.config(); + const citiesDisabled = config.isUnitDisabled(UnitType.City); + const cityCount = citiesDisabled + ? Math.max( + 1, + Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT), + ) + : this.player.unitsOwned(UnitType.City); + const hasCoastalTiles = this.hasCoastalTiles(); + + // Build order for non-city structures (priority order) + const buildOrder: UnitType[] = [ + UnitType.DefensePost, + UnitType.Port, + UnitType.Factory, + UnitType.SAMLauncher, + UnitType.MissileSilo, + ]; + + const nukesEnabled = + !config.isUnitDisabled(UnitType.AtomBomb) || + !config.isUnitDisabled(UnitType.HydrogenBomb) || + !config.isUnitDisabled(UnitType.MIRV); + const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo); + + for (const structureType of buildOrder) { + // Skip disabled structure types + if (config.isUnitDisabled(structureType)) { + continue; + } + + // Skip ports if no coastal tiles + if (structureType === UnitType.Port && !hasCoastalTiles) { + continue; + } + + // Skip missile silos and SAM launchers if all nukes are disabled + if ( + !nukesEnabled && + (structureType === UnitType.MissileSilo || + structureType === UnitType.SAMLauncher) + ) { + continue; + } + + // Skip SAM launchers if missile silos are disabled + if (!missileSilosEnabled && structureType === UnitType.SAMLauncher) { + continue; + } + + if ( + this.shouldBuildStructure(structureType, cityCount, hasCoastalTiles) + ) { + if (this.maybeSpawnStructure(structureType)) { + return true; + } + } + } + + if (!citiesDisabled && this.maybeSpawnStructure(UnitType.City)) { + return true; + } + + return false; + } + + private hasCoastalTiles(): boolean { + for (const tile of this.player.borderTiles()) { + if (this.game.isOceanShore(tile)) return true; + } + return false; + } + + /** + * Determines if we should build more of this structure type based on + * the current city count and the configured ratio. + */ + private shouldBuildStructure( + type: UnitType, + cityCount: number, + hasCoastalTiles: boolean, + ): boolean { + const { difficulty } = this.game.config().gameConfig(); + const ratios = getStructureRatios(difficulty); + const config = ratios[type]; + if (config === undefined) { + return false; + } + + let ratio = config.ratioPerCity; + + // Heavily reduce factory spawning if we have coastal tiles + if ( + type === UnitType.Factory && + hasCoastalTiles && + !this.game.config().isUnitDisabled(UnitType.Port) + ) { + ratio *= FACTORY_COASTAL_RATIO_MULTIPLIER; + } + + const owned = this.player.unitsOwned(type); + + // Hard cap on missile silos + if (type === UnitType.MissileSilo && owned >= MAX_MISSILE_SILOS) { + return false; + } + + // Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold) + if (type === UnitType.DefensePost) { + const tilesOwned = this.player.numTilesOwned(); + if ( + tilesOwned > 0 && + owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD + ) { + return false; + } + } + + const targetCount = Math.floor(cityCount * ratio); + + return owned < targetCount; + } + + private cost(type: UnitType): Gold { + return this.game.unitInfo(type).cost(this.game, this.player); + } + + private maybeSpawnStructure(type: UnitType): boolean { + const perceivedCost = this.getPerceivedCost(type); + if (this.player.gold() < perceivedCost) { + return false; + } + + // Check if we should upgrade instead of building new + const structures = this.player.units(type); + if ( + this.getTotalStructureDensity() > UPGRADE_DENSITY_THRESHOLD && + type !== UnitType.DefensePost + ) { + if (this.maybeUpgradeStructure(structures)) { + return true; + } + // Density too high but couldn't upgrade (e.g. all under construction) — don't build new, wait for construction (most relevant for SAMs) + if (structures.length > 0) { + return false; + } + // No structures of this type exist yet — fall through to build the first one + // (even if density is high - the nation is probably on a tiny island and we need to use all building spots we can find) + } + + const tile = this.structureSpawnTile(type); + if (tile === null) { + return false; + } + const canBuild = this.player.canBuild(type, tile); + if (canBuild === false) { + return false; + } + this.game.addExecution(new ConstructionExecution(this.player, type, tile)); + return true; + } + + /** + * Calculates the perceived cost for a structure type. + * The perceived cost increases by a percentage for each structure of that type already owned. + * This makes nations save up gold for nukes. + * Once the nation can afford its target stockpile, stop inflating costs. + */ + private getPerceivedCost(type: UnitType): Gold { + const realCost = this.cost(type); + + const saveUpTarget = this.getSaveUpTarget(); + if (saveUpTarget === 0n || this.player.gold() >= saveUpTarget) { + return realCost; + } + + const owned = this.player.unitsOwned(type); + + let increasePerOwned: number; + if (type === UnitType.City) { + increasePerOwned = CITY_PERCEIVED_COST_INCREASE_PER_OWNED; + } else { + const { difficulty } = this.game.config().gameConfig(); + const ratios = getStructureRatios(difficulty); + const config = ratios[type]; + increasePerOwned = config?.perceivedCostIncreasePerOwned ?? 0.1; + } + + // Each owned structure makes the next one feel more expensive + // Formula: realCost * (1 + increasePerOwned * owned) + const multiplier = 1 + increasePerOwned * owned; + return BigInt(Math.ceil(Number(realCost) * multiplier)); + } + + /** + * Determines the gold target we want to save up for based on which nukes are enabled. + * Returns 0 if no saving is needed. + */ + private getSaveUpTarget(): Gold { + const config = this.game.config(); + + // No need to save up if missile silos are disabled + if (config.isUnitDisabled(UnitType.MissileSilo)) { + return 0n; + } + + const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV); + const hydroEnabled = !config.isUnitDisabled(UnitType.HydrogenBomb); + const atomEnabled = !config.isUnitDisabled(UnitType.AtomBomb); + + if (mirvEnabled) { + // Save up for MIRV + Hydrogen Bomb + return this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb); + } + if (hydroEnabled) { + // Save up for 5 hydrogen bombs + return this.cost(UnitType.HydrogenBomb) * 5n; + } + if (atomEnabled) { + // Save up for 20 atom bombs + return this.cost(UnitType.AtomBomb) * 20n; + } + // No nukes enabled, no need to save up + return 0n; + } + + /** + * Tries to upgrade an existing structure if density threshold is exceeded. + * @param structures The pool of structures to consider for upgrading + * @returns true if an upgrade was initiated, false otherwise + */ + private maybeUpgradeStructure(structures: Unit[]): boolean { + if (this.getTotalStructureDensity() <= UPGRADE_DENSITY_THRESHOLD) { + return false; + } + if (structures.length === 0) { + return false; + } + const structureToUpgrade = this.findBestStructureToUpgrade(structures); + if ( + structureToUpgrade !== null && + this.player.canUpgradeUnit(structureToUpgrade) + ) { + this.game.addExecution( + new UpgradeStructureExecution(this.player, structureToUpgrade.id()), + ); + return true; + } + return false; + } + + /** + * Calculates total structure density across player's territory. + */ + private getTotalStructureDensity(): number { + let totalStructures = 0; + for (const type of StructureTypes) { + totalStructures += this.player.units(type).length; // ignoring levels + } + const tilesOwned = this.player.numTilesOwned(); + return tilesOwned > 0 ? totalStructures / tilesOwned : 0; + } + + /** + * Finds the best structure to upgrade, preferring structures protected by a SAM. + * In 50% of cases, picks the second or third best to add variety. + */ + private findBestStructureToUpgrade(structures: Unit[]): Unit | null { + if (structures.length === 0) { + return null; + } + + // Filter to only upgradable structures + const upgradable = structures.filter((s) => this.player.canUpgradeUnit(s)); + if (upgradable.length === 0) { + return null; + } + + // Based on difficulty, chance to just pick a random structure + const { difficulty } = this.game.config().gameConfig(); + let randomChance: number; + switch (difficulty) { + case Difficulty.Easy: + randomChance = 70; + break; + case Difficulty.Medium: + randomChance = 40; + break; + case Difficulty.Hard: + randomChance = 25; + break; + case Difficulty.Impossible: + randomChance = 10; + break; + default: + assertNever(difficulty); + } + + if (this.random.nextInt(0, 100) < randomChance) { + return this.random.randElement(upgradable); + } + + const samLaunchers = this.player.units(UnitType.SAMLauncher); + + // Score each structure based on SAM protection + const scored: { structure: Unit; score: number }[] = []; + + for (const structure of upgradable) { + let score = 0; + + // Check if protected by any SAM, using per-SAM level-based range + for (const sam of samLaunchers) { + const samRange = this.game.config().samRange(sam.level()); + const samRangeSquared = samRange * samRange; + const distSquared = this.game.euclideanDistSquared( + structure.tile(), + sam.tile(), + ); + if (distSquared <= samRangeSquared) { + // Protected by this SAM, add score based on SAM level + score += 10; + if (sam.level() > 1) { + score += (sam.level() - 1) * 7.5; + } + } + } + + // Add small random factor to break ties + score += this.random.nextInt(0, 5); + + scored.push({ structure, score }); + } + + if (scored.length === 0) { + return null; + } + + // Sort descending by score + scored.sort((a, b) => b.score - a.score); + + // 50% of the time, pick the second or third best for variety + if (scored.length >= 2 && this.random.chance(2)) { + const pickIndex = + scored.length >= 3 + ? this.random.nextInt(1, 3) // pick index 1 or 2 + : 1; // only index 1 available + return scored[pickIndex].structure; + } + + return scored[0].structure; + } + + private structureSpawnTile(type: UnitType): TileRef | null { + const tiles = + type === UnitType.Port + ? this.randCoastalTileArray(25) + : randTerritoryTileArray(this.random, this.game, this.player, 25); + if (tiles.length === 0) return null; + const valueFunction = this.structureSpawnTileValue(type); + if (valueFunction === null) return null; + let bestTile: TileRef | null = null; + let bestValue = 0; + for (const t of tiles) { + const v = valueFunction(t); + if (v <= bestValue && bestTile !== null) continue; + if (!this.player.canBuild(type, t)) continue; + // Found a better tile + bestTile = t; + bestValue = v; + } + return bestTile; + } + + private randCoastalTileArray(numTiles: number): TileRef[] { + const tiles = Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ); + return Array.from(this.arraySampler(tiles, numTiles)); + } + + private *arraySampler(a: T[], sampleSize: number): Generator { + if (a.length <= sampleSize) { + // Return all elements + yield* a; + } else { + // Sample `sampleSize` elements + const remaining = new Set(a); + while (sampleSize--) { + const t = this.random.randFromSet(remaining); + remaining.delete(t); + yield t; + } + } + } + + private structureSpawnTileValue( + type: UnitType, + ): ((tile: TileRef) => number) | null { + switch (type) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + return this.interiorStructureValue(type); + case UnitType.Port: + return this.portValue(); + case UnitType.DefensePost: + return this.defensePostValue(); + case UnitType.SAMLauncher: + return this.samLauncherValue(); + default: + throw new Error(`Value function not implemented for ${type}`); + } + } + + /** + * Value function for interior structures (City, Factory, MissileSilo). + * Prefers high elevation, distance from border, and spacing from same-type structures. + */ + private interiorStructureValue(type: UnitType): (tile: TileRef) => number { + const game = this.game; + const borderTiles = this.player.borderTiles(); + const otherUnits = this.player.units(type); + const { borderSpacing, structureSpacing } = this.spacingConstants(); + + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += game.magnitude(tile); + + // Prefer to be away from the border + const [, closestBorderDist] = closestTile(game, borderTiles, tile); + w += Math.min(closestBorderDist, borderSpacing); + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(game, otherTiles, [tile]); + if (closestOther !== null) { + const d = game.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + + /** + * Value function for ports. + * Prefers spacing from other ports. + */ + private portValue(): (tile: TileRef) => number { + const game = this.game; + const otherUnits = this.player.units(UnitType.Port); + const { structureSpacing } = this.spacingConstants(); + + return (tile) => { + let w = 0; + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const [, closestOtherDist] = closestTile(game, otherTiles, tile); + w += Math.min(closestOtherDist, structureSpacing); + + return w; + }; + } + + /** + * Value function for defense posts. + * Returns null if there are no hostile non-bot neighbors. + * Prefers elevation, proximity to border with hostile neighbors, and spacing. + */ + private defensePostValue(): ((tile: TileRef) => number) | null { + const game = this.game; + const player = this.player; + const borderTiles = player.borderTiles(); + const otherUnits = player.units(UnitType.DefensePost); + const { borderSpacing, structureSpacing } = this.spacingConstants(); + + // Check if we have any non-friendly non-bot neighbors with more troops + const hasHostileNeighbor = + player + .neighbors() + .filter( + (n): n is Player => + n.isPlayer() && + player.isFriendly(n) === false && + n.type() !== PlayerType.Bot && + n.troops() > player.troops(), + ).length > 0; + + // Don't build defense posts if there is no danger + if (!hasHostileNeighbor) { + return null; + } + + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += game.magnitude(tile); + + const [closest, closestBorderDist] = closestTile(game, borderTiles, tile); + if (closest !== null) { + // Prefer to be borderSpacing tiles from the border + w += Math.max( + 0, + borderSpacing - Math.abs(borderSpacing - closestBorderDist), + ); + + // Prefer adjacent players who are hostile and have more troops + const neighbors: Set = new Set(); + for (const neighborTile of game.neighbors(closest)) { + if (!game.isLand(neighborTile)) continue; + const id = game.ownerID(neighborTile); + if (id === player.smallID()) continue; + const neighbor = game.playerBySmallID(id); + if (!neighbor.isPlayer()) continue; + if (neighbor.type() === PlayerType.Bot) continue; + if (neighbor.troops() <= player.troops()) continue; + neighbors.add(neighbor); + } + for (const neighbor of neighbors) { + w += borderSpacing * (Relation.Friendly - player.relation(neighbor)); + } + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(game, otherTiles, [tile]); + if (closestOther !== null) { + const d = game.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + + /** + * Value function for SAM launchers. + * Prefers elevation, distance from border, spacing, and proximity to protectable structures. + * On harder difficulties, weights by structure level and considers existing SAM coverage. + */ + private samLauncherValue(): (tile: TileRef) => number { + const game = this.game; + const player = this.player; + const borderTiles = player.borderTiles(); + const otherUnits = player.units(UnitType.SAMLauncher); + const { borderSpacing, structureSpacing } = this.spacingConstants(); + + const { difficulty } = game.config().gameConfig(); + const weightByLevel = + difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible; + + const protectEntries: { tile: TileRef; weight: number }[] = []; + for (const unit of player.units()) { + switch (unit.type()) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + case UnitType.Port: + protectEntries.push({ + tile: unit.tile(), + weight: weightByLevel ? unit.level() : 1, + }); + } + } + const range = game.config().defaultSamRange(); + const rangeSquared = range * range; + + const useCoverageWeighting = + difficulty !== Difficulty.Easy && this.random.nextInt(0, 100) < 25; + + // Pre-compute existing SAM coverage for each protectable structure + let structureCoverage: Map | null = null; + if (useCoverageWeighting) { + structureCoverage = new Map(); + const existingSams = player.units(UnitType.SAMLauncher); + for (const entry of protectEntries) { + let coverageScore = 0; + for (const sam of existingSams) { + const samRange = game.config().samRange(sam.level()); + const dist = game.euclideanDistSquared(entry.tile, sam.tile()); + if (dist <= samRange * samRange) { + coverageScore += sam.level(); + } + } + structureCoverage.set(entry.tile, coverageScore); + } + } + + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += game.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(game, borderTiles, [tile]); + if (closestBorder !== null) { + const d = game.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(game, otherTiles, [tile]); + if (closestOther !== null) { + const d = game.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // Prefer to be in range of other structures (skip on easy difficulty) + if (difficulty !== Difficulty.Easy) { + for (const entry of protectEntries) { + const distanceSquared = game.euclideanDistSquared(tile, entry.tile); + if (distanceSquared > rangeSquared) continue; + if (useCoverageWeighting && structureCoverage !== null) { + const coverage = structureCoverage.get(entry.tile) ?? 0; + const coverageWeight = 1 / (1 + coverage); + w += structureSpacing * entry.weight * coverageWeight; + } else { + w += structureSpacing * entry.weight; + } + } + } + + return w; + }; + } + + /** Shared spacing constants derived from atom bomb range. */ + private spacingConstants(): { + borderSpacing: number; + structureSpacing: number; + } { + const borderSpacing = this.game + .config() + .nukeMagnitudes(UnitType.AtomBomb).outer; + return { borderSpacing, structureSpacing: borderSpacing * 2 }; + } +} diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts deleted file mode 100644 index a882ca664..000000000 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Game, Player, PlayerType, Relation, UnitType } from "../../game/Game"; -import { TileRef } from "../../game/GameMap"; -import { closestTile, closestTwoTiles } from "../Util"; - -export function structureSpawnTileValue( - mg: Game, - player: Player, - type: UnitType, -): ((tile: TileRef) => number) | null { - const borderTiles = player.borderTiles(); - const otherUnits = player.units(type); - // Prefer spacing structures out of atom bomb range - const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; - const structureSpacing = borderSpacing * 2; - switch (type) { - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: { - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const [, closestBorderDist] = closestTile(mg, borderTiles, tile); - w += Math.min(closestBorderDist, borderSpacing); - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // TODO: Cities and factories should consider train range limits - return w; - }; - } - case UnitType.Port: { - return (tile) => { - let w = 0; - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const [, closestOtherDist] = closestTile(mg, otherTiles, tile); - w += Math.min(closestOtherDist, structureSpacing); - - return w; - }; - } - case UnitType.DefensePost: { - // Check if we have any non-friendly non-bot neighbors - const hasHostileNeighbor = - player - .neighbors() - .filter( - (n): n is Player => - n.isPlayer() && - player.isFriendly(n) === false && - n.type() !== PlayerType.Bot, - ).length > 0; - - // Don't build defense posts if there is no danger - if (!hasHostileNeighbor) { - return null; - } - - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile); - if (closest !== null) { - // Prefer to be borderSpacing tiles from the border - w += Math.max( - 0, - borderSpacing - Math.abs(borderSpacing - closestBorderDist), - ); - - // Prefer adjacent players who are hostile - const neighbors: Set = new Set(); - for (const tile of mg.neighbors(closest)) { - if (!mg.isLand(tile)) continue; - const id = mg.ownerID(tile); - if (id === player.smallID()) continue; - const neighbor = mg.playerBySmallID(id); - if (!neighbor.isPlayer()) continue; - if (neighbor.type() === PlayerType.Bot) continue; - neighbors.add(neighbor); - } - for (const neighbor of neighbors) { - w += - borderSpacing * (Relation.Friendly - player.relation(neighbor)); - } - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - } - case UnitType.SAMLauncher: { - const protectTiles: Set = new Set(); - for (const unit of player.units()) { - switch (unit.type()) { - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: - case UnitType.Port: - protectTiles.add(unit.tile()); - } - } - const range = mg.config().defaultSamRange(); - const rangeSquared = range * range; - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.min(d, borderSpacing); - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // Prefer to be in range of other structures - for (const maybeProtected of protectTiles) { - const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected); - if (distanceSquared > rangeSquared) continue; - w += structureSpacing; - } - - return w; - }; - } - default: - throw new Error(`Value function not implemented for ${type}`); - } -} From 900cc89067f1fd72752d2325554e9a65a2d8bfae Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 9 Feb 2026 21:05:59 -0800 Subject: [PATCH 007/144] Better username censoring (#3122) ## Description: Many inapropriate names bypass the current filter. This PR does the following: 1. Moves name censoring to server side so inappropriate names are scrubbed before being sent to the client 2. Requests a list of profane words from the api, this allows us to quickly add new profane words in the admin panel without having to redeploy. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/ClientGameRunner.ts | 1 + src/core/GameRunner.ts | 3 +- src/core/game/GameView.ts | 8 ++ src/core/validations/username.ts | 83 ----------------- src/server/Client.ts | 1 + src/server/Privilege.ts | 120 ++++++++++++++++++++++++- src/server/PrivilegeRefresher.ts | 53 +++++++++-- src/server/Worker.ts | 8 ++ tests/Censor.test.ts | 69 --------------- tests/Privilege.test.ts | 147 +++++++++++++++++++++++++++++++ 10 files changed, 330 insertions(+), 163 deletions(-) create mode 100644 tests/Privilege.test.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6b8f8ee52..ca7dfef7f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -227,6 +227,7 @@ async function createClientGame( config, gameMap, clientID, + lobbyConfig.playerName, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, ); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 0f93a94f6..619fb2645 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; import { simpleHash } from "./Util"; -import { censorNameWithClanTag } from "./validations/username"; export async function createGameRunner( gameStart: GameStartInfo, @@ -48,7 +47,7 @@ export async function createGameRunner( const humans = gameStart.players.map((p) => { return new PlayerInfo( - p.clientID === clientID ? p.username : censorNameWithClanTag(p.username), + p.username, PlayerType.Human, p.clientID, random.nextID(), diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 15ce0d564..65a5b74d8 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -603,12 +603,20 @@ export class GameView implements GameMap { private _config: Config, private _mapData: TerrainMapData, private _myClientID: ClientID, + private _myUsername: string, private _gameID: GameID, private humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); + // Replace the local player's username with their own stored username. + // This way the user does not know they are being censored. + for (const h of this.humans) { + if (h.clientID === this._myClientID) { + h.username = this._myUsername; + } + } this._cosmetics = new Map( this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index 8c348b31c..cb55390f2 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,92 +1,9 @@ -import { - RegExpMatcher, - collapseDuplicatesTransformer, - englishDataset, - englishRecommendedTransformers, - resolveConfusablesTransformer, - resolveLeetSpeakTransformer, - skipNonAlphabeticTransformer, -} from "obscenity"; import { translateText } from "../../client/Utils"; import { UsernameSchema } from "../Schemas"; -import { getClanTagOriginalCase, simpleHash } from "../Util"; - -const matcher = new RegExpMatcher({ - ...englishDataset.build(), - ...englishRecommendedTransformers, - ...resolveConfusablesTransformer(), - ...skipNonAlphabeticTransformer(), - ...collapseDuplicatesTransformer(), - ...resolveLeetSpeakTransformer(), -}); export const MIN_USERNAME_LENGTH = 3; export const MAX_USERNAME_LENGTH = 27; -const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", -]; - -export function fixProfaneUsername(username: string): string { - if (isProfaneUsername(username)) { - return shadowNames[simpleHash(username) % shadowNames.length]; - } - return username; -} - -export function isProfaneUsername(username: string): boolean { - return matcher.hasMatch(username); -} - -/** - * Sanitizes and censors profane usernames and clan tags. - * Profane username is overwritten, profane clan tag is removed. - * - * Preserves non-profane clan tag: - * prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten - * - * Removing bad clan tags won't hurt existing clans nor cause desyncs: - * - full name including clan tag was overwritten in the past, if any part of name was bad - * - only each separate local player name with a profane clan tag will remain, no clan team assignment - * - * Examples: - * - "GoodName" -> "GoodName" - * - "BadName" -> "Censored" - * - "[CLAN]GoodName" -> "[CLAN]GoodName" - * - "[CLaN]BadName" -> "[CLaN] Censored" - * - "[BAD]GoodName" -> "GoodName" - * - "[BAD]BadName" -> "Censored" - */ -export function censorNameWithClanTag(username: string): string { - // Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match - const clanTag = getClanTagOriginalCase(username); - - const nameWithoutClan = clanTag - ? username.replace(`[${clanTag}]`, "").trim() - : username; - - const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false; - const usernameIsProfane = isProfaneUsername(nameWithoutClan); - - const censoredNameWithoutClan = usernameIsProfane - ? fixProfaneUsername(nameWithoutClan) - : nameWithoutClan; - - // Restore clan tag if it existed and is not profane - if (clanTag && !clanTagIsProfane) { - return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`; - } - - // Don't restore profane or nonexistent clan tag - return censoredNameWithoutClan; -} - export function validateUsername(username: string): { isValid: boolean; error?: string; diff --git a/src/server/Client.ts b/src/server/Client.ts index 6f07b6562..9fda7317d 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -18,6 +18,7 @@ export class Client { public readonly flares: string[] | undefined, public readonly ip: string, public readonly username: string, + public readonly uncensoredUsername: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, ) {} diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 62d917fd3..c3d5af3e0 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -1,3 +1,14 @@ +import { + DataSet, + RegExpMatcher, + collapseDuplicatesTransformer, + englishDataset, + pattern, + resolveConfusablesTransformer, + resolveLeetSpeakTransformer, + skipNonAlphabeticTransformer, + toAsciiLowerCaseTransformer, +} from "obscenity"; import { Cosmetics } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { @@ -7,6 +18,95 @@ import { PlayerCosmetics, PlayerPattern, } from "../core/Schemas"; +import { getClanTagOriginalCase, simpleHash } from "../core/Util"; + +export const shadowNames = [ + "UnhuggedToday", + "DaddysLilChamp", + "BunnyKisses67", + "SnugglePuppy", + "CuddleMonster67", + "DaddysLilStar", + "SnuggleMuffin", + "PeesALittle", + "PleaseFullSendMe", + "NanasLilMan", + "NoAlliances", + "TryingTooHard67", + "MommysLilStinker", + "NeedHugs", + "MommysLilPeanut", + "IWillBetrayU", + "DaddysLilTater", + "PreciousBubbles", + "67 Cringelord", + "Peace And Love", + "AlmostPottyTrained", +]; + +export function createMatcher(bannedWords: string[]): RegExpMatcher { + const customDataset = new DataSet<{ originalWord: string }>().addAll( + englishDataset, + ); + + for (const word of bannedWords) { + customDataset.addPhrase((phrase) => + phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`), + ); + } + + return new RegExpMatcher({ + ...customDataset.build(), + blacklistMatcherTransformers: [ + toAsciiLowerCaseTransformer(), + resolveConfusablesTransformer(), + resolveLeetSpeakTransformer(), + collapseDuplicatesTransformer(), + skipNonAlphabeticTransformer(), + ], + }); +} + +/** + * Sanitizes and censors profane usernames and clan tags. + * Profane username is overwritten, profane clan tag is removed. + * + * Removing bad clan tags won't hurt existing clans nor cause desyncs: + * - full name including clan tag was overwritten in the past, if any part of name was bad + * - only each separate local player name with a profane clan tag will remain, no clan team assignment + * + * Examples: + * - "GoodName" -> "GoodName" + * - "BadName" -> "Censored" + * - "[CLAN]GoodName" -> "[CLAN]GoodName" + * - "[CLaN]BadName" -> "[CLAN] Censored" + * - "[BAD]GoodName" -> "GoodName" + * - "[BAD]BadName" -> "Censored" + */ +function censorUsernameWithMatcher( + username: string, + matcher: RegExpMatcher, +): string { + const clanTag = getClanTagOriginalCase(username); + + const nameWithoutClan = clanTag + ? username.replace(`[${clanTag}]`, "").trim() + : username; + + const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false; + const usernameIsProfane = matcher.hasMatch(nameWithoutClan); + + const censoredName = usernameIsProfane + ? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length] + : nameWithoutClan; + + // Restore clan tag only if it's clean, otherwise remove it entirely + if (clanTag && !clanTagIsProfane) { + return `[${clanTag.toUpperCase()}] ${censoredName}`; + } + + return censoredName; +} type CosmeticResult = | { type: "allowed"; cosmetics: PlayerCosmetics } @@ -14,13 +114,19 @@ type CosmeticResult = export interface PrivilegeChecker { isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult; + censorUsername(username: string): string; } export class PrivilegeCheckerImpl implements PrivilegeChecker { + private matcher: RegExpMatcher; + constructor( private cosmetics: Cosmetics, private b64urlDecode: (base64: string) => Uint8Array, - ) {} + bannedWords: string[], + ) { + this.matcher = createMatcher(bannedWords); + } isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult { const cosmetics: PlayerCosmetics = {}; @@ -106,10 +212,22 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } return { color }; } + + censorUsername(username: string): string { + return censorUsernameWithMatcher(username, this.matcher); + } } +// Default matcher with no custom banned words (just englishDataset) +const defaultMatcher = createMatcher([]); + export class FailOpenPrivilegeChecker implements PrivilegeChecker { isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult { return { type: "allowed", cosmetics: {} }; } + + censorUsername(username: string): string { + // Fail open: use matcher with just the built-in English profanity dataset + return censorUsernameWithMatcher(username, defaultMatcher); + } } diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts index 030da9621..086c7218a 100644 --- a/src/server/PrivilegeRefresher.ts +++ b/src/server/PrivilegeRefresher.ts @@ -8,7 +8,7 @@ import { PrivilegeCheckerImpl, } from "./Privilege"; -// Refreshes the privilege checker every 5 minutes. +// Refreshes the privilege checker every 3 minutes. // WARNING: This fails open if cosmetics.json is not available. export class PrivilegeRefresher { private privilegeChecker: PrivilegeChecker | null = null; @@ -18,7 +18,9 @@ export class PrivilegeRefresher { private log: Logger; constructor( - private endpoint: string, + private cosmeticsEndpoint: string, + private profaneWordsEndpoint: string, + private apiKey: string, parentLog: Logger, private refreshInterval: number = 1000 * 60 * 3, ) { @@ -37,27 +39,62 @@ export class PrivilegeRefresher { } private async loadPrivilegeChecker(): Promise { - this.log.info(`Loading privilege checker from ${this.endpoint}`); + this.log.info(`Loading privilege checker`); try { - const response = await fetch(this.endpoint); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const fetchWithTimeout = async (url: string) => { + try { + return await fetch(url, { + signal: AbortSignal.timeout(5000), + headers: { "x-api-key": this.apiKey }, + }); + } catch (error) { + this.log.warn(`Failed to fetch ${url}: ${error}`); + return null; + } + }; + + const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([ + fetchWithTimeout(this.cosmeticsEndpoint), + fetchWithTimeout(this.profaneWordsEndpoint), + ]); + + if (!cosmeticsResponse || !cosmeticsResponse.ok) { + throw new Error( + `Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`, + ); } - const cosmeticsData = await response.json(); + const cosmeticsData = await cosmeticsResponse.json(); const result = CosmeticsSchema.safeParse(cosmeticsData); if (!result.success) { throw new Error(`Invalid cosmetics data: ${result.error.message}`); } + let bannedWords: string[] = []; + if (profaneWordsResponse && profaneWordsResponse.ok) { + try { + bannedWords = await profaneWordsResponse.json(); + this.log.info( + `Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`, + ); + } catch (error) { + this.log.warn(`Failed to parse profane words JSON, using empty list`); + } + } else { + this.log.warn( + `Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`, + ); + } + this.privilegeChecker = new PrivilegeCheckerImpl( result.data, base64url.decode, + bannedWords, ); this.log.info(`Privilege checker loaded successfully`); } catch (error) { - this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error); + this.log.error(`Failed to load privilege checker:`, error); throw error; } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 931f41369..a5bf11bed 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -67,6 +67,8 @@ export async function startWorker() { const privilegeRefresher = new PrivilegeRefresher( config.jwtIssuer() + "/cosmetics.json", + config.jwtIssuer() + "/profane_words_game_server", + config.apiKey(), log, ); privilegeRefresher.start(); @@ -436,6 +438,11 @@ export async function startWorker() { } } + // Censor profane usernames server-side (don't reject, just rename) + const censoredUsername = privilegeRefresher + .get() + .censorUsername(clientMsg.username); + // Create client and add to game const client = new Client( generateID(), @@ -444,6 +451,7 @@ export async function startWorker() { roles, flares, ip, + censoredUsername, clientMsg.username, ws, cosmeticResult.cosmetics, diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 72e77daa2..4c5253d72 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -1,30 +1,3 @@ -// Mocking the obscenity library to control its behavior in tests. -vi.mock("obscenity", () => { - return { - RegExpMatcher: class { - private dummy: string[] = ["foo", "bar", "leet", "code"]; - constructor(_opts: any) {} - hasMatch(input: string): boolean { - const lower = input.toLowerCase(); - const decoded = lower - .replace(/4/g, "a") - .replace(/3/g, "e") - .replace(/1/g, "i") - .replace(/0/g, "o") - .replace(/5/g, "s") - .replace(/7/g, "t"); - return this.dummy.some((token) => decoded.includes(token)); - } - }, - collapseDuplicatesTransformer: () => ({}), - englishRecommendedTransformers: {}, - englishDataset: { build: () => ({}) }, - resolveConfusablesTransformer: () => ({}), - resolveLeetSpeakTransformer: () => ({}), - skipNonAlphabeticTransformer: () => ({}), - }; -}); - // Mocks the output of translation functions to return predictable values. vi.mock("../src/client/Utils", () => ({ translateText: (key: string, vars?: any) => @@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({ })); import { - fixProfaneUsername, - isProfaneUsername, MAX_USERNAME_LENGTH, validateUsername, } from "../src/core/validations/username"; describe("username.ts functions", () => { - const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", - ]; - - describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => { - test.each([ - { username: "l33t", profane: true }, // decodes to "leet" - { username: "L33T", profane: true }, - { username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code" - { username: "L33TC0DE", profane: true }, - { username: "foo123", profane: true }, // contains "foo" - { username: "b4r", profane: true }, // decodes to "bar" - { username: "safeName", profane: false }, - { username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list - ])('isProfaneUsername("%s") → %s', ({ username, profane }) => { - expect(isProfaneUsername(username)).toBe(profane); - }); - - test.each([ - { username: "safeName" }, - { username: "l33t" }, - { username: "b4rUser" }, - ])('fixProfaneUsername("%s") behavior', ({ username }) => { - const profane = isProfaneUsername(username); - const fixed = fixProfaneUsername(username); - if (!profane) { - expect(fixed).toBe(username); - } else { - // When profane: result should be one of shadowNames - expect(shadowNames).toContain(fixed); - } - }); - }); - describe("validateUsername", () => { test("rejects non-string", () => { // @ts-expect-error: Testing non-string input to validateUsername on purpose diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts new file mode 100644 index 000000000..e3acc62b3 --- /dev/null +++ b/tests/Privilege.test.ts @@ -0,0 +1,147 @@ +import { + createMatcher, + PrivilegeCheckerImpl, + shadowNames, +} from "../src/server/Privilege"; + +const bannedWords = [ + "hitler", + "adolf", + "nazi", + "jew", + "auschwitz", + "whitepower", + "heil", + "chair", // Test word to verify custom banned words work +]; + +const matcher = createMatcher(bannedWords); + +// Create a minimal PrivilegeCheckerImpl for testing censorUsername +const mockCosmetics = { patterns: {}, colorPalettes: {} }; +const mockDecoder = () => new Uint8Array(); +const checker = new PrivilegeCheckerImpl( + mockCosmetics, + mockDecoder, + bannedWords, +); +const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []); + +describe("UsernameCensor", () => { + describe("isProfane (via matcher.hasMatch)", () => { + test("detects exact banned words", () => { + expect(matcher.hasMatch("hitler")).toBe(true); + expect(matcher.hasMatch("nazi")).toBe(true); + expect(matcher.hasMatch("auschwitz")).toBe(true); + }); + + test("detects custom banned words like 'chair'", () => { + expect(matcher.hasMatch("chair")).toBe(true); + expect(matcher.hasMatch("Chair")).toBe(true); + expect(matcher.hasMatch("CHAIR")).toBe(true); + expect(matcher.hasMatch("MyChairName")).toBe(true); + }); + + test("detects banned words case-insensitively", () => { + expect(matcher.hasMatch("Hitler")).toBe(true); + expect(matcher.hasMatch("NAZI")).toBe(true); + expect(matcher.hasMatch("Adolf")).toBe(true); + }); + + test("detects banned words with leet speak", () => { + expect(matcher.hasMatch("h1tl3r")).toBe(true); + expect(matcher.hasMatch("4d0lf")).toBe(true); + expect(matcher.hasMatch("n4z1")).toBe(true); + }); + + test("detects banned words with duplicated characters", () => { + expect(matcher.hasMatch("hiiitler")).toBe(true); + expect(matcher.hasMatch("naazzii")).toBe(true); + }); + + test("detects banned words with accented characters", () => { + expect(matcher.hasMatch("Adölf")).toBe(true); + }); + + test("detects banned words as substrings", () => { + expect(matcher.hasMatch("xhitlerx")).toBe(true); + expect(matcher.hasMatch("IloveNazi")).toBe(true); + }); + + test("allows clean usernames", () => { + expect(matcher.hasMatch("CoolPlayer")).toBe(false); + expect(matcher.hasMatch("GameMaster")).toBe(false); + expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false); + }); + }); + + describe("censorUsername", () => { + test("returns clean usernames unchanged", () => { + expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("GameMaster")).toBe("GameMaster"); + }); + + test("replaces profane usernames with a shadow name", () => { + const result = checker.censorUsername("hitler"); + expect(shadowNames).toContain(result); + }); + + test("replaces leet speak profane usernames with a shadow name", () => { + const result = checker.censorUsername("h1tl3r"); + expect(shadowNames).toContain(result); + }); + + test("preserves clean clan tag when username is profane", () => { + const result = checker.censorUsername("[COOL]hitler"); + expect(result).toMatch(/^\[COOL\] /); + const nameAfterTag = result.replace("[COOL] ", ""); + expect(shadowNames).toContain(nameAfterTag); + }); + + test("removes profane clan tag but keeps clean username", () => { + expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag with leet speak profanity", () => { + expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag with uppercased banned word", () => { + expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag containing banned word substring", () => { + expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes profane clan tag and censors profane username", () => { + const result = checker.censorUsername("[NAZI]hitler"); + // No clan tag prefix, just a shadow name + expect(shadowNames).toContain(result); + }); + + test("removes leet speak profane clan tag and censors leet speak username", () => { + const result = checker.censorUsername("[N4Z1]h1tl3r"); + // No clan tag prefix, just a shadow name + expect(shadowNames).toContain(result); + }); + + test("returns deterministic shadow name for same input", () => { + const a = checker.censorUsername("hitler"); + const b = checker.censorUsername("hitler"); + expect(a).toBe(b); + }); + + test("handles username with no clan tag", () => { + expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer"); + }); + + test("empty banned words list still catches englishDataset profanity", () => { + // The emptyChecker still uses englishDataset, so common profanity is caught + expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); + // Verify a known english profanity gets censored even without custom banned words + const result = emptyChecker.censorUsername("fuck"); + expect(shadowNames).toContain(result); + }); + }); +}); From 79330af2b2cb8231abcdacbb22ab4745414ef927 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 9 Feb 2026 21:06:08 -0800 Subject: [PATCH 008/144] attack panel (#3114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relates #2260 ## Description: Move outgoing & incoming boat & land attacks to a new "AttacksDisplay" layer that sits on top of the ControlPanel. The idea is to break up EventsDisplay so it's easier to find information. It's also more mobile friendly. It still needs more styling, but this just a first pass. Screenshot 2026-02-09 at 4 44 38 PM Screenshot 2026-02-09 at 4 44 18 PM Screenshot 2026-02-09 at 4 43 33 PM Screenshot 2026-02-09 at 4 44 05 PM ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 5 +- resources/lang/en.json | 2 - src/client/graphics/GameRenderer.ts | 12 + src/client/graphics/layers/AttacksDisplay.ts | 450 +++++++++++++++++++ src/client/graphics/layers/ControlPanel.ts | 2 +- src/client/graphics/layers/EventsDisplay.ts | 295 +----------- 6 files changed, 470 insertions(+), 296 deletions(-) create mode 100644 src/client/graphics/layers/AttacksDisplay.ts diff --git a/index.html b/index.html index 7c7854bae..e9a96fd50 100644 --- a/index.html +++ b/index.html @@ -242,7 +242,10 @@
-
+
+
= new Set(); + private spriteDataURLCache: Map = new Map(); + @state() private _isVisible: boolean = false; + @state() private incomingAttacks: AttackUpdate[] = []; + @state() private outgoingAttacks: AttackUpdate[] = []; + @state() private outgoingLandAttacks: AttackUpdate[] = []; + @state() private outgoingBoats: UnitView[] = []; + @state() private incomingBoats: UnitView[] = []; + + createRenderRoot() { + return this; + } + + init() {} + + tick() { + this.active = true; + + if (!this._isVisible && !this.game.inSpawnPhase()) { + this._isVisible = true; + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + if (this._isVisible) { + this._isVisible = false; + } + return; + } + + // Track incoming boat unit IDs from UnitIncoming events + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const event of updates[ + GameUpdateType.UnitIncoming + ] as UnitIncomingUpdate[]) { + if ( + event.playerID === myPlayer.smallID() && + event.messageType === MessageType.NAVAL_INVASION_INBOUND + ) { + this.incomingBoatIDs.add(event.unitID); + } + } + } + + // Resolve incoming boats from tracked IDs, remove inactive ones + const resolvedIncomingBoats: UnitView[] = []; + for (const unitID of this.incomingBoatIDs) { + const unit = this.game.unit(unitID); + if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) { + resolvedIncomingBoats.push(unit); + } else { + this.incomingBoatIDs.delete(unitID); + } + } + this.incomingBoats = resolvedIncomingBoats; + + this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => { + const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type(); + return t !== PlayerType.Bot; + }); + + this.outgoingAttacks = myPlayer + .outgoingAttacks() + .filter((a) => a.targetID !== 0); + + this.outgoingLandAttacks = myPlayer + .outgoingAttacks() + .filter((a) => a.targetID === 0); + + this.outgoingBoats = myPlayer + .units() + .filter((u) => u.type() === UnitType.TransportShip); + + this.requestUpdate(); + } + + shouldTransform(): boolean { + return false; + } + + renderLayer(): void {} + + private renderButton(options: { + content: any; + onClick?: () => void; + className?: string; + disabled?: boolean; + translate?: boolean; + hidden?: boolean; + }) { + const { + content, + onClick, + className = "", + disabled = false, + translate = true, + hidden = false, + } = options; + + if (hidden) { + return html``; + } + + return html` + + `; + } + + private emitCancelAttackIntent(id: string) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + this.eventBus.emit(new CancelAttackIntentEvent(id)); + } + + private emitBoatCancelIntent(id: number) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + this.eventBus.emit(new CancelBoatIntentEvent(id)); + } + + private emitGoToPlayerEvent(attackerID: number) { + const attacker = this.game.playerBySmallID(attackerID) as PlayerView; + this.eventBus.emit(new GoToPlayerEvent(attacker)); + } + + private getBoatSpriteDataURL(unit: UnitView): string { + const owner = unit.owner(); + const key = `boat-${owner.id()}`; + const cached = this.spriteDataURLCache.get(key); + if (cached) return cached; + try { + const canvas = getColoredSprite(unit, this.game.config().theme()); + const dataURL = canvas.toDataURL(); + this.spriteDataURLCache.set(key, dataURL); + return dataURL; + } catch { + return ""; + } + } + + private async attackWarningOnClick(attack: AttackUpdate) { + const playerView = this.game.playerBySmallID(attack.attackerID); + if (playerView !== undefined) { + if (playerView instanceof PlayerView) { + const averagePosition = await playerView.attackAveragePosition( + attack.attackerID, + attack.id, + ); + + if (averagePosition === null) { + this.emitGoToPlayerEvent(attack.attackerID); + } else { + this.eventBus.emit( + new GoToPositionEvent(averagePosition.x, averagePosition.y), + ); + } + } + } else { + this.emitGoToPlayerEvent(attack.attackerID); + } + } + + private handleRetaliate(attack: AttackUpdate) { + const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView; + if (!attacker) return; + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + + const counterTroops = Math.min( + attack.troops, + this.uiState.attackRatio * myPlayer.troops(), + ); + this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops)); + } + + private renderIncomingAttacks() { + if (this.incomingAttacks.length === 0) return html``; + + return this.incomingAttacks.map( + (attack) => html` +
+ ${this.renderButton({ + content: html` + ${renderTroops(attack.troops)} + ${( + this.game.playerBySmallID(attack.attackerID) as PlayerView + )?.name()} + ${attack.retreating + ? `(${translateText("events_display.retreating")}...)` + : ""} `, + onClick: () => this.attackWarningOnClick(attack), + className: + "text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!attack.retreating + ? this.renderButton({ + content: html``, + onClick: () => this.handleRetaliate(attack), + className: + "ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded px-1.5 py-1 border border-red-700/50", + translate: false, + }) + : ""} +
+ `, + ); + } + + private renderOutgoingAttacks() { + if (this.outgoingAttacks.length === 0) return html``; + + return this.outgoingAttacks.map( + (attack) => html` +
+ ${this.renderButton({ + content: html` + ${renderTroops(attack.troops)} + ${( + this.game.playerBySmallID(attack.targetID) as PlayerView + )?.name()} `, + onClick: async () => this.attackWarningOnClick(attack), + className: + "text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!attack.retreating + ? this.renderButton({ + content: "âŒ", + onClick: () => this.emitCancelAttackIntent(attack.id), + className: "ml-auto text-left shrink-0", + disabled: attack.retreating, + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private renderOutgoingLandAttacks() { + if (this.outgoingLandAttacks.length === 0) return html``; + + return this.outgoingLandAttacks.map( + (landAttack) => html` +
+ ${this.renderButton({ + content: html` + ${renderTroops(landAttack.troops)} + ${translateText("help_modal.ui_wilderness")}`, + className: + "text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!landAttack.retreating + ? this.renderButton({ + content: "âŒ", + onClick: () => this.emitCancelAttackIntent(landAttack.id), + className: "ml-auto text-left shrink-0", + disabled: landAttack.retreating, + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private getBoatTargetName(boat: UnitView): string { + const target = boat.targetTile(); + if (target === undefined) return ""; + const ownerID = this.game.ownerID(target); + if (ownerID === 0) return ""; + const player = this.game.playerBySmallID(ownerID) as PlayerView; + return player?.name() ?? ""; + } + + private renderBoatIcon(boat: UnitView) { + const dataURL = this.getBoatSpriteDataURL(boat); + if (!dataURL) return html``; + return html``; + } + + private renderBoats() { + if (this.outgoingBoats.length === 0) return html``; + + return this.outgoingBoats.map( + (boat) => html` +
+ ${this.renderButton({ + content: html`${this.renderBoatIcon(boat)} + ${renderTroops(boat.troops())} + ${this.getBoatTargetName(boat)}`, + onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), + className: + "text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!boat.retreating() + ? this.renderButton({ + content: "âŒ", + onClick: () => this.emitBoatCancelIntent(boat.id()), + className: "ml-auto text-left shrink-0", + disabled: boat.retreating(), + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private renderIncomingBoats() { + if (this.incomingBoats.length === 0) return html``; + + return this.incomingBoats.map( + (boat) => html` +
+ ${this.renderButton({ + content: html`${this.renderBoatIcon(boat)} + ${renderTroops(boat.troops())} + ${boat.owner()?.name()}`, + onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), + className: + "text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} +
+ `, + ); + } + + render() { + if (!this.active || !this._isVisible) { + return html``; + } + + const hasAnything = + this.outgoingAttacks.length > 0 || + this.outgoingLandAttacks.length > 0 || + this.outgoingBoats.length > 0 || + this.incomingAttacks.length > 0 || + this.incomingBoats.length > 0; + + if (!hasAnything) { + return html``; + } + + return html` +
+ ${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()} + ${this.renderBoats()} ${this.renderIncomingAttacks()} + ${this.renderIncomingBoats()} +
+ `; + } +} diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index c45a31b00..c5f6c4a41 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -261,7 +261,7 @@ export class ControlPanel extends LitElement implements Layer { return html`
e.preventDefault()} > diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 3d0bad609..b39d3f8de 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -8,15 +8,12 @@ import { getMessageCategory, MessageCategory, MessageType, - PlayerType, Tick, - UnitType, } from "../../../core/game/Game"; import { AllianceExpiredUpdate, AllianceRequestReplyUpdate, AllianceRequestUpdate, - AttackUpdate, BrokeAllianceUpdate, DisplayChatMessageUpdate, DisplayMessageUpdate, @@ -26,22 +23,15 @@ import { UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; import { - CancelAttackIntentEvent, - CancelBoatIntentEvent, SendAllianceExtensionIntentEvent, SendAllianceReplyIntentEvent, - SendAttackIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { onlyImages } from "../../../core/Util"; -import { renderNumber, renderTroops } from "../../Utils"; -import { - GoToPlayerEvent, - GoToPositionEvent, - GoToUnitEvent, -} from "./Leaderboard"; +import { renderNumber } from "../../Utils"; +import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard"; import { getMessageTypeClasses, translateText } from "../../Utils"; import { UIState } from "../UIState"; @@ -84,10 +74,6 @@ export class EventsDisplay extends LitElement implements Layer { // allianceID -> last checked at tick private alliancesCheckedAt = new Map(); - @state() private incomingAttacks: AttackUpdate[] = []; - @state() private outgoingAttacks: AttackUpdate[] = []; - @state() private outgoingLandAttacks: AttackUpdate[] = []; - @state() private outgoingBoats: UnitView[] = []; @state() private _hidden: boolean = false; @state() private _isVisible: boolean = false; @state() private newEvents: number = 0; @@ -194,9 +180,6 @@ export class EventsDisplay extends LitElement implements Layer { constructor() { super(); this.events = []; - this.incomingAttacks = []; - this.outgoingAttacks = []; - this.outgoingBoats = []; } init() {} @@ -254,24 +237,6 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } - // Update attacks - this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => { - const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type(); - return t !== PlayerType.Bot; - }); - - this.outgoingAttacks = myPlayer - .outgoingAttacks() - .filter((a) => a.targetID !== 0); - - this.outgoingLandAttacks = myPlayer - .outgoingAttacks() - .filter((a) => a.targetID === 0); - - this.outgoingBoats = myPlayer - .units() - .filter((u) => u.type() === UnitType.TransportShip); - this.requestUpdate(); } @@ -664,28 +629,12 @@ export class EventsDisplay extends LitElement implements Layer { }); } - emitCancelAttackIntent(id: string) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - this.eventBus.emit(new CancelAttackIntentEvent(id)); - } - - emitBoatCancelIntent(id: number) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - this.eventBus.emit(new CancelBoatIntentEvent(id)); - } - emitGoToPlayerEvent(attackerID: number) { const attacker = this.game.playerBySmallID(attackerID) as PlayerView; if (!attacker) return; this.eventBus.emit(new GoToPlayerEvent(attacker)); } - emitGoToPositionEvent(x: number, y: number) { - this.eventBus.emit(new GoToPositionEvent(x, y)); - } - emitGoToUnitEvent(unit: UnitView) { this.eventBus.emit(new GoToUnitEvent(unit)); } @@ -753,196 +702,6 @@ export class EventsDisplay extends LitElement implements Layer { : event.description; } - private async attackWarningOnClick(attack: AttackUpdate) { - const playerView = this.game.playerBySmallID(attack.attackerID); - if (playerView !== undefined) { - if (playerView instanceof PlayerView) { - const averagePosition = await playerView.attackAveragePosition( - attack.attackerID, - attack.id, - ); - - if (averagePosition === null) { - this.emitGoToPlayerEvent(attack.attackerID); - } else { - this.emitGoToPositionEvent(averagePosition.x, averagePosition.y); - } - } - } else { - this.emitGoToPlayerEvent(attack.attackerID); - } - } - - private handleRetaliate(attack: AttackUpdate) { - const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView; - if (!attacker) return; - - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - - const counterTroops = Math.min( - attack.troops, - this.uiState.attackRatio * myPlayer.troops(), - ); - this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops)); - } - - private renderIncomingAttacks() { - return html` - ${this.incomingAttacks.length > 0 - ? html` -
- ${this.incomingAttacks.map( - (attack) => html` -
- ${this.renderButton({ - content: html` - ${renderTroops(attack.troops)} - ${( - this.game.playerBySmallID( - attack.attackerID, - ) as PlayerView - )?.name()} - ${attack.retreating - ? `(${translateText("events_display.retreating")}...)` - : ""} - `, - onClick: () => this.attackWarningOnClick(attack), - className: "text-left text-red-400", - translate: false, - })} - ${!attack.retreating - ? this.renderButton({ - content: translateText("events_display.retaliate"), - onClick: () => this.handleRetaliate(attack), - className: - "inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700", - translate: true, - }) - : ""} -
- `, - )} -
- ` - : ""} - `; - } - - private renderOutgoingAttacks() { - return html` - ${this.outgoingAttacks.length > 0 - ? html` -
- ${this.outgoingAttacks.map( - (attack) => html` -
- ${this.renderButton({ - content: html` - ${renderTroops(attack.troops)} - ${( - this.game.playerBySmallID( - attack.targetID, - ) as PlayerView - )?.name()} - `, - onClick: async () => this.attackWarningOnClick(attack), - className: "text-left text-blue-400", - translate: false, - })} - ${!attack.retreating - ? this.renderButton({ - content: "âŒ", - onClick: () => this.emitCancelAttackIntent(attack.id), - className: "text-left shrink-0", - disabled: attack.retreating, - }) - : html`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - - private renderOutgoingLandAttacks() { - return html` - ${this.outgoingLandAttacks.length > 0 - ? html` -
- ${this.outgoingLandAttacks.map( - (landAttack) => html` -
- ${this.renderButton({ - content: html`${renderTroops(landAttack.troops)} - ${translateText("help_modal.ui_wilderness")}`, - className: "text-left text-gray-400", - translate: false, - })} - ${!landAttack.retreating - ? this.renderButton({ - content: "âŒ", - onClick: () => - this.emitCancelAttackIntent(landAttack.id), - className: "text-left shrink-0", - disabled: landAttack.retreating, - }) - : html`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - - private renderBoats() { - return html` - ${this.outgoingBoats.length > 0 - ? html` -
- ${this.outgoingBoats.map( - (boat) => html` -
- ${this.renderButton({ - content: html`${translateText("events_display.boat")}: - ${renderTroops(boat.troops())}`, - onClick: () => this.emitGoToUnitEvent(boat), - className: "text-left text-blue-400", - translate: false, - })} - ${!boat.retreating() - ? this.renderButton({ - content: "âŒ", - onClick: () => this.emitBoatCancelIntent(boat.id()), - className: "text-left shrink-0", - disabled: boat.retreating(), - }) - : html`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - private renderBetrayalDebuffTimer() { const myPlayer = this.game.myPlayer(); if (!myPlayer || !myPlayer.isTraitor()) { @@ -1161,17 +920,6 @@ export class EventsDisplay extends LitElement implements Layer { `, )} - - ${this.incomingAttacks.length > 0 - ? html` - - - ${this.renderIncomingAttacks()} - - - ` - : ""} - ${(() => { const myPlayer = this.game.myPlayer(); @@ -1190,45 +938,8 @@ export class EventsDisplay extends LitElement implements Layer { ` : ""} - - ${this.outgoingAttacks.length > 0 - ? html` - - - ${this.renderOutgoingAttacks()} - - - ` - : ""} - - - ${this.outgoingLandAttacks.length > 0 - ? html` - - - ${this.renderOutgoingLandAttacks()} - - - ` - : ""} - - - ${this.outgoingBoats.length > 0 - ? html` - - - ${this.renderBoats()} - - - ` - : ""} - - + ${filteredEvents.length === 0 && - this.incomingAttacks.length === 0 && - this.outgoingAttacks.length === 0 && - this.outgoingLandAttacks.length === 0 && - this.outgoingBoats.length === 0 && !(() => { const myPlayer = this.game.myPlayer(); return ( From fce8b0cd1d7ba7a7a7f5648aeb1820837232a130 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:08:51 +0100 Subject: [PATCH 009/144] Help youtube video loads without even having the help modal open (#3169) ## Description: Title ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/HelpModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 5375bd4da..fde1e6545 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -153,7 +153,7 @@ export class HelpModal extends BaseModal {