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" > + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index a9663b2b0..56c8941cd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -7,6 +7,7 @@ }, "common": { "close": "Close", + "copy": "Copy", "back": "Back", "available": "Available", "preset_max": "Max", @@ -51,12 +52,43 @@ "account": "Account", "help": "Help", "menu": "Menu", + "troubleshooting": "Troubleshooting", + "go_to_troubleshooting": "Go to our troubleshooting page", "pick_pattern": "Pick a pattern!" }, "news": { "github_link": "on GitHub", "title": "Release Notes" }, + "troubleshooting": { + "title": "Troubleshooting", + "loading": "Loading...", + "environment": "Environment", + "rendering": "Rendering", + "power": "Power", + "browser": "Browser", + "platform": "Platform", + "copied_to_clipboard": "Info copied to the clipboard! Feel free to share it on our Discord if you need help.", + "os": "OS", + "device_pixel_ratio": "Device Pixel Ratio", + "chromium_tip": "OpenFront runs best on Chromium-based browsers.", + "hardware_acceleration_tip": "Make sure hardware acceleration is enabled in your browser settings for optimal performance.", + "renderer": "Renderer", + "max_texture_size": "Max Texture Size", + "high_precision_shaders": "High Precision Shaders", + "gpu": "GPU", + "unavailable": "Unavailable", + "gpu_tip": "Verify that this is the dedicated GPU, if one is available.", + "battery": "Battery", + "charging": "Charging", + "battery_level": "Battery Level", + "power_saving_tip": "Make sure that your browser is not set to power saving mode.", + "yes": "Yes", + "no": "No", + "unknown": "Unknown", + "software_rendering": "Software rendering", + "canvas_2d_no_gpu": "Canvas 2D (no GPU)" + }, "help_modal": { "hotkeys": "Hotkeys", "table_key": "Key", @@ -141,6 +173,7 @@ "build_mirv": "MIRV", "build_mirv_desc": "The most powerful bomb in the game. Splits up into smaller bombs that will cover a huge range of territory. Only damages the player that you first clicked on to build it. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.", "player_icons": "Player icons", + "troubleshooting_desc": "If you experience performance issues, crashes, or other problems while playing OpenFront, please visit our Troubleshooting page for help diagnosing and fixing common issues:", "icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:", "icon_crown": "Crown - Number 1. This is the top player in the leaderboard.", "icon_traitor": "Broken shield - Traitor. This player attacked an ally.", @@ -166,6 +199,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)", @@ -246,6 +280,8 @@ }, "map": { "map": "Map", + "featured": "Featured", + "all": "All", "world": "World", "giantworldmap": "Giant World Map", "europe": "Europe", @@ -296,6 +332,7 @@ "amazonriver": "Amazon River" }, "map_categories": { + "featured": "Featured", "continental": "Continental", "regional": "Regional", "fantasy": "Other", @@ -421,6 +458,7 @@ "public_game_modifier": { "random_spawn": "Random Spawn", "compact_map": "Compact Map", + "crowded": "Crowded", "starting_gold": "5M Starting Gold" }, "select_lang": { 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/src/client/HelpModal.ts b/src/client/HelpModal.ts index cfc2a611b..e696db76f 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -3,8 +3,8 @@ 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"; @customElement("help-modal") export class HelpModal extends BaseModal { @@ -104,7 +104,7 @@ export class HelpModal extends BaseModal { : ""}" > ${modalHeader({ - title: translateText("main.instructions"), + title: translateText("main.help"), onBack: this.close, ariaLabel: translateText("common.back"), })} @@ -120,6 +120,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 +1184,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/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e5bb92f11..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()} + >
@@ -653,6 +585,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/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/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/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 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/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/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/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 + ); + } } 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")} +

+
+ +
+
+
+ `; + } +} 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/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"; +} 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/Schemas.ts b/src/core/Schemas.ts index 8ae498745..90a044256 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -219,6 +219,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/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( diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 690227305..1a74ecfd3 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, @@ -171,7 +190,7 @@ export class MapPlaylist { disableNations: true, gameMode: GameMode.FFA, bots: 100, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: 30 * 10, disabledUnits: [], } satisfies GameConfig; } @@ -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; @@ -292,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++) { @@ -306,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; } 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"), + ); + } + }); +}); 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(); + }); }); 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.");