From bc479af5c956a1277587e7be8dfbe54dc6dd4e4d Mon Sep 17 00:00:00 2001 From: Simon Schaarschmidt <112267398+xTonai@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:30:08 +0100 Subject: [PATCH 01/10] Fix: Extended spawn immunity in 1v1s (#3010) (#3028) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #3010 ## Description: Extended the spawn immunity in 1v1s from 5 to 30 seconds, to prevent spawn killing. ## 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: @xtonai Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 690227305..5bbc11b00 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -171,7 +171,7 @@ export class MapPlaylist { disableNations: true, gameMode: GameMode.FFA, bots: 100, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: 30 * 10, disabledUnits: [], } satisfies GameConfig; } From 7942990037309f310eee160d2f45b5cf0d22c40b Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:29:52 +0100 Subject: [PATCH 02/10] =?UTF-8?q?Crowded=20modifier=20=F0=9F=98=84=20(#302?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: To increase variety a bit more I present: The "crowded" public game modifier :) It basically simulates a crazy youtuber lobby. Cramp a lot of players on a small map 😄 I think its fun, exciting and you actually need skill to manage the chaos. 5% of public games get this modifier, but because we remove the modifier for big maps its more like 2.5% (should be something special) | Screenshot 2026-01-25 200427 | Screenshot 2026-01-25 200554 | Screenshot 2026-01-25 200521 | |---|---|---| ## 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 --- resources/lang/en.json | 2 ++ src/client/PublicLobby.ts | 3 ++ src/core/Schemas.ts | 1 + src/core/game/Game.ts | 1 + src/server/MapPlaylist.ts | 56 +++++++++++++++++++++++++++++----- tests/util/TestServerConfig.ts | 2 +- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a9663b2b0..5f76bd3b2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -166,6 +166,7 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Compact Map", + "crowded": "Crowded", "max_timer": "Game length (minutes)", "max_timer_placeholder": "Mins", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", @@ -421,6 +422,7 @@ "public_game_modifier": { "random_spawn": "Random Spawn", "compact_map": "Compact Map", + "crowded": "Crowded", "starting_gold": "5M Starting Gold" }, "select_lang": { diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 4c895ab8f..e7610672e 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,9 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.isCrowded) { + labels.push(translateText("public_game_modifier.crowded")); + } if (publicGameModifiers.startingGold) { labels.push(translateText("public_game_modifier.starting_gold")); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index d225857c5..9255156c1 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -190,6 +190,7 @@ export const GameConfigSchema = z.object({ .object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), + isCrowded: z.boolean(), startingGold: z.number().int().min(0).optional(), }) .optional(), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1c56d5d46..7e613e0c5 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -211,6 +211,7 @@ export enum GameMapSize { export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; + isCrowded: boolean; startingGold?: number; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 5bbc11b00..b54aa54ff 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -97,7 +97,7 @@ export class MapPlaylist { const modifiers = this.getRandomPublicGameModifiers(); const { startingGold } = modifiers; - let { isCompact, isRandomSpawn } = modifiers; + let { isCompact, isRandomSpawn, isCrowded } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -108,8 +108,8 @@ export class MapPlaylist { isRandomSpawn = false; } - // Maps with smallest player count < 50 don't support compact map in team games - // The smallest player count is the 3rd number in the player counts array + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games + // (not enough players after 75% player reduction for compact maps) if ( mode === GameMode.Team && !(await this.supportsCompactMapForTeams(map)) @@ -117,15 +117,34 @@ export class MapPlaylist { isCompact = false; } + // Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps), + // set player count to 125 (or 60 if compact map is also enabled) + let crowdedMaxPlayers: number | undefined; + if (isCrowded) { + crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); + if (crowdedMaxPlayers === undefined) { + isCrowded = false; + } else { + crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams); + } + } + // Create the default public game config (from your GameManager) return { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), + maxPlayers: + crowdedMaxPlayers ?? + (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, - publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, + publicGameModifiers: { + isCompact, + isRandomSpawn, + isCrowded, + startingGold, + }, startingGold, difficulty: playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, @@ -209,18 +228,31 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance + isCrowded: Math.random() < 0.05, // 5% chance startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance }; } + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games + // (not enough players after 75% player reduction for compact maps) private async supportsCompactMapForTeams(map: GameMapType): Promise { - // Maps with smallest player count < 50 don't support compact map in team games - // The smallest player count is the 3rd number in the player counts array const landTiles = await getMapLandTiles(map); const [, , smallest] = this.calculateMapPlayerCounts(landTiles); return smallest >= 50; } + private async getCrowdedMaxPlayers( + map: GameMapType, + isCompact: boolean, + ): Promise { + const landTiles = await getMapLandTiles(map); + const [firstPlayerCount] = this.calculateMapPlayerCounts(landTiles); + if (firstPlayerCount <= 60) { + return isCompact ? 60 : 125; + } + return undefined; + } + private async lobbyMaxPlayers( map: GameMapType, mode: GameMode, @@ -236,7 +268,15 @@ export class MapPlaylist { if (isCompactMap) { p = Math.max(3, Math.floor(p * 0.25)); } - if (numPlayerTeams === undefined) return p; + return this.adjustForTeams(p, numPlayerTeams); + } + + private adjustForTeams( + playerCount: number, + numPlayerTeams: TeamCountConfig | undefined, + ): number { + if (numPlayerTeams === undefined) return playerCount; + let p = playerCount; switch (numPlayerTeams) { case Duos: p -= p % 2; diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 6a879ccd5..94b625943 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -80,7 +80,7 @@ export class TestServerConfig implements ServerConfig { throw new Error("Method not implemented."); } getRandomPublicGameModifiers(): PublicGameModifiers { - return { isCompact: false, isRandomSpawn: false }; + return { isCompact: false, isRandomSpawn: false, isCrowded: false }; } async supportsCompactMapForTeams(): Promise { throw new Error("Method not implemented."); From 476fa373798ea5ffe129a9f85ccda90443bcb11d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:10:14 +0100 Subject: [PATCH 03/10] =?UTF-8?q?For=20v29.6:=20More=20team=20games=20?= =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=A4=9D=E2=80=8D=F0=9F=A7=91=20(#3051?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Use ffa:teams ratio of 3:2 ## 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/server/MapPlaylist.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index b54aa54ff..1a74ecfd3 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -332,6 +332,8 @@ export class MapPlaylist { const ffa1: GameMapType[] = rand.shuffleArray([...maps]); const team1: GameMapType[] = rand.shuffleArray([...maps]); const ffa2: GameMapType[] = rand.shuffleArray([...maps]); + const team2: GameMapType[] = rand.shuffleArray([...maps]); + const ffa3: GameMapType[] = rand.shuffleArray([...maps]); this.mapsPlaylist = []; for (let i = 0; i < maps.length; i++) { @@ -346,6 +348,14 @@ export class MapPlaylist { if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) { return false; } + if (!this.disableTeams) { + if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) { + return false; + } + } + if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) { + return false; + } } return true; } From 4176944639f239489efb5a198694a63088e1b497 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:11:03 +0100 Subject: [PATCH 04/10] =?UTF-8?q?Changelog=20cache=20busting=20?= =?UTF-8?q?=F0=9F=94=A7=20(#3047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Added cache busting for `changelog.md` ## 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/NewsModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 4c67f10c8..fe0d622f3 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -65,7 +65,7 @@ export class NewsModal extends BaseModal { protected onOpen(): void { if (!this.initialized) { this.initialized = true; - fetch(changelog) + fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`) .then((response) => (response.ok ? response.text() : "Failed to load")) .then((markdown) => markdown From 1dac7bd2e82ce935a2013f2adbc31817acd02499 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:00:18 +0100 Subject: [PATCH 05/10] =?UTF-8?q?Confirm=20alliance=20break=20=E2=9A=A0?= =?UTF-8?q?=EF=B8=8F=20(#3033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: People accidentally clicked the betray button because it's at the same position as the ally button. So let's add a small confirmation step. https://github.com/user-attachments/assets/754f2d33-7419-42fc-a732-197c3107236e ## 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 --- resources/images/CheckmarkIconWhite.svg | 3 ++ src/client/graphics/layers/RadialMenu.ts | 5 ++- .../graphics/layers/RadialMenuElements.ts | 21 ++++++++++ ...nts.spec.ts => radialMenuElements.test.ts} | 42 ++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 resources/images/CheckmarkIconWhite.svg rename tests/{radialMenuElements.spec.ts => radialMenuElements.test.ts} (66%) diff --git a/resources/images/CheckmarkIconWhite.svg b/resources/images/CheckmarkIconWhite.svg new file mode 100644 index 000000000..ef1abfe12 --- /dev/null +++ b/resources/images/CheckmarkIconWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 090df03c1..ab4d0198f 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -907,9 +907,12 @@ export class RadialMenu implements Layer { .select(".center-button-hitbox") .style("cursor", enabled ? "pointer" : "not-allowed"); + // Use default color for back button, otherwise use the current center button color + const buttonColor = + state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor; centerButton .select(".center-button-visible") - .attr("fill", enabled ? this.centerButtonColor : "#999999"); + .attr("fill", enabled ? buttonColor : "#999999"); centerButton .select(".center-button-icon") diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 0cf28cebb..4ee7e5924 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -17,6 +17,7 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url"; import boatIcon from "/images/BoatIconWhite.svg?url"; import buildIcon from "/images/BuildIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; +import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; @@ -218,6 +219,15 @@ const allyBreakElement: MenuElement = { !!params.playerActions?.interaction?.canBreakAlliance, color: COLORS.breakAlly, icon: traitorIcon, + subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement], +}; + +const allyBreakConfirmElement: MenuElement = { + id: "ally_break_confirm", + name: "confirm", + disabled: () => false, + color: COLORS.breakAlly, + icon: checkmarkIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleBreakAlliance( params.myPlayer, @@ -227,6 +237,17 @@ const allyBreakElement: MenuElement = { }, }; +const allyBreakCancelElement: MenuElement = { + id: "ally_break_cancel", + name: "cancel", + disabled: () => false, + color: COLORS.info, + icon: xIcon, + action: (params: MenuElementParams) => { + params.closeMenu(); + }, +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", diff --git a/tests/radialMenuElements.spec.ts b/tests/radialMenuElements.test.ts similarity index 66% rename from tests/radialMenuElements.spec.ts rename to tests/radialMenuElements.test.ts index c24ea9227..a95e6be05 100644 --- a/tests/radialMenuElements.spec.ts +++ b/tests/radialMenuElements.test.ts @@ -75,6 +75,12 @@ const makeParams = (opts?: Partial): MenuElementParams => { const findAllyBreak = (items: any[]) => items.find((i) => i && i.id === "ally_break"); +const findAllyBreakConfirm = (items: any[]) => + items.find((i) => i && i.id === "ally_break_confirm"); + +const findAllyBreakCancel = (items: any[]) => + items.find((i) => i && i.id === "ally_break_cancel"); + describe("RadialMenuElements ally break", () => { test("shows break option with correct color when allied", () => { const params = makeParams(); @@ -85,12 +91,29 @@ describe("RadialMenuElements ally break", () => { expect(ally.color).toBe(COLORS.breakAlly); }); - test("action calls handleBreakAlliance and closes menu", () => { + test("break option opens confirmation submenu", () => { const params = makeParams(); const items = rootMenuElement.subMenu!(params); const ally = findAllyBreak(items)!; - ally.action!(params); + expect(ally.subMenu).toBeDefined(); + const subMenuItems = ally.subMenu!(params); + expect(subMenuItems.length).toBe(2); + + const confirmItem = findAllyBreakConfirm(subMenuItems); + const cancelItem = findAllyBreakCancel(subMenuItems); + expect(confirmItem).toBeTruthy(); + expect(cancelItem).toBeTruthy(); + }); + + test("confirm action calls handleBreakAlliance and closes menu", () => { + const params = makeParams(); + const items = rootMenuElement.subMenu!(params); + const ally = findAllyBreak(items)!; + const subMenuItems = ally.subMenu!(params); + const confirmItem = findAllyBreakConfirm(subMenuItems)!; + + confirmItem.action!(params); expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith( params.myPlayer, @@ -98,4 +121,19 @@ describe("RadialMenuElements ally break", () => { ); expect(params.closeMenu).toHaveBeenCalled(); }); + + test("cancel action closes menu without breaking alliance", () => { + const params = makeParams(); + const items = rootMenuElement.subMenu!(params); + const ally = findAllyBreak(items)!; + const subMenuItems = ally.subMenu!(params); + const cancelItem = findAllyBreakCancel(subMenuItems)!; + + cancelItem.action!(params); + + expect( + params.playerActionHandler.handleBreakAlliance, + ).not.toHaveBeenCalled(); + expect(params.closeMenu).toHaveBeenCalled(); + }); }); From da4b8aa5e1254c96e3e5e052950be48601a8f768 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:15:35 +0000 Subject: [PATCH 06/10] Spectate catchup (#3012) ## Description: https://github.com/user-attachments/assets/dc118d5f-3b7f-4ccb-8579-5b0d8c73fe8e Catchup mechanic for live games and changes replays to have a backlog for more "max" speed ## 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/client/LocalServer.ts | 17 +++++++++++++++-- src/core/GameRunner.ts | 14 ++++++++++---- src/core/worker/Worker.worker.ts | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 2514dc695..75121b38a 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,7 +20,13 @@ import { import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; +import { + defaultReplaySpeedMultiplier, + ReplaySpeedMultiplier, +} from "./utilities/ReplaySpeedMultiplier"; + +// build a small backlog so MAX can catch up. +const MAX_REPLAY_BACKLOG_TURNS = 60; export class LocalServer { // All turns from the game record on replay. @@ -64,9 +70,16 @@ export class LocalServer { const turnIntervalMs = this.lobbyConfig.serverConfig.turnIntervalMs() * this.replaySpeedMultiplier; + const backlog = Math.max(0, this.turns.length - this.turnsExecuted); + const allowReplayBacklog = + this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest && + this.lobbyConfig.gameRecord !== undefined; + const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0; + const canQueueNextTurn = + backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog); if ( - this.turnsExecuted === this.turns.length && + canQueueNextTurn && Date.now() > this.turnStartTime + turnIntervalMs ) { this.turnStartTime = Date.now(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 5e45612ea..0f93a94f6 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -112,12 +112,12 @@ export class GameRunner { this.turns.push(turn); } - public executeNextTick() { + public executeNextTick(): boolean { if (this.isExecuting) { - return; + return false; } if (this.currTurn >= this.turns.length) { - return; + return false; } this.isExecuting = true; @@ -144,7 +144,8 @@ export class GameRunner { } else { console.error("Game tick error:", error); } - return; + this.isExecuting = false; + return false; } if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) { @@ -177,6 +178,11 @@ export class GameRunner { tickExecutionDuration: tickExecutionDuration, }); this.isExecuting = false; + return true; + } + + public pendingTurns(): number { + return Math.max(0, this.turns.length - this.currTurn); } public playerActions( diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index a60e63e4b..31fd3f136 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -16,6 +16,7 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +const MAX_TICKS_PER_HEARTBEAT = 4; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { - case "heartbeat": - (await gameRunner)?.executeNextTick(); + case "heartbeat": { + const gr = await gameRunner; + if (!gr) { + break; + } + const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT); + for (let i = 0; i < ticksToRun; i++) { + if (!gr.executeNextTick()) { + break; + } + } break; + } case "init": try { gameRunner = createGameRunner( From 6cca96b545fd17664134dbcff944c4b056e249a2 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:49:06 +0900 Subject: [PATCH 07/10] Anonymized/hidden names on lobby preview (#2965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2962 ## Description: Anonymize lobby preview player names when “Hidden names” is enabled, using the same deterministic mapping as in-game. スクリーンショット 2026-01-20 21 13 19 スクリーンショット 2026-01-20 21 13 27 ## 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: aotumuri Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/client/HostLobbyModal.ts | 1 + src/client/JoinPrivateLobbyModal.ts | 9 +++- src/client/components/LobbyPlayerView.ts | 52 +++++++++++++++++------- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e5bb92f11..5355f9f33 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -653,6 +653,7 @@ export class HostLobbyModal extends BaseModal { .gameMode=${this.gameMode} .clients=${this.clients} .lobbyCreatorClientID=${this.lobbyCreatorClientID} + .currentClientID=${this.lobbyCreatorClientID} .teamCount=${this.teamCount} .nationCount=${this.nationCount} .disableNations=${this.disableNations} diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index c04b8fa93..9287b1730 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -28,6 +28,7 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; @state() private currentLobbyId: string = ""; + @state() private currentClientID: string = ""; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; @@ -101,6 +102,7 @@ export class JoinPrivateLobbyModal extends BaseModal { .gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA} .clients=${this.players} .lobbyCreatorClientID=${this.lobbyCreatorClientID} + .currentClientID=${this.currentClientID} .teamCount=${this.gameConfig?.playerTeams ?? 2} .nationCount=${this.nationCount} .disableNations=${this.gameConfig?.disableNations ?? false} @@ -290,6 +292,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.currentClientID = ""; this.nationCount = 0; this.leaveLobbyOnClose = true; @@ -418,6 +421,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.showMessage(translateText("private_lobby.joined_waiting")); this.message = ""; this.hasJoined = true; + this.currentClientID = generateID(); // If the modal closes as part of joining the game, do not leave the lobby this.leaveLobbyOnClose = false; @@ -426,7 +430,7 @@ export class JoinPrivateLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -477,12 +481,13 @@ export class JoinPrivateLobbyModal extends BaseModal { return "version_mismatch"; } + this.currentClientID = generateID(); this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, gameRecord: parsed.data, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 4c72fc21d..cc43e6930 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -15,7 +15,9 @@ import { } from "../../core/game/Game"; import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; +import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; +import { createRandomName } from "../../core/Util"; import { translateText } from "../Utils"; export interface TeamPreviewData { @@ -30,6 +32,7 @@ export class LobbyTeamView extends LitElement { @state() private teamPreview: TeamPreviewData[] = []; @state() private teamMaxSize: number = 0; @property({ type: String }) lobbyCreatorClientID: string = ""; + @property({ type: String }) currentClientID: string = ""; @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; @@ -38,6 +41,7 @@ export class LobbyTeamView extends LitElement { private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; + private userSettings: UserSettings = new UserSettings(); willUpdate(changedProperties: Map) { // Recompute team preview when relevant properties change @@ -108,12 +112,14 @@ export class LobbyTeamView extends LitElement { ${repeat( this.clients, (c) => c.clientID ?? c.username, - (client) => - html`
{ + const displayName = this.displayUsername(client); + return html`
- ${client.username} -
`, + ${displayName} +
`; + }, )}
@@ -151,9 +157,10 @@ export class LobbyTeamView extends LitElement { return html`${repeat( this.clients, (c) => c.clientID ?? c.username, - (client) => - html` - ${client.username} + (client) => { + const displayName = this.displayUsername(client); + return html` + ${displayName} ${client.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) this.onKickPlayer?.(client.clientID)} aria-label=${translateText("host_modal.remove_player", { - username: client.username, + username: displayName, })} > × ` : html``} - `, + `; + }, )} `; } @@ -207,11 +215,12 @@ export class LobbyTeamView extends LitElement { : repeat( preview.players, (p) => p.clientID ?? p.username, - (p) => - html`
{ + const displayName = this.displayUsername(p); + return html`
- ${p.username} + ${displayName} ${p.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) × ` : html``} -
`, +
`; + }, )}
@@ -353,4 +363,18 @@ export class LobbyTeamView extends LitElement { } return getCompactMapNationCount(this.nationCount, this.isCompactMap); } + + private displayUsername(client: ClientInfo): string { + if (!this.userSettings.anonymousNames()) { + return client.username; + } + + if (this.currentClientID && client.clientID === this.currentClientID) { + return client.username; + } + + return ( + createRandomName(client.username, PlayerType.Human) ?? client.username + ); + } } From db745dcf4a9fe765cdb32704a625fb14b5dc1b0a Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Wed, 28 Jan 2026 00:51:11 +0100 Subject: [PATCH 08/10] Add a troubleshooting panel (#2951) ## Description: Add a troobleshooting panel with the most common problems, and a button to copy the infos for better sharing image 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 --- index.html | 5 + resources/lang/en.json | 33 ++++ src/client/HelpModal.ts | 64 +++++++- src/client/Main.ts | 1 + src/client/TroubleshootingModal.ts | 254 +++++++++++++++++++++++++++++ src/client/utilities/Diagnostic.ts | 141 ++++++++++++++++ 6 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 src/client/TroubleshootingModal.ts create mode 100644 src/client/utilities/Diagnostic.ts diff --git a/index.html b/index.html index cad490b1c..6c90f20e2 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,11 @@ inline class="hidden w-full h-full page-content" > + ${modalHeader({ - title: translateText("main.instructions"), + title: translateText("main.help"), onBack: this.close, ariaLabel: translateText("common.back"), })} @@ -120,6 +121,53 @@ export class HelpModal extends BaseModal { [&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent" > + +
+
+ + + + + +
+

+ ${translateText("main.troubleshooting")} +

+
+
+
+
+

+ ${translateText("help_modal.troubleshooting_desc")} +

+ +
+
@@ -1137,6 +1185,20 @@ export class HelpModal extends BaseModal { `; } + openTroubleshooting() { + const troubleshootingModal = document.querySelector( + "troubleshooting-modal", + ) as TroubleshootingModal; + if ( + !troubleshootingModal || + !(troubleshootingModal instanceof TroubleshootingModal) + ) { + console.warn("Troubleshooting modal element not found"); + return; + } + troubleshootingModal.open(); + } + protected onOpen(): void { this.keybinds = this.getKeybinds(); } diff --git a/src/client/Main.ts b/src/client/Main.ts index 8858b2f43..b89c7a3cd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -813,6 +813,7 @@ class Client { "game-top-bar", "help-modal", "user-setting", + "troubleshooting-modal", "territory-patterns-modal", "language-modal", "news-modal", diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts new file mode 100644 index 000000000..4a017a4fe --- /dev/null +++ b/src/client/TroubleshootingModal.ts @@ -0,0 +1,254 @@ +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "./Utils"; +import { BaseModal } from "./components/BaseModal"; +import "./components/baseComponents/Modal"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + collectGraphicsDiagnostics, + GraphicsDiagnostics, +} from "./utilities/Diagnostic"; +import infoIcon from "/images/InfoIcon.svg?url"; + +@customElement("troubleshooting-modal") +export class TroubleshootingModal extends BaseModal { + @property({ type: String }) markdown = "Loading..."; + + @property({ type: Object }) + diagnostics?: GraphicsDiagnostics; + + @property({ type: Boolean }) loading = true; + + private initialized: boolean = false; + + private async loadDiagnostics() { + const canvas = document.createElement("canvas"); + this.diagnostics = await collectGraphicsDiagnostics(canvas); + this.loading = false; + this.initialized = true; + } + + render() { + const content = html` +
+ ${modalHeader({ + titleContent: html`
+ + ${translateText("main.help")} + / ${translateText("troubleshooting.title")} + + +
`, + onBack: this.close, + ariaLabel: translateText("common.back"), + })} + ${this.loading + ? "" + : html` +
+ ${this.section( + "", + html`${this.infoTip( + translateText("troubleshooting.hardware_acceleration_tip"), + true, + )}`, + )} + ${this.section( + translateText("troubleshooting.environment"), + html` + ${this.row( + translateText("troubleshooting.browser"), + this.diagnostics!.browser.engine, + )} + ${this.row( + translateText("troubleshooting.platform"), + this.diagnostics!.browser.platform, + )} + ${this.row( + translateText("troubleshooting.os"), + this.diagnostics!.browser.os, + )} + ${this.row( + translateText("troubleshooting.device_pixel_ratio"), + this.diagnostics!.browser.dpr, + )} + ${this.infoTip( + translateText("troubleshooting.chromium_tip"), + )} + `, + )} + ${this.section( + translateText("troubleshooting.rendering"), + html` + ${this.row( + translateText("troubleshooting.renderer"), + this.describeRenderer(this.diagnostics!.rendering), + )} + ${this.row( + translateText("troubleshooting.max_texture_size"), + this.diagnostics!.rendering.maxTextureSize ?? + translateText("troubleshooting.unknown"), + )} + ${this.row( + translateText("troubleshooting.high_precision_shaders"), + this.diagnostics!.rendering.shaderHighp === true + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )}${this.row( + translateText("troubleshooting.gpu"), + !this.diagnostics!.rendering.gpu || + this.diagnostics!.rendering.gpu.unavailable + ? translateText("troubleshooting.unavailable") + : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`, + )} + ${this.infoTip(translateText("troubleshooting.gpu_tip"))} + `, + )} + ${this.section( + translateText("troubleshooting.power"), + html` + ${this.diagnostics!.power.unavailable + ? this.row( + translateText("troubleshooting.battery"), + translateText("troubleshooting.unavailable"), + ) + : html` + ${this.row( + translateText("troubleshooting.charging"), + this.diagnostics!.power.charging + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )} + ${this.row( + translateText("troubleshooting.battery_level"), + this.diagnostics!.power.level, + )} + `} + ${this.infoTip( + translateText("troubleshooting.power_saving_tip"), + )} + `, + )} +
+ `} +
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + private infoTip(text: string, warning?: boolean): unknown { + return html` +
+ + ${text} +
+ `; + } + + protected onOpen(): void { + if (!this.initialized) { + this.initialized = true; + this.loadDiagnostics(); + } + } + + private section(title: string, content: unknown) { + return html` +
+

+ ${title} +

+
${content}
+
+ `; + } + + private row(label: string, value: unknown) { + return html` +
+ ${label} + ${value} +
+ `; + } + + private async copyDiagnostics() { + if (!this.diagnostics) return; + const formatted = + "```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```"; + await navigator.clipboard.writeText(formatted); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: html`${translateText("troubleshooting.copied_to_clipboard")}`, + type: "info", + duration: 3000, + }, + }), + ); + } + + private describeRenderer(rendering: any): string { + if (rendering.gpu?.software) { + return translateText("troubleshooting.software_rendering"); + } + if (rendering.type === "Canvas2D") { + return translateText("troubleshooting.canvas_2d_no_gpu"); + } + return `${rendering.type}`; + } + + public close(): void { + this.unregisterEscapeHandler(); + + if (this.inline) { + this.style.pointerEvents = "none"; + if (window.showPage) { + window.showPage?.("page-help"); + } + } else { + this.modalEl?.close(); + } + } +} diff --git a/src/client/utilities/Diagnostic.ts b/src/client/utilities/Diagnostic.ts new file mode 100644 index 000000000..dc6553071 --- /dev/null +++ b/src/client/utilities/Diagnostic.ts @@ -0,0 +1,141 @@ +export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2"; + +export interface BrowserInfo { + engine: string; + platform: string; + os: string; + dpr: number; +} + +export interface GraphicsDiagnostics { + browser: BrowserInfo; + rendering: RenderingInfo; + power: PowerInfo; +} + +export interface GPUInfo { + vendor?: string; + renderer?: string; + software?: boolean; + unavailable?: boolean; +} + +export interface RenderingInfo { + type: RendererType; + antialias?: boolean; + maxTextureSize?: number; + shaderHighp?: boolean; + gpu?: GPUInfo; +} + +export interface PerformanceInfo { + fps: number; + worstFrameMs: number; + jankPercent: number; + throttlingLikely: boolean; +} + +export interface PowerInfo { + charging?: boolean; + level?: string; + unavailable?: boolean; +} + +export async function collectGraphicsDiagnostics( + canvas: HTMLCanvasElement, +): Promise { + /* ---------- Browser / OS ---------- */ + + const uaData = (navigator as any).userAgentData; + + const os = uaData?.platform ?? detectOS(navigator.userAgent); + + const browser: BrowserInfo = { + engine: uaData?.brands + ? uaData.brands.map((b: any) => b.brand).join(", ") + : navigator.userAgent, + platform: navigator.platform, + os, + dpr: window.devicePixelRatio, + }; + + /* ---------- Rendering ---------- */ + + let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null; + let type: RendererType = "Canvas2D"; + + gl = + canvas.getContext("webgl2", { antialias: true }) ?? + canvas.getContext("webgl", { antialias: true }); + + if (gl) { + const isWebGL2 = + typeof WebGL2RenderingContext !== "undefined" && + gl instanceof WebGL2RenderingContext; + type = isWebGL2 ? "WebGL2" : "WebGL1"; + } + + const rendering: RenderingInfo = { type }; + + if (gl) { + rendering.antialias = gl.getContextAttributes()?.antialias ?? false; + rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); + + const precision = gl.getShaderPrecisionFormat( + gl.FRAGMENT_SHADER, + gl.HIGH_FLOAT, + ); + rendering.shaderHighp = precision !== null && precision.precision > 0; + + const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); + + if (debugInfo) { + const renderer = gl.getParameter( + (debugInfo as any).UNMASKED_RENDERER_WEBGL, + ) as string; + + const vendor = gl.getParameter( + (debugInfo as any).UNMASKED_VENDOR_WEBGL, + ) as string; + rendering.gpu = { + vendor, + renderer, + software: /swiftshader|llvmpipe|software/i.test(renderer), + }; + } else { + rendering.gpu = { unavailable: true }; + } + } + + /* ---------- Power ---------- */ + + let power: PowerInfo = {}; + + if ("getBattery" in navigator) { + try { + const battery = await (navigator as any).getBattery(); + power = { + charging: battery.charging, + level: Math.round(battery.level * 100) + "%", + }; + } catch { + power = { unavailable: true }; + } + } else { + power = { unavailable: true }; + } + return { + browser, + rendering, + power, + }; +} + +function detectOS(ua: string): string { + if (/windows nt/i.test(ua)) return "Windows"; + if (/mac os x/i.test(ua)) return "macOS"; + if (/android/i.test(ua)) return "Android"; + if (/iphone|ipad|ipod/i.test(ua)) return "iOS"; + if (/linux/i.test(ua)) return "Linux"; + return "Unknown"; +} From 1314115d3f8ff989a5911d4e66c339c672ac28fa Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:52:48 +0900 Subject: [PATCH 09/10] Add map picker with Featured/All tabs (#3005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2996 ## Description: Replace map selection UI in src/client/SinglePlayerModal.ts and src/client/HostLobbyModal.ts with the picker (Featured/All tabs + random map card). Also, since the html was getting quite long, I extracted the shared parts into a separate component. スクリーンショット 2026-01-23 21 57 03 スクリーンショット 2026-01-23 21 57 12 I separated Map.ts because the display logic looked reusable in other places, but I’m also open to merging it back if that makes more sense. If the review prefers it integrated, I can combine them again. ## 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: aotumuri --- resources/lang/en.json | 3 + src/client/HelpModal.ts | 1 - src/client/HostLobbyModal.ts | 86 +------- src/client/SinglePlayerModal.ts | 91 +-------- .../components/{Maps.ts => map/MapDisplay.ts} | 6 +- src/client/components/map/MapPicker.ts | 183 ++++++++++++++++++ 6 files changed, 208 insertions(+), 162 deletions(-) rename src/client/components/{Maps.ts => map/MapDisplay.ts} (96%) create mode 100644 src/client/components/map/MapPicker.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index d9b6c0d9b..56c8941cd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -280,6 +280,8 @@ }, "map": { "map": "Map", + "featured": "Featured", + "all": "All", "world": "World", "giantworldmap": "Giant World Map", "europe": "Europe", @@ -330,6 +332,7 @@ "amazonriver": "Amazon River" }, "map_categories": { + "featured": "Featured", "continental": "Continental", "regional": "Regional", "fantasy": "Other", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index a5d150f3f..e696db76f 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; -import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { TroubleshootingModal } from "./TroubleshootingModal"; diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5355f9f33..eff76894b 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -12,7 +12,6 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; import { ClientInfo, @@ -28,7 +27,7 @@ import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyPlayerView"; -import "./components/Maps"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; @@ -38,7 +37,6 @@ import { renderToggleInputCardInput, } from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @@ -209,80 +207,14 @@ export class HostLobbyModal extends BaseModal { ${translateText("map.map")}
-
- - ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
-

- ${translateText(`map_categories.${categoryKey}`)} -

-
- ${maps.map((mapValue) => { - const mapKey = Object.entries(GameMapType).find( - ([, v]) => v === mapValue, - )?.[0]; - return html` -
this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
- `; - })} -
-
- `, - )} - -
-

- ${translateText("map_categories.special")} -

-
- -
-
-
+ + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + >
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index dece359e4..e423d7596 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -13,7 +13,6 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { TeamCountConfig } from "../core/Schemas"; @@ -24,7 +23,7 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/Maps"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; @@ -35,7 +34,6 @@ import { renderToggleInputCardInput, } from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { @@ -197,84 +195,15 @@ export class SinglePlayerModal extends BaseModal { -
- ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
-

- ${translateText(`map_categories.${categoryKey}`)} -

-
- ${maps.map((mapValue) => { - const mapKey = Object.keys(GameMapType).find( - (key) => - GameMapType[key as keyof typeof GameMapType] === - mapValue, - ); - return html` -
this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
- `; - })} -
-
- `, - )} - - -
-

- ${translateText("map_categories.special")} -

-
- -
-
-
+ + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + > diff --git a/src/client/components/Maps.ts b/src/client/components/map/MapDisplay.ts similarity index 96% rename from src/client/components/Maps.ts rename to src/client/components/map/MapDisplay.ts index e46e4691b..b7fc1364c 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/map/MapDisplay.ts @@ -1,8 +1,8 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { Difficulty, GameMapType } from "../../core/game/Game"; -import { terrainMapFileLoader } from "../TerrainMapFileLoader"; -import { translateText } from "../Utils"; +import { Difficulty, GameMapType } from "../../../core/game/Game"; +import { terrainMapFileLoader } from "../../TerrainMapFileLoader"; +import { translateText } from "../../Utils"; @customElement("map-display") export class MapDisplay extends LitElement { diff --git a/src/client/components/map/MapPicker.ts b/src/client/components/map/MapPicker.ts new file mode 100644 index 000000000..52607622e --- /dev/null +++ b/src/client/components/map/MapPicker.ts @@ -0,0 +1,183 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + Difficulty, + GameMapType, + mapCategories, +} from "../../../core/game/Game"; +import { translateText } from "../../Utils"; +import "./MapDisplay"; +import randomMap from "/images/RandomMap.webp?url"; + +const featuredMaps: GameMapType[] = [ + GameMapType.World, + GameMapType.Europe, + GameMapType.NorthAmerica, + GameMapType.SouthAmerica, + GameMapType.Asia, + GameMapType.Africa, + GameMapType.Japan, +]; + +@customElement("map-picker") +export class MapPicker extends LitElement { + @property({ type: String }) selectedMap: GameMapType = GameMapType.World; + @property({ type: Boolean }) useRandomMap = false; + @property({ type: Boolean }) showMedals = false; + @property({ type: Boolean }) randomMapDivider = false; + @property({ attribute: false }) mapWins: Map> = + new Map(); + @property({ attribute: false }) onSelectMap?: (map: GameMapType) => void; + @property({ attribute: false }) onSelectRandom?: () => void; + @state() private showAllMaps = false; + + createRenderRoot() { + return this; + } + + private handleMapSelection(mapValue: GameMapType) { + this.onSelectMap?.(mapValue); + } + + private handleSelectRandomMap = () => { + this.onSelectRandom?.(); + }; + + private getWins(mapValue: GameMapType): Set { + return this.mapWins?.get(mapValue) ?? new Set(); + } + + private renderMapCard(mapValue: GameMapType) { + const mapKey = Object.entries(GameMapType).find( + ([_, value]) => value === mapValue, + )?.[0]; + return html` +
this.handleMapSelection(mapValue)} + class="cursor-pointer transition-transform duration-200 active:scale-95" + > + +
+ `; + } + + private renderAllMaps() { + const mapCategoryEntries = Object.entries(mapCategories); + return html`
+ ${mapCategoryEntries.map( + ([categoryKey, maps]) => html` +
+

+ ${translateText(`map_categories.${categoryKey}`)} +

+
+ ${maps.map((mapValue) => this.renderMapCard(mapValue))} +
+
+ `, + )} +
`; + } + + private renderFeaturedMaps() { + let featuredMapList = featuredMaps; + if (!featuredMapList.includes(this.selectedMap)) { + featuredMapList = [this.selectedMap, ...featuredMaps]; + } + return html`
+

+ ${translateText("map_categories.featured")} +

+
+ ${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))} +
+
`; + } + + render() { + return html` +
+
+
+ + +
+
+ ${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()} +
+

+ ${translateText("map_categories.special")} +

+
+ +
+
+
+ `; + } +} From 0cc58a8f5a13f62a36f91eea2ed9d2c39419a442 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:54:01 +0900 Subject: [PATCH 10/10] fix: add validation for unknown flags in manifest.json (#3044) Resolves #3041 ## Description: - Add a test to ensure an error is thrown when manifest.json specifies a non-existent flag. - Fix the underlying issue by removing the invalid flag specification (see error below). ``` resources/maps/straitofgibraltar/manifest.json -> nations[0].flag "Rif" does not exist in resources/flags resources/maps/straitofgibraltar/manifest.json -> nations[5].flag "Shilha" does not exist in resources/flags resources/maps/straitofgibraltar/manifest.json -> nations[6].flag "Andalusia" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[0].flag "custom:Kingdom of the Two Sicilies" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[3].flag "custom:Tuscany" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[5].flag "custom:Modena" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[6].flag "custom:Parma" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[8].flag "custom:Kingdom of Sardinia" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[11].flag "custom:Ottoman Empire2" does not exist in resources/flags resources/maps/britannia/manifest.json -> nations[19].flag "gb-nir" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[0].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[1].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[2].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[4].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[5].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[6].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[7].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[8].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[9].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[10].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[11].flag "quebec" does not exist in resources/flags ``` ## 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: aotumuri --- map-generator/assets/maps/britannia/info.json | 3 +- map-generator/assets/maps/italia/info.json | 18 ++---- map-generator/assets/maps/montreal/info.json | 22 +++---- .../assets/maps/straitofgibraltar/info.json | 9 +-- resources/maps/britannia/manifest.json | 1 - resources/maps/italia/manifest.json | 6 -- resources/maps/montreal/manifest.json | 22 +++---- .../maps/straitofgibraltar/manifest.json | 3 - tests/MapManifestFlags.test.ts | 61 +++++++++++++++++++ 9 files changed, 93 insertions(+), 52 deletions(-) create mode 100644 tests/MapManifestFlags.test.ts diff --git a/map-generator/assets/maps/britannia/info.json b/map-generator/assets/maps/britannia/info.json index b72c54cbb..5d6ddd2b4 100644 --- a/map-generator/assets/maps/britannia/info.json +++ b/map-generator/assets/maps/britannia/info.json @@ -98,8 +98,7 @@ }, { "coordinates": [404, 1146], - "name": "Fermanagh", - "flag": "gb-nir" + "name": "Fermanagh" } ] } diff --git a/map-generator/assets/maps/italia/info.json b/map-generator/assets/maps/italia/info.json index 99465ddda..ea87c2146 100644 --- a/map-generator/assets/maps/italia/info.json +++ b/map-generator/assets/maps/italia/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1038, 993], - "name": "Kingdom of the Two Sicilies", - "flag": "custom:Kingdom of the Two Sicilies" + "name": "Kingdom of the Two Sicilies" }, { "coordinates": [370, 1137], @@ -18,8 +17,7 @@ }, { "coordinates": [625, 534], - "name": "Tuscany", - "flag": "custom:Tuscany" + "name": "Tuscany" }, { "coordinates": [595, 190], @@ -28,13 +26,11 @@ }, { "coordinates": [469, 386], - "name": "Modena", - "flag": "custom:Modena" + "name": "Modena" }, { "coordinates": [391, 254], - "name": "Parma", - "flag": "custom:Parma" + "name": "Parma" }, { "coordinates": [361, 68], @@ -43,8 +39,7 @@ }, { "coordinates": [278, 774], - "name": "Kingdom of Sardinia", - "flag": "custom:Kingdom of Sardinia" + "name": "Kingdom of Sardinia" }, { "coordinates": [29, 266], @@ -58,8 +53,7 @@ }, { "coordinates": [1238, 349], - "name": "Ottoman Empire", - "flag": "custom:Ottoman Empire2" + "name": "Ottoman Empire" } ] } diff --git a/map-generator/assets/maps/montreal/info.json b/map-generator/assets/maps/montreal/info.json index 0cb6c3e75..85d3756a6 100644 --- a/map-generator/assets/maps/montreal/info.json +++ b/map-generator/assets/maps/montreal/info.json @@ -3,17 +3,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -23,42 +23,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/map-generator/assets/maps/straitofgibraltar/info.json b/map-generator/assets/maps/straitofgibraltar/info.json index edc797671..1a826d7e7 100644 --- a/map-generator/assets/maps/straitofgibraltar/info.json +++ b/map-generator/assets/maps/straitofgibraltar/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1941, 1031], - "name": "Rif", - "flag": "Rif" + "name": "Rif" }, { "coordinates": [2733, 1190], @@ -28,13 +27,11 @@ }, { "coordinates": [1271, 1393], - "name": "Shilha", - "flag": "Shilha" + "name": "Shilha" }, { "coordinates": [1555, 258], - "name": "Andalusia", - "flag": "Andalusia" + "name": "Andalusia" } ] } diff --git a/resources/maps/britannia/manifest.json b/resources/maps/britannia/manifest.json index 7a0db2a33..415dac416 100644 --- a/resources/maps/britannia/manifest.json +++ b/resources/maps/britannia/manifest.json @@ -113,7 +113,6 @@ }, { "coordinates": [404, 1146], - "flag": "gb-nir", "name": "Fermanagh" } ] diff --git a/resources/maps/italia/manifest.json b/resources/maps/italia/manifest.json index d8242ba2b..ed721cb36 100644 --- a/resources/maps/italia/manifest.json +++ b/resources/maps/italia/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1038, 993], - "flag": "custom:Kingdom of the Two Sicilies", "name": "Kingdom of the Two Sicilies" }, { @@ -33,7 +32,6 @@ }, { "coordinates": [625, 534], - "flag": "custom:Tuscany", "name": "Tuscany" }, { @@ -43,12 +41,10 @@ }, { "coordinates": [469, 386], - "flag": "custom:Modena", "name": "Modena" }, { "coordinates": [391, 254], - "flag": "custom:Parma", "name": "Parma" }, { @@ -58,7 +54,6 @@ }, { "coordinates": [278, 774], - "flag": "custom:Kingdom of Sardinia", "name": "Kingdom of Sardinia" }, { @@ -73,7 +68,6 @@ }, { "coordinates": [1238, 349], - "flag": "custom:Ottoman Empire2", "name": "Ottoman Empire" } ] diff --git a/resources/maps/montreal/manifest.json b/resources/maps/montreal/manifest.json index 78ce3f637..4c17fa2f8 100644 --- a/resources/maps/montreal/manifest.json +++ b/resources/maps/montreal/manifest.json @@ -18,17 +18,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -38,42 +38,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/resources/maps/straitofgibraltar/manifest.json b/resources/maps/straitofgibraltar/manifest.json index 930ef92a5..790ecf015 100644 --- a/resources/maps/straitofgibraltar/manifest.json +++ b/resources/maps/straitofgibraltar/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1941, 1031], - "flag": "Rif", "name": "Rif" }, { @@ -43,12 +42,10 @@ }, { "coordinates": [1271, 1393], - "flag": "Shilha", "name": "Shilha" }, { "coordinates": [1555, 258], - "flag": "Andalusia", "name": "Andalusia" } ] diff --git a/tests/MapManifestFlags.test.ts b/tests/MapManifestFlags.test.ts new file mode 100644 index 000000000..dee46bf7d --- /dev/null +++ b/tests/MapManifestFlags.test.ts @@ -0,0 +1,61 @@ +import fs from "fs"; +import { globSync } from "glob"; +import path from "path"; + +type Nation = { + flag?: string; +}; + +type Manifest = { + nations?: Nation[]; +}; + +describe("Map manifests: nation flags exist", () => { + test("All nations' flags reference existing SVG files", () => { + const manifestPaths = globSync("resources/maps/**/manifest.json"); + + expect(manifestPaths.length).toBeGreaterThan(0); + + const flagDir = path.join(__dirname, "../resources/flags"); + const errors: string[] = []; + + for (const manifestPath of manifestPaths) { + try { + const raw = fs.readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(raw) as Manifest; + + (manifest.nations ?? []).forEach((nation, idx) => { + const flag = nation?.flag; + if (flag === undefined || flag === null) return; + if (typeof flag !== "string") { + errors.push( + `${manifestPath} -> nations[${idx}].flag is not a string`, + ); + return; + } + + if (flag.trim().length === 0) return; + if (flag.startsWith("!")) return; + + const svgFile = flag.endsWith(".svg") ? flag : `${flag}.svg`; + const flagPath = path.join(flagDir, svgFile); + if (!fs.existsSync(flagPath)) { + errors.push( + `${manifestPath} -> nations[${idx}].flag "${flag}" does not exist in resources/flags`, + ); + } + }); + } catch (err) { + errors.push( + `Failed to parse ${manifestPath}: ${(err as Error).message}`, + ); + } + } + + if (errors.length > 0) { + throw new Error( + "Map manifest flag file violations:\n" + errors.join("\n"), + ); + } + }); +});