From f8c14398c81e13f413b546af1fef64e9cf281b2c Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:41:14 +0000 Subject: [PATCH 1/5] UI Extraction Host/Solo Modal (#3181) ## Description: UI Extraction Host/Solo Modal - Made all buttons do the same "press" feel (options/settings/random map) are now the same as the map button. - Also fixed a bug where you could "drag" the map image off the button. ## 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/HostLobbyModal.ts | 900 +++++++----------- src/client/SinglePlayerModal.ts | 791 +++++---------- src/client/components/GameConfigSettings.ts | 447 +++++++++ src/client/components/ToggleInputCard.ts | 173 ++++ src/client/components/map/MapDisplay.ts | 10 +- src/client/components/map/MapPicker.ts | 33 +- src/client/utilities/GameConfigHelpers.ts | 93 ++ src/client/utilities/RenderToggleInputCard.ts | 162 ---- src/client/utilities/RenderUnitTypeOptions.ts | 47 - 9 files changed, 1322 insertions(+), 1334 deletions(-) create mode 100644 src/client/components/GameConfigSettings.ts create mode 100644 src/client/components/ToggleInputCard.ts create mode 100644 src/client/utilities/GameConfigHelpers.ts delete mode 100644 src/client/utilities/RenderToggleInputCard.ts delete mode 100644 src/client/utilities/RenderUnitTypeOptions.ts diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 3a8f5c48f..71e661842 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,17 +1,14 @@ -import { TemplateResult, html } from "lit"; +import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { EventBus } from "../core/EventBus"; import { Difficulty, - Duos, GameMapSize, GameMapType, GameMode, HumansVsNations, - Quads, - Trios, UnitType, } from "../core/game/Game"; import { @@ -27,19 +24,23 @@ import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; -import "./components/Difficulties"; -import "./components/FluentSlider"; +import "./components/GameConfigSettings"; import "./components/LobbyPlayerView"; -import "./components/map/MapPicker"; +import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { - renderToggleInputCard, - renderToggleInputCardInput, -} from "./utilities/RenderToggleInputCard"; -import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; + getBotsForCompactMap, + getRandomMapType, + getUpdatedDisabledUnits, + parseBoundedFloatFromInput, + parseBoundedIntegerFromInput, + preventDisallowedKeys, + toOptionalNumber, +} from "./utilities/GameConfigHelpers"; + @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @@ -93,32 +94,6 @@ export class HostLobbyModal extends BaseModal { } }; - private renderOptionToggle( - labelKey: string, - checked: boolean, - onChange: (val: boolean) => void, - hidden: boolean = false, - ): TemplateResult { - if (hidden) return html``; - - return html` - - `; - } - private getRandomString(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; return Array.from( @@ -165,34 +140,73 @@ export class HostLobbyModal extends BaseModal { } render() { - const maxTimerHandlers = this.createToggleHandlers( - () => this.maxTimer, - (val) => (this.maxTimer = val), - () => this.maxTimerValue, - (val) => (this.maxTimerValue = val), - 30, - ); - const spawnImmunityHandlers = this.createToggleHandlers( - () => this.spawnImmunity, - (val) => (this.spawnImmunity = val), - () => this.spawnImmunityDurationMinutes, - (val) => (this.spawnImmunityDurationMinutes = val), - 5, - ); - const goldMultiplierHandlers = this.createToggleHandlers( - () => this.goldMultiplier, - (val) => (this.goldMultiplier = val), - () => this.goldMultiplierValue, - (val) => (this.goldMultiplierValue = val), - 2, - ); - const startingGoldHandlers = this.createToggleHandlers( - () => this.startingGold, - (val) => (this.startingGold = val), - () => this.startingGoldValue, - (val) => (this.startingGoldValue = val), - 5000000, - ); + const inputCards = [ + html``, + html``, + html``, + html``, + ]; const content = html`
-
-
- -
-
-
- - - -
-

- ${translateText("map.map")} -

-
- - this.handleMapSelection(mapValue)} - .onSelectRandom=${() => this.handleSelectRandomMap()} - > -
+
+ - -
-
-
- - - -
-

- ${translateText("difficulty.difficulty")} -

-
- -
- ${Object.entries(Difficulty) - .filter(([key]) => isNaN(Number(key))) - .map(([key, value]) => { - const isSelected = this.selectedDifficulty === value; - const isDisabled = this.disableNations; - return html` - - `; - })} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.mode")} -

-
-
- ${[GameMode.FFA, GameMode.Team].map((mode) => { - const isSelected = this.gameMode === mode; - return html` - - `; - })} -
-
- - ${this.gameMode === GameMode.FFA - ? "" - : html` - -
-
- ${translateText("host_modal.team_count")} -
-
- ${[ - 2, - 3, - 4, - 5, - 6, - 7, - Quads, - Trios, - Duos, - HumansVsNations, - ].map((o) => { - const isSelected = this.teamCount === o; - return html` - - `; - })} -
-
- `} - - -
-
-
- - - -
-

- ${translateText("host_modal.options_title")} -

-
-
- -
- -
- - ${!( - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ) - ? this.renderOptionToggle( - "host_modal.disable_nations", - this.disableNations, - this.handleDisableNationsChange, - ) - : ""} - ${this.renderOptionToggle( - "host_modal.instant_build", - this.instantBuild, - this.handleInstantBuildChange, - )} - ${this.renderOptionToggle( - "host_modal.random_spawn", - this.randomSpawn, - this.handleRandomSpawnChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_gold", - this.donateGold, - this.handleDonateGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_troops", - this.donateTroops, - this.handleDonateTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_gold", - this.infiniteGold, - this.handleInfiniteGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_troops", - this.infiniteTroops, - this.handleInfiniteTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.compact_map", - this.compactMap, - this.handleCompactMapChange, - )} - - - ${renderToggleInputCard({ - labelKey: "host_modal.max_timer", - checked: this.maxTimer, - onClick: maxTimerHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - value: this.maxTimerValue ?? 0, - ariaLabel: translateText("host_modal.max_timer"), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleMaxTimerValueChanges, - onKeyDown: this.handleMaxTimerValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "host_modal.player_immunity_duration", - checked: this.spawnImmunity, - onClick: spawnImmunityHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - step: 1, - value: this.spawnImmunityDurationMinutes ?? 0, - ariaLabel: translateText( - "host_modal.player_immunity_duration", - ), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleSpawnImmunityDurationInput, - onKeyDown: this.handleSpawnImmunityDurationKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.gold_multiplier", - checked: this.goldMultiplier, - onClick: goldMultiplierHandlers.click, - input: renderToggleInputCardInput({ - id: "gold-multiplier-value", - min: 0.1, - max: 1000, - step: "any", - value: this.goldMultiplierValue ?? "", - ariaLabel: translateText("single_modal.gold_multiplier"), - placeholder: translateText( - "single_modal.gold_multiplier_placeholder", - ), - onChange: this.handleGoldMultiplierValueChanges, - onKeyDown: this.handleGoldMultiplierValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.starting_gold", - checked: this.startingGold, - onClick: startingGoldHandlers.click, - input: renderToggleInputCardInput({ - id: "starting-gold-value", - min: 0, - max: 1000000000, - step: 100000, - value: this.startingGoldValue ?? "", - ariaLabel: translateText("single_modal.starting_gold"), - placeholder: translateText( - "single_modal.starting_gold_placeholder", - ), - onInput: this.handleStartingGoldValueChanges, - onKeyDown: this.handleStartingGoldValueKeyDown, - }), - })} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.enables_title")} -

-
-
- ${renderUnitTypeOptions({ - disabledUnits: this.disabledUnits, - toggleUnit: this.toggleUnit.bind(this), - })} -
-
- - - this.kickPlayer(clientID)} - > -
+ this.kickPlayer(clientID)} + >
@@ -700,40 +395,6 @@ export class HostLobbyModal extends BaseModal { this.loadNationCount(); } - private createToggleHandlers( - toggleStateGetter: () => boolean, - toggleStateSetter: (val: boolean) => void, - valueGetter: () => number | undefined, - valueSetter: (val: number | undefined) => void, - defaultValue: number = 0, - ) { - const toggleLogic = () => { - const newState = !toggleStateGetter(); - toggleStateSetter(newState); - if (newState) { - valueSetter(valueGetter() ?? defaultValue); - } else { - valueSetter(undefined); - } - this.putGameConfig(); - this.requestUpdate(); - }; - - return { - click: (e: Event) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; - toggleLogic(); - }, - keydown: (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleLogic(); - } - }, - }; - } - private leaveLobby() { if (!this.lobbyId) { return; @@ -800,11 +461,15 @@ export class HostLobbyModal extends BaseModal { private async handleSelectRandomMap() { this.useRandomMap = true; - this.selectedMap = this.getRandomMap(); + this.selectedMap = getRandomMapType(); await this.loadNationCount(); this.putGameConfig(); } + private handleConfigRandomMapSelected = () => { + void this.handleSelectRandomMap(); + }; + private async handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; @@ -812,13 +477,81 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleConfigMapSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ map: GameMapType }>; + void this.handleMapSelection(customEvent.detail.map); + }; + private async handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; this.putGameConfig(); } + private handleConfigDifficultySelected = (e: Event) => { + const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; + void this.handleDifficultySelection(customEvent.detail.difficulty); + }; + + private handleConfigGameModeSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ mode: GameMode }>; + void this.handleGameModeSelection(customEvent.detail.mode); + }; + + private handleConfigTeamCountSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; + void this.handleTeamCountSelection(customEvent.detail.count); + }; + + private handleConfigOptionToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ + labelKey: string; + checked: boolean; + }>; + const { labelKey, checked } = customEvent.detail; + + switch (labelKey) { + case "host_modal.disable_nations": + void this.handleDisableNationsChange(checked); + break; + case "host_modal.instant_build": + this.handleInstantBuildChange(checked); + break; + case "host_modal.random_spawn": + this.handleRandomSpawnChange(checked); + break; + case "host_modal.donate_gold": + this.handleDonateGoldChange(checked); + break; + case "host_modal.donate_troops": + this.handleDonateTroopsChange(checked); + break; + case "host_modal.infinite_gold": + this.handleInfiniteGoldChange(checked); + break; + case "host_modal.infinite_troops": + this.handleInfiniteTroopsChange(checked); + break; + case "host_modal.compact_map": + this.handleCompactMapChange(checked); + break; + default: + break; + } + }; + + private handleConfigUnitToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; + const { unit, checked } = customEvent.detail; + this.disabledUnits = getUpdatedDisabledUnits( + this.disabledUnits, + unit, + checked, + ); + this.putGameConfig(); + }; + // Modified to include debouncing - private handleBotsChange(e: Event) { + private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { @@ -838,67 +571,94 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); this.botsUpdateTimer = null; }, 300); - } + }; private handleInstantBuildChange = (val: boolean) => { this.instantBuild = val; this.putGameConfig(); }; - private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.maxTimer = checked; + this.maxTimerValue = toOptionalNumber(value); + this.putGameConfig(); + }; - private handleSpawnImmunityDurationInput(e: Event) { + private handleSpawnImmunityToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.spawnImmunity = checked; + this.spawnImmunityDurationMinutes = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleGoldMultiplierToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.goldMultiplier = checked; + this.goldMultiplierValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleStartingGoldToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startingGold = checked; + this.startingGoldValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; + + private handleSpawnImmunityDurationInput = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value, 10); - if (Number.isNaN(value) || value < 0 || value > 120) { + const value = parseBoundedIntegerFromInput(input, { min: 0, max: 120 }); + if (value === undefined) { return; } this.spawnImmunityDurationMinutes = value; this.putGameConfig(); - } + }; - private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { - if (["+", "-", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["+", "-", "e", "E"]); + }; - private handleGoldMultiplierValueChanges(e: Event) { + private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - const value = parseFloat(input.value); + const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); - if (isNaN(value) || value < 0.1 || value > 1000) { + if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } this.putGameConfig(); - } + }; - private handleStartingGoldValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; - private handleStartingGoldValueChanges(e: Event) { + private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 1000000000, + }); - if (isNaN(value) || value < 0 || value > 1000000000) { - this.startingGoldValue = undefined; - } else { - this.startingGoldValue = value; - } + this.startingGoldValue = value; this.putGameConfig(); - } + }; private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; @@ -922,11 +682,7 @@ export class HostLobbyModal extends BaseModal { private handleCompactMapChange = (val: boolean) => { this.compactMap = val; - if (val && this.bots === 400) { - this.bots = 100; - } else if (!val && this.bots === 100) { - this.bots = 400; - } + this.bots = getBotsForCompactMap(this.bots, val); this.putGameConfig(); }; @@ -935,24 +691,24 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; - private handleMaxTimerValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e"]); + }; - private handleMaxTimerValueChanges(e: Event) { - (e.target as HTMLInputElement).value = ( - e.target as HTMLInputElement - ).value.replace(/[e+-]/gi, ""); - const value = parseInt((e.target as HTMLInputElement).value); + private handleMaxTimerValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedIntegerFromInput(input, { + min: 1, + max: 120, + stripPattern: /[e+-]/gi, + }); - if (isNaN(value) || value < 0 || value > 120) { + if (value === undefined) { return; } this.maxTimerValue = value; this.putGameConfig(); - } + }; private handleDisableNationsChange = async (val: boolean) => { this.disableNations = val; @@ -1029,20 +785,6 @@ export class HostLobbyModal extends BaseModal { ); } - private toggleUnit(unit: UnitType, checked: boolean): void { - this.disabledUnits = checked - ? [...this.disabledUnits, unit] - : this.disabledUnits.filter((u) => u !== unit); - - this.putGameConfig(); - } - - private getRandomMap(): GameMapType { - const maps = Object.values(GameMapType); - const randIdx = Math.floor(Math.random() * maps.length); - return maps[randIdx] as GameMapType; - } - private async startGame() { await this.putGameConfig(); console.log( diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 4f7bf279c..6a72726bc 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -4,14 +4,11 @@ import { translateText } from "../client/Utils"; import { UserMeResponse } from "../core/ApiSchemas"; import { Difficulty, - Duos, GameMapSize, GameMapType, GameMode, GameType, HumansVsNations, - Quads, - Trios, UnitType, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; @@ -21,9 +18,8 @@ import { hasLinkedAccount } from "./Api"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; -import "./components/Difficulties"; -import "./components/FluentSlider"; -import "./components/map/MapPicker"; +import "./components/GameConfigSettings"; +import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -31,10 +27,14 @@ import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; import { - renderToggleInputCard, - renderToggleInputCardInput, -} from "./utilities/RenderToggleInputCard"; -import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; + getBotsForCompactMap, + getRandomMapType, + getUpdatedDisabledUnits, + parseBoundedFloatFromInput, + parseBoundedIntegerFromInput, + preventDisallowedKeys, + toOptionalNumber, +} from "./utilities/GameConfigHelpers"; const DEFAULT_OPTIONS = { selectedMap: GameMapType.World, @@ -166,6 +166,60 @@ export class SinglePlayerModal extends BaseModal { } render() { + const inputCards = [ + html``, + html``, + html``, + ]; + const content = html`
-
-
- -
-
-
- - - -
-

- ${translateText("map.map")} -

-
- - - this.handleMapSelection(mapValue)} - .onSelectRandom=${() => this.handleSelectRandomMap()} - > -
- - -
-
-
- - - -
-

- ${translateText("difficulty.difficulty")} -

-
- -
- ${Object.entries(Difficulty) - .filter(([key]) => isNaN(Number(key))) - .map( - ([key, value]) => html` - - `, - )} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.mode")} -

-
- -
- ${[GameMode.FFA, GameMode.Team].map((mode) => { - const isSelected = this.gameMode === mode; - const label = - mode === GameMode.FFA - ? translateText("game_mode.ffa") - : translateText("game_mode.teams"); - - return html` - - `; - })} -
-
- - ${this.gameMode === GameMode.FFA - ? "" - : html` - -
-
- ${translateText("host_modal.team_count")} -
-
- ${[ - 2, - 3, - 4, - 5, - 6, - 7, - Quads, - Trios, - Duos, - HumansVsNations, - ].map( - (o) => html` - - `, - )} -
-
- `} - - -
-
-
- - - -
-

- ${translateText("single_modal.options_title")} -

-
- -
- -
- -
- - ${this.renderOptionToggle( - "single_modal.disable_nations", - this.disableNations, - (val) => (this.disableNations = val), - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations, - )} - ${this.renderOptionToggle( - "single_modal.instant_build", - this.instantBuild, - (val) => (this.instantBuild = val), - )} - ${this.renderOptionToggle( - "single_modal.random_spawn", - this.randomSpawn, - (val) => (this.randomSpawn = val), - )} - ${this.renderOptionToggle( - "single_modal.infinite_gold", - this.infiniteGold, - (val) => (this.infiniteGold = val), - )} - ${this.renderOptionToggle( - "single_modal.infinite_troops", - this.infiniteTroops, - (val) => (this.infiniteTroops = val), - )} - ${this.renderOptionToggle( - "single_modal.compact_map", - this.compactMap, - (val) => { - this.compactMap = val; - if (val && this.bots === 400) { - this.bots = 100; - } else if (!val && this.bots === 100) { - this.bots = 400; - } +
+ { - this.maxTimer = !this.maxTimer; - if (!this.maxTimer) { - this.maxTimerValue = undefined; - } else { - // Set default value when enabling if not already set or invalid - if (!this.maxTimerValue || this.maxTimerValue <= 0) { - this.maxTimerValue = 30; - } - // Focus the input after render - setTimeout(() => { - const input = this.getEndTimerInput(); - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.instant_build", + checked: this.instantBuild, }, - input: renderToggleInputCardInput({ - id: "end-timer-value", - min: 1, - max: 120, - value: this.maxTimerValue ?? "", - ariaLabel: translateText("single_modal.max_timer"), - placeholder: translateText( - "single_modal.max_timer_placeholder", - ), - onInput: this.handleMaxTimerValueChanges, - onKeyDown: this.handleMaxTimerValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.gold_multiplier", - checked: this.goldMultiplier, - onClick: () => { - this.goldMultiplier = !this.goldMultiplier; - if (!this.goldMultiplier) { - this.goldMultiplierValue = undefined; - } else { - if ( - !this.goldMultiplierValue || - this.goldMultiplierValue <= 0 - ) { - this.goldMultiplierValue = 2; - } - setTimeout(() => { - const input = this.renderRoot.querySelector( - "#gold-multiplier-value", - ) as HTMLInputElement; - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.random_spawn", + checked: this.randomSpawn, }, - input: renderToggleInputCardInput({ - id: "gold-multiplier-value", - min: 0.1, - max: 1000, - step: "any", - value: this.goldMultiplierValue ?? "", - ariaLabel: translateText("single_modal.gold_multiplier"), - placeholder: translateText( - "single_modal.gold_multiplier_placeholder", - ), - onChange: this.handleGoldMultiplierValueChanges, - onKeyDown: this.handleGoldMultiplierValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.starting_gold", - checked: this.startingGold, - onClick: () => { - this.startingGold = !this.startingGold; - if (!this.startingGold) { - this.startingGoldValue = undefined; - } else { - if ( - !this.startingGoldValue || - this.startingGoldValue < 0 - ) { - this.startingGoldValue = 5000000; - } - setTimeout(() => { - const input = this.renderRoot.querySelector( - "#starting-gold-value", - ) as HTMLInputElement; - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.infinite_gold", + checked: this.infiniteGold, }, - input: renderToggleInputCardInput({ - id: "starting-gold-value", - min: 0, - max: 1000000000, - step: 100000, - value: this.startingGoldValue ?? "", - ariaLabel: translateText("single_modal.starting_gold"), - placeholder: translateText( - "single_modal.starting_gold_placeholder", - ), - onInput: this.handleStartingGoldValueChanges, - onKeyDown: this.handleStartingGoldValueKeyDown, - }), - })} -
-
- - -
-
-
- - - -
-

- ${translateText("single_modal.enables_title")} -

-
-
- ${renderUnitTypeOptions({ - disabledUnits: this.disabledUnits, - toggleUnit: this.toggleUnit.bind(this), - })} -
-
-
+ { + labelKey: "single_modal.infinite_troops", + checked: this.infiniteTroops, + }, + { + labelKey: "single_modal.compact_map", + checked: this.compactMap, + }, + ], + inputCards, + }, + unitTypes: { + titleKey: "single_modal.enables_title", + disabledUnits: this.disabledUnits, + }, + }} + @map-selected=${this.handleConfigMapSelected} + @random-map-selected=${this.handleConfigRandomMapSelected} + @difficulty-selected=${this.handleConfigDifficultySelected} + @game-mode-selected=${this.handleConfigGameModeSelected} + @team-count-selected=${this.handleConfigTeamCountSelected} + @bots-changed=${this.handleBotsChange} + @option-toggle-changed=${this.handleConfigOptionToggleChanged} + @unit-toggle-changed=${this.handleConfigUnitToggleChanged} + >
@@ -698,33 +383,6 @@ export class SinglePlayerModal extends BaseModal { ); } - // Helper for consistent option buttons - private renderOptionToggle( - labelKey: string, - checked: boolean, - onChange: (val: boolean) => void, - hidden: boolean = false, - ): TemplateResult { - if (hidden) return html``; - - return html` - - `; - } - protected onClose(): void { // Reset all transient form state to ensure clean slate this.selectedMap = DEFAULT_OPTIONS.selectedMap; @@ -752,29 +410,121 @@ export class SinglePlayerModal extends BaseModal { this.useRandomMap = true; } + private handleConfigRandomMapSelected = () => { + this.handleSelectRandomMap(); + }; + private handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; } + private handleConfigMapSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ map: GameMapType }>; + this.handleMapSelection(customEvent.detail.map); + }; + private handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; } - private handleBotsChange(e: Event) { + private handleConfigDifficultySelected = (e: Event) => { + const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; + this.handleDifficultySelection(customEvent.detail.difficulty); + }; + + private handleConfigGameModeSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ mode: GameMode }>; + this.handleGameModeSelection(customEvent.detail.mode); + }; + + private handleConfigTeamCountSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; + this.handleTeamCountSelection(customEvent.detail.count); + }; + + private handleCompactMapChange(val: boolean) { + this.compactMap = val; + this.bots = getBotsForCompactMap(this.bots, val); + } + + private handleConfigOptionToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ + labelKey: string; + checked: boolean; + }>; + const { labelKey, checked } = customEvent.detail; + + switch (labelKey) { + case "single_modal.disable_nations": + this.disableNations = checked; + break; + case "single_modal.instant_build": + this.instantBuild = checked; + break; + case "single_modal.random_spawn": + this.randomSpawn = checked; + break; + case "single_modal.infinite_gold": + this.infiniteGold = checked; + break; + case "single_modal.infinite_troops": + this.infiniteTroops = checked; + break; + case "single_modal.compact_map": + this.handleCompactMapChange(checked); + break; + default: + break; + } + }; + + private handleConfigUnitToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; + const { unit, checked } = customEvent.detail; + this.disabledUnits = getUpdatedDisabledUnits( + this.disabledUnits, + unit, + checked, + ); + }; + + private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } this.bots = value; - } + }; - private handleMaxTimerValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.maxTimer = checked; + this.maxTimerValue = toOptionalNumber(value); + }; + + private handleGoldMultiplierToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.goldMultiplier = checked; + this.goldMultiplierValue = toOptionalNumber(value); + }; + + private handleStartingGoldToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startingGold = checked; + this.startingGoldValue = toOptionalNumber(value); + }; + + private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e"]); + }; private getEndTimerInput(): HTMLInputElement | null { return ( @@ -785,55 +535,46 @@ export class SinglePlayerModal extends BaseModal { ); } - private handleMaxTimerValueChanges(e: Event) { + private handleMaxTimerValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[e+-]/gi, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 1, + max: 120, + stripPattern: /[e+-]/gi, + }); - // Always update state to keep UI and internal state in sync - if (isNaN(value) || value < 1 || value > 120) { - // Set to undefined for invalid/empty/out-of-range values - this.maxTimerValue = undefined; - } else { - this.maxTimerValue = value; - } - } + this.maxTimerValue = value; + }; - private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { - if (["+", "-", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["+", "-", "e", "E"]); + }; - private handleGoldMultiplierValueChanges(e: Event) { + private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - const value = parseFloat(input.value); + const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); - if (isNaN(value) || value < 0.1 || value > 1000) { + if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } - } + }; - private handleStartingGoldValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; - private handleStartingGoldValueChanges(e: Event) { + private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 1000000000, + }); - if (isNaN(value) || value < 0 || value > 1000000000) { - this.startingGoldValue = undefined; - } else { - this.startingGoldValue = value; - } - } + this.startingGoldValue = value; + }; private handleGameModeSelection(value: GameMode) { this.gameMode = value; @@ -843,18 +584,6 @@ export class SinglePlayerModal extends BaseModal { this.teamCount = value; } - private getRandomMap(): GameMapType { - const maps = Object.values(GameMapType); - const randIdx = Math.floor(Math.random() * maps.length); - return maps[randIdx] as GameMapType; - } - - private toggleUnit(unit: UnitType, checked: boolean): void { - this.disabledUnits = checked - ? [...this.disabledUnits, unit] - : this.disabledUnits.filter((u) => u !== unit); - } - private async startGame() { // Validate and clamp maxTimer setting before starting let finalMaxTimerValue: number | undefined = undefined; @@ -879,7 +608,7 @@ export class SinglePlayerModal extends BaseModal { // If random map is selected, choose a random map now if (this.useRandomMap) { - this.selectedMap = this.getRandomMap(); + this.selectedMap = getRandomMapType(); } console.log( diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts new file mode 100644 index 000000000..b981a89fe --- /dev/null +++ b/src/client/components/GameConfigSettings.ts @@ -0,0 +1,447 @@ +import { + LitElement, + SVGTemplateResult, + TemplateResult, + html, + nothing, + svg, +} from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + Difficulty, + Duos, + GameMapType, + GameMode, + HumansVsNations, + Quads, + Trios, + UnitType, +} from "../../core/game/Game"; +import { TeamCountConfig } from "../../core/Schemas"; +import { translateText } from "../Utils"; +import "./Difficulties"; +import "./FluentSlider"; +import "./map/MapPicker"; + +const ACTIVE_CARD = + "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"; +const INACTIVE_CARD = + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; + +const DISABLED_CARD = + "w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5"; + +function cardClass(active: boolean, extra = ""): string { + return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; +} + +const CARD_LABEL_CLASS = + "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; + +const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) => + isNaN(Number(key)), +) as Array<[string, Difficulty]>; +const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [ + 2, + 3, + 4, + 5, + 6, + 7, + Quads, + Trios, + Duos, + HumansVsNations, +]; + +function stateTextClass(active: boolean): string { + return active ? "text-white" : "text-white/60"; +} + +function renderTextCardButton( + label: string, + active: boolean, + onClick: () => void, + cardExtraClass: string, +): TemplateResult { + return html` + + `; +} + +function renderSection( + iconSvg: SVGTemplateResult, + colorClass: string, + bgClass: string, + titleKey: string, + content: TemplateResult | TemplateResult[], + sectionClass = "space-y-6", +): TemplateResult { + return html` +
+ ${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content} +
+ `; +} + +const unitOptions: { type: UnitType; translationKey: string }[] = [ + { type: UnitType.City, translationKey: "unit_type.city" }, + { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, + { type: UnitType.Port, translationKey: "unit_type.port" }, + { type: UnitType.Warship, translationKey: "unit_type.warship" }, + { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, + { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, + { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, + { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, + { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, + { type: UnitType.Factory, translationKey: "unit_type.factory" }, +]; + +const MAP_ICON = svg``; + +const DIFFICULTY_ICON = svg``; + +const MODE_ICON = svg``; + +const OPTIONS_ICON = svg``; + +const ENABLES_ICON = svg``; + +function renderSectionHeader( + iconSvg: SVGTemplateResult, + colorClass: string, + bgClass: string, + titleKey: string, +): TemplateResult { + return html` +
+
+ + ${iconSvg} + +
+

+ ${translateText(titleKey)} +

+
+ `; +} + +export interface ToggleOptionConfig { + labelKey: string; + checked: boolean; + hidden?: boolean; +} + +export interface GameConfigSettingsData { + map: { + selected: GameMapType; + useRandom: boolean; + randomMapDivider?: boolean; + showMedals?: boolean; + mapWins?: Map>; + }; + difficulty: { + selected: Difficulty; + disabled: boolean; + }; + gameMode: { + selected: GameMode; + }; + teamCount: { + selected: TeamCountConfig; + }; + options: { + titleKey: string; + bots: { + value: number; + labelKey: string; + disabledKey: string; + }; + toggles: ToggleOptionConfig[]; + inputCards: TemplateResult[]; + }; + unitTypes: { + titleKey: string; + disabledUnits: UnitType[]; + }; +} + +@customElement("game-config-settings") +export class GameConfigSettings extends LitElement { + @property({ attribute: false }) settings?: GameConfigSettingsData; + @property({ attribute: false }) sectionGapClass = "space-y-6"; + + createRenderRoot() { + return this; + } + + private emit(name: string, detail: T) { + this.dispatchEvent( + new CustomEvent(name, { + detail, + bubbles: true, + composed: true, + }), + ); + } + + private handleSelectMap = (map: GameMapType) => { + this.emit("map-selected", { map }); + }; + + private handleSelectRandom = () => { + this.emit("random-map-selected", {}); + }; + + private handleDifficultySelect = (difficulty: Difficulty) => { + this.emit("difficulty-selected", { difficulty }); + }; + + private handleGameModeSelect = (mode: GameMode) => { + this.emit("game-mode-selected", { mode }); + }; + + private handleTeamCountSelect = (count: TeamCountConfig) => { + this.emit("team-count-selected", { count }); + }; + + private handleOptionToggle = (toggle: ToggleOptionConfig) => { + this.emit("option-toggle-changed", { + labelKey: toggle.labelKey, + checked: !toggle.checked, + }); + }; + + private handleBotsChanged = (event: Event) => { + const customEvent = event as CustomEvent<{ value: number }>; + this.emit("bots-changed", customEvent.detail); + }; + + private handleUnitToggle = (unit: UnitType, checked: boolean) => { + this.emit("unit-toggle-changed", { unit, checked }); + }; + + private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult { + if (toggle.hidden) return html``; + + return renderTextCardButton( + translateText(toggle.labelKey), + toggle.checked, + () => this.handleOptionToggle(toggle), + "p-4 text-center", + ); + } + + private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] { + return unitOptions.map(({ type, translationKey }) => { + const isEnabled = !disabledUnits.includes(type); + return html` + + `; + }); + } + + render() { + if (!this.settings) return nothing; + const settings = this.settings; + + return html` +
+ ${renderSection( + MAP_ICON, + "text-blue-400", + "bg-blue-500/20", + "map.map", + html``, + )} + ${renderSection( + DIFFICULTY_ICON, + "text-green-400", + "bg-green-500/20", + "difficulty.difficulty", + html` +
+ ${DIFFICULTY_OPTIONS.map(([key, value]) => { + const isSelected = settings.difficulty.selected === value; + const isDisabled = settings.difficulty.disabled; + return html` + + `; + })} +
+ `, + )} + ${renderSection( + MODE_ICON, + "text-purple-400", + "bg-purple-500/20", + "host_modal.mode", + html` +
+ ${[GameMode.FFA, GameMode.Team].map((mode) => { + const isSelected = settings.gameMode.selected === mode; + return html` + + `; + })} +
+ `, + )} + ${settings.gameMode.selected === GameMode.FFA + ? nothing + : html` +
+
+ ${translateText("host_modal.team_count")} +
+
+ ${TEAM_COUNT_OPTIONS.map((o) => { + const isSelected = settings.teamCount.selected === o; + return html` + + `; + })} +
+
+ `} + ${renderSection( + OPTIONS_ICON, + "text-orange-400", + "bg-orange-500/20", + settings.options.titleKey, + html` +
+
+ +
+ + ${settings.options.toggles.map((toggle) => + this.renderOptionToggle(toggle), + )} + ${settings.options.inputCards} +
+ `, + )} + ${renderSection( + ENABLES_ICON, + "text-teal-400", + "bg-teal-500/20", + settings.unitTypes.titleKey, + html` +
+ ${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)} +
+ `, + "space-y-6 pb-6", + )} +
+ `; + } +} diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts new file mode 100644 index 000000000..279366aff --- /dev/null +++ b/src/client/components/ToggleInputCard.ts @@ -0,0 +1,173 @@ +import { LitElement, PropertyValues, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../Utils"; + +const ACTIVE_CARD = + "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"; +const INACTIVE_CARD = + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; +const INPUT_CLASS = + "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"; +const CARD_LABEL_CLASS = + "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; + +function cardClass(active: boolean, extra = ""): string { + return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; +} + +@customElement("toggle-input-card") +export class ToggleInputCard extends LitElement { + @property({ attribute: false }) labelKey = ""; + @property({ type: Boolean, attribute: false }) checked = false; + @property({ attribute: false }) inputId?: string; + @property({ attribute: false }) inputType = "number"; + @property({ attribute: false }) inputMin?: number | string; + @property({ attribute: false }) inputMax?: number | string; + @property({ attribute: false }) inputStep?: number | string; + @property({ attribute: false }) inputValue?: number | string; + @property({ attribute: false }) inputAriaLabel?: string; + @property({ attribute: false }) inputPlaceholder?: string; + @property({ attribute: false }) defaultInputValue?: number | string; + @property({ attribute: false }) minValidOnEnable?: number; + @property({ attribute: false }) onToggle?: ( + checked: boolean, + value: number | string | undefined, + ) => void; + @property({ attribute: false }) onInput?: (e: Event) => void; + @property({ attribute: false }) onChange?: (e: Event) => void; + @property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void; + + createRenderRoot() { + return this; + } + + protected updated(changedProperties: PropertyValues) { + if (!changedProperties.has("checked")) return; + const previousChecked = changedProperties.get("checked"); + if (previousChecked === false && this.checked) { + const input = this.querySelector("input"); + if (input) { + input.focus(); + input.select(); + } + } + } + + private toOptionalNumber( + value: number | string | undefined, + ): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : undefined; + } + return undefined; + } + + private resolveValueOnEnable(): number | string | undefined { + const currentValue = this.inputValue; + + if ( + currentValue === undefined || + currentValue === null || + currentValue === "" + ) { + return this.defaultInputValue; + } + + if (this.minValidOnEnable === undefined) { + return currentValue; + } + + const numericValue = this.toOptionalNumber(currentValue); + if (numericValue === undefined || numericValue < this.minValidOnEnable) { + return this.defaultInputValue; + } + + return numericValue; + } + + private emitToggle() { + const nextChecked = !this.checked; + const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined; + this.onToggle?.(nextChecked, nextValue); + } + + private handleCardClick = () => { + this.emitToggle(); + }; + + render() { + return html` +
+ + + ${this.checked + ? html` +
+ +
+ ` + : nothing} +
+ `; + } +} diff --git a/src/client/components/map/MapDisplay.ts b/src/client/components/map/MapDisplay.ts index d843a9999..61241eff2 100644 --- a/src/client/components/map/MapDisplay.ts +++ b/src/client/components/map/MapDisplay.ts @@ -53,6 +53,10 @@ export class MapDisplay extends LitElement { } } + private preventImageDrag(event: DragEvent) { + event.preventDefault(); + } + render() { return html`
${this.isLoading ? html`
{ return this.mapWins?.get(mapValue) ?? new Set(); } @@ -54,7 +58,7 @@ export class MapPicker extends LitElement { return html`
this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" + class="cursor-pointer" >
diff --git a/src/client/utilities/GameConfigHelpers.ts b/src/client/utilities/GameConfigHelpers.ts new file mode 100644 index 000000000..a149dee88 --- /dev/null +++ b/src/client/utilities/GameConfigHelpers.ts @@ -0,0 +1,93 @@ +import { GameMapType, UnitType } from "../../core/game/Game"; + +export function toOptionalNumber( + value: number | string | undefined, +): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : undefined; + } + return undefined; +} + +export function preventDisallowedKeys( + e: KeyboardEvent, + disallowedKeys: string[], +): void { + if (disallowedKeys.includes(e.key)) { + e.preventDefault(); + } +} + +export function parseBoundedIntegerFromInput( + input: HTMLInputElement, + { + min, + max, + stripPattern = /[eE+-]/g, + radix = 10, + }: { + min: number; + max: number; + stripPattern?: RegExp; + radix?: number; + }, +): number | undefined { + input.value = input.value.replace(stripPattern, ""); + const value = parseInt(input.value, radix); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function parseBoundedFloatFromInput( + input: HTMLInputElement, + { min, max }: { min: number; max: number }, +): number | undefined { + const value = parseFloat(input.value); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function getBotsForCompactMap( + bots: number, + compactMapEnabled: boolean, +): number { + if (compactMapEnabled && bots === 400) { + return 100; + } + + if (!compactMapEnabled && bots === 100) { + return 400; + } + + return bots; +} + +export function getRandomMapType(): GameMapType { + const maps = Object.values(GameMapType); + const randIdx = Math.floor(Math.random() * maps.length); + return maps[randIdx] as GameMapType; +} + +export function getUpdatedDisabledUnits( + disabledUnits: UnitType[], + unit: UnitType, + checked: boolean, +): UnitType[] { + return checked + ? [...disabledUnits, unit] + : disabledUnits.filter((u) => u !== unit); +} diff --git a/src/client/utilities/RenderToggleInputCard.ts b/src/client/utilities/RenderToggleInputCard.ts deleted file mode 100644 index 44fbd437e..000000000 --- a/src/client/utilities/RenderToggleInputCard.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { TemplateResult, html, nothing } from "lit"; -import { translateText } from "../Utils"; - -export const TOGGLE_INPUT_CARD_CLASSES = { - containerActive: - "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]", - containerInactive: - "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80", - labelBase: - "text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto", - labelActive: "text-white", - labelInactive: "text-white/60", - input: - "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1", -}; - -export interface ToggleInputCardInputOptions { - id?: string; - type?: string; - min?: number | string; - max?: number | string; - step?: number | string; - value?: number | string; - ariaLabel?: string; - placeholder?: string; - onInput?: (e: Event) => void; - onChange?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - onClick?: (e: Event) => void; - className?: string; -} - -export function renderToggleInputCardInput({ - id, - type = "number", - min, - max, - step, - value, - ariaLabel, - placeholder, - onInput, - onChange, - onKeyDown, - onClick, - className = TOGGLE_INPUT_CARD_CLASSES.input, -}: ToggleInputCardInputOptions): TemplateResult { - const resolvedValue = value ?? ""; - const handleClick = onClick ?? ((e: Event) => e.stopPropagation()); - - return html` - - `; -} - -export interface ToggleInputCardRenderContext { - labelKey: string; - checked: boolean; - input?: TemplateResult; - onClick?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - activeClassName?: string; - inactiveClassName?: string; - labelBaseClassName?: string; - labelActiveClassName?: string; - labelInactiveClassName?: string; - role?: string; - tabIndex?: number; -} - -export function renderToggleInputCard({ - labelKey, - checked, - input, - onClick, - onKeyDown, - activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive, - inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive, - labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase, - labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive, - labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive, - role, - tabIndex, -}: ToggleInputCardRenderContext): TemplateResult { - const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown); - const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined); - const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined); - const resolvedOnKeyDown = - onKeyDown ?? - (onClick - ? (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(e); - } - } - : undefined); - - return html` -
-
-
- ${checked - ? html` - - ` - : ""} -
-
- - ${checked - ? (input ?? html``) - : html`
`} - -
- ${translateText(labelKey)} -
-
- `; -} diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts deleted file mode 100644 index c04dea7f4..000000000 --- a/src/client/utilities/RenderUnitTypeOptions.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { html, TemplateResult } from "lit"; -import { UnitType } from "../../core/game/Game"; -import { translateText } from "../Utils"; - -export interface UnitTypeRenderContext { - disabledUnits: UnitType[]; - toggleUnit: (unit: UnitType, checked: boolean) => void; -} - -const unitOptions: { type: UnitType; translationKey: string }[] = [ - { type: UnitType.City, translationKey: "unit_type.city" }, - { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, - { type: UnitType.Port, translationKey: "unit_type.port" }, - { type: UnitType.Warship, translationKey: "unit_type.warship" }, - { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, - { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, - { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, - { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, - { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, - { type: UnitType.Factory, translationKey: "unit_type.factory" }, -]; - -export function renderUnitTypeOptions({ - disabledUnits, - toggleUnit, -}: UnitTypeRenderContext): TemplateResult[] { - return unitOptions.map(({ type, translationKey }) => { - const isEnabled = !disabledUnits.includes(type); - return html` - - `; - }); -} From 6cc0ef7d148c2189442da77c7f53cc635b9595e0 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:57:18 +0100 Subject: [PATCH 2/5] =?UTF-8?q?Add=20PVP=20immunity=20to=205M=20starting?= =?UTF-8?q?=20gold=20modifier=20games=20=F0=9F=94=A7=20(#3180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Adds 30 seconds of PVP immunity to 5M starting gold modifier games. So you cannot insta-nuke other players. Because I'm sure people would be confused "I cannot attack!!!!" I added a HeadsUpMessage which informs about the PVP immunity. We already have a ImmunityTimer progress bar but I don't think its enough. image I had a second count in the HeadsUpMessage (seconds until PVP immunity is over) but it felt too busy. So I removed it. You can tell when PVP immunity is over by looking at the progress bar. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --------- Co-authored-by: Evan --- resources/lang/en.json | 3 ++- src/client/graphics/layers/HeadsUpMessage.ts | 21 +++++++++++++++++++- src/client/graphics/layers/ImmunityTimer.ts | 5 ++++- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 +++++++- src/core/game/GameImpl.ts | 2 +- src/core/game/GameView.ts | 6 ++++++ src/server/MapPlaylist.ts | 2 +- tests/Attack.test.ts | 6 +++--- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a29df60a2..546323c80 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -845,7 +845,8 @@ "choose_spawn": "Choose a starting location", "random_spawn": "Random spawn is enabled. Selecting starting location for you...", "singleplayer_game_paused": "Game paused", - "multiplayer_game_paused": "Game paused by Lobby Creator" + "multiplayer_game_paused": "Game paused by Lobby Creator", + "pvp_immunity_active": "PVP immunity active for {seconds}s" }, "territory_patterns": { "title": "Skins", diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 0239e2f15..a0b0937ce 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -16,6 +16,9 @@ export class HeadsUpMessage extends LitElement implements Layer { @state() private isPaused = false; + @state() + private isImmunityActive = false; + @state() private toastMessage: string | import("lit").TemplateResult | null = null; @state() @@ -79,7 +82,18 @@ export class HeadsUpMessage extends LitElement implements Layer { this.isPaused = pauseUpdate.paused; } - this.isVisible = this.game.inSpawnPhase() || this.isPaused; + const showImmunityHudDuration = 10 * 10; + const spawnEnd = this.game.config().numSpawnPhaseTurns(); + const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd; + + this.isImmunityActive = + this.game.config().hasExtendedSpawnImmunity() && + !this.game.inSpawnPhase() && + this.game.isSpawnImmunityActive() && + ticksSinceSpawnEnd < showImmunityHudDuration; + + this.isVisible = + this.game.inSpawnPhase() || this.isPaused || this.isImmunityActive; this.requestUpdate(); } @@ -91,6 +105,11 @@ export class HeadsUpMessage extends LitElement implements Layer { return translateText("heads_up_message.multiplayer_game_paused"); } } + if (this.isImmunityActive) { + return translateText("heads_up_message.pvp_immunity_active", { + seconds: Math.round(this.game.config().spawnImmunityDuration() / 10), + }); + } return this.game.config().isRandomSpawn() ? translateText("heads_up_message.random_spawn") : translateText("heads_up_message.choose_spawn"); diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts index 29f66fbac..cfb44ae2b 100644 --- a/src/client/graphics/layers/ImmunityTimer.ts +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -41,7 +41,10 @@ export class ImmunityTimer extends LitElement implements Layer { const immunityDuration = this.game.config().spawnImmunityDuration(); const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); - if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) { + if ( + !this.game.config().hasExtendedSpawnImmunity() || + this.game.inSpawnPhase() + ) { this.setInactive(); return; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index ac1d9ee4a..8595b3a21 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -58,6 +58,7 @@ export interface NukeMagnitude { export interface Config { spawnImmunityDuration(): Tick; + hasExtendedSpawnImmunity(): boolean; serverConfig(): ServerConfig; gameConfig(): GameConfig; theme(): Theme; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 3cb2e1af8..5a672f296 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -28,6 +28,7 @@ import { PastelThemeDark } from "./PastelThemeDark"; const DEFENSE_DEBUFF_MIDPOINT = 150_000; const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000; +const DEFAULT_SPAWN_IMMUNITY_TICKS = 5 * 10; const JwksSchema = z.object({ keys: z @@ -163,7 +164,12 @@ export class DefaultConfig implements Config { return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { - return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds + return ( + this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS + ); + } + hasExtendedSpawnImmunity(): boolean { + return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS; } gameConfig(): GameConfig { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6dd76beff..cab7e6061 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -722,7 +722,7 @@ export class GameImpl implements Game { public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + - this.config().spawnImmunityDuration() >= + this.config().spawnImmunityDuration() > this.ticks() ); } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 65a5b74d8..d66b9361b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -813,6 +813,12 @@ export class GameView implements GameMap { inSpawnPhase(): boolean { return this.ticks() <= this._config.numSpawnPhaseTurns(); } + isSpawnImmunityActive(): boolean { + return ( + this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() > + this.ticks() + ); + } config(): Config { return this._config; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index ed80175c1..0abbf4ed2 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -160,7 +160,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index e2bf619dc..8659a809d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -391,17 +391,17 @@ describe("Attack immunity", () => { test("Ensure a player can't attack during all the immunity phase", async () => { // Execute a few ticks but stop right before the immunity phase is over - for (let i = 0; i < immunityPhaseTicks - 1; i++) { + for (let i = 0; i < immunityPhaseTicks - 2; i++) { game.executeNextTick(); } // Player A attacks Player B game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); - game.executeNextTick(); // ticks === immunityPhaseTicks here + game.executeNextTick(); // ticks === immunityPhaseTicks - 1 here // Attack is not possible during immunity expect(playerA.outgoingAttacks()).toHaveLength(0); // Retry after the immunity is over - game.executeNextTick(); // ticks === immunityPhaseTicks + 1 + game.executeNextTick(); // ticks === immunityPhaseTicks game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); game.executeNextTick(); // Attack is now possible right after From 07e13b3479fef50873b8cbb3a4aea3c7229819e7 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:01:08 +0100 Subject: [PATCH 3/5] Fix: remove alliances on death (#3168) ## Description: - Remove alliances on death: after death, alliances would stay active including countdown timers and (when dead player kept spectating) icons. Now remove them when player becomes inActive. - Moved code to private method within PlayerExecution + added comments in NationExecution and BotExecution for more clarity as to where removals are performed from at death - Remove renewal request from Events Display when Alliance doesn't exist anymore (after death or otherwise). - Also cleanup this.alliancesCheckedAt when alliance doesn't exist anymore. Before, old/broken alliance id's would accumulate in it during a game. - Removed now-redundant isAlive check in EventsDisplay. Both the alliances array as the isAlive are updated in the same tick from PlayerUpdates so now alliance is removed from alliances array on player death, the other.isAlive() check is no longer needed. Of course we could keep it in just to be very safe, so just let me know when you're doubtful about this. - Attack.test.ts: fix failing test. Player B dies because of the attack, meaning the alliance now gets removed. Prevent this by gving both a different, adjecent, starting tile. And to be more clear about what is needed for the test to pass, add isAlive check for both of them after the attacks. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/client/graphics/layers/EventsDisplay.ts | 13 +++++++- src/core/execution/BotExecution.ts | 1 + src/core/execution/NationExecution.ts | 1 + src/core/execution/PlayerExecution.ts | 34 +++++++++++++-------- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 ++++ src/core/game/PlayerImpl.ts | 4 +++ tests/Attack.test.ts | 5 ++- 8 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index b39d3f8de..31f496c05 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -251,7 +251,11 @@ export class EventsDisplay extends LitElement implements Layer { const myPlayer = this.game.myPlayer(); if (!myPlayer?.isAlive()) return; + const currentAllianceIds = new Set(); + for (const alliance of myPlayer.alliances()) { + currentAllianceIds.add(alliance.id); + if ( alliance.expiresAt > this.game.ticks() + this.game.config().allianceExtensionPromptOffset() @@ -270,7 +274,6 @@ export class EventsDisplay extends LitElement implements Layer { this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); const other = this.game.player(alliance.other) as PlayerView; - if (!other.isAlive()) continue; this.addEvent({ description: translateText("events_display.about_to_expire", { @@ -305,6 +308,13 @@ export class EventsDisplay extends LitElement implements Layer { allianceID: alliance.id, }); } + + for (const [allianceId] of this.alliancesCheckedAt) { + if (!currentAllianceIds.has(allianceId)) { + this.removeAllianceRenewalEvents(allianceId); + this.alliancesCheckedAt.delete(allianceId); + } + } } private addEvent(event: GameEvent) { @@ -530,6 +540,7 @@ export class EventsDisplay extends LitElement implements Layer { if (!myPlayer) return; this.removeAllianceRenewalEvents(update.allianceID); + this.alliancesCheckedAt.delete(update.allianceID); this.requestUpdate(); const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 85fabb34a..491bf1b21 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -39,6 +39,7 @@ export class BotExecution implements Execution { if (ticks % this.attackRate !== this.attackTick) return; if (!this.bot.isAlive()) { + //removeOnDeath is called from bot's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 466adcdaa..45abd3729 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -144,6 +144,7 @@ export class NationExecution implements Execution { } if (!this.player.isAlive()) { + //removeOnDeath is called from nation's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index e1d80d0b4..1e9ae4a10 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -60,19 +60,7 @@ export class PlayerExecution implements Execution { } if (!this.player.isAlive()) { - // Player has no tiles, delete any remaining units and gold - const gold = this.player.gold(); - this.player.removeGold(gold); - this.player.units().forEach((u) => { - if ( - u.type() !== UnitType.AtomBomb && - u.type() !== UnitType.HydrogenBomb && - u.type() !== UnitType.MIRVWarhead && - u.type() !== UnitType.MIRV - ) { - u.delete(); - } - }); + this.removeOnDeath(); this.active = false; this.mg.stats().playerKilled(this.player, ticks); return; @@ -400,4 +388,24 @@ export class PlayerExecution implements Execution { return result; } + + private removeOnDeath(): void { + // Player (bot, human, nation) has no tiles + // Delete any remaining gold, non-nuke units and alliances + const gold = this.player.gold(); + this.player.removeGold(gold); + + this.player.units().forEach((u) => { + if ( + u.type() !== UnitType.AtomBomb && + u.type() !== UnitType.HydrogenBomb && + u.type() !== UnitType.MIRVWarhead && + u.type() !== UnitType.MIRV + ) { + u.delete(); + } + }); + + this.player.removeAllAlliances(); + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1057e7e6b..d0a1d3f97 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -663,6 +663,7 @@ export interface Player { allianceWith(other: Player): MutableAlliance | null; canSendAllianceRequest(other: Player): boolean; breakAlliance(alliance: Alliance): void; + removeAllAlliances(): void; createAllianceRequest(recipient: Player): AllianceRequest | null; betrayals(): number; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index cab7e6061..45d77878f 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -719,6 +719,12 @@ export class GameImpl implements Game { }); } + public removeAlliancesByPlayerSilently(player: Player): void { + this.alliances_ = this.alliances_.filter( + (a) => a.requestor() !== player && a.recipient() !== player, + ); + } + public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index dd72a3307..d00948d44 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -450,6 +450,10 @@ export class PlayerImpl implements Player { this.mg.breakAlliance(this, alliance); } + removeAllAlliances(): void { + this.mg.removeAlliancesByPlayerSilently(this); + } + isTraitor(): boolean { return this.getTraitorRemainingTicks() > 0; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 8659a809d..ff875870d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -183,7 +183,7 @@ describe("Attack race condition with alliance requests", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -224,6 +224,9 @@ describe("Attack race condition with alliance requests", () => { game.executeNextTick(); } + expect(playerA.isAlive()).toBe(true); + expect(playerB.isAlive()).toBe(true); + // Player A should not be marked as traitor because the alliance was formed after the attack started expect(playerA.isTraitor()).toBe(false); From cb6e97ed11ef0aaeb9dd5566a1cf99c2ef46ea8d Mon Sep 17 00:00:00 2001 From: Wawa Date: Thu, 12 Feb 2026 23:58:17 +0100 Subject: [PATCH 4/5] Add Leaderboard refresh time (#3190) ## Description: I added a small refresh time text (see screenshots below). > I play ranked a lot since it's been added and I just reached the top 100 (yay !!), I was wondering what was the refresh time so after I found it in the code, I wanted to add a small text for easier understanding :)

Open Screenshots "players" here

Before "players" : image After "players" : image
This can be edited as you want but I did not added the text in the "clans" section. I did not added any test in the tests files since this is a minor UI improvement, but I can if needed, And I do tested everything locally myself to take the screenshots :) ## 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: @noleet --- resources/lang/en.json | 1 + src/client/LeaderboardModal.ts | 5 +++++ tests/client/LeaderboardModal.test.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 546323c80..0650ae6a3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -230,6 +230,7 @@ "title": "Leaderboard", "ranked_tab": "1v1 Ranked", "clans_tab": "Clans", + "refresh_time": "Refreshed every 1 hour", "loading": "Loading...", "error": "Error loading leaderboard", "no_stats": "No stats available", diff --git a/src/client/LeaderboardModal.ts b/src/client/LeaderboardModal.ts index 33c4c809f..9c9f40a53 100644 --- a/src/client/LeaderboardModal.ts +++ b/src/client/LeaderboardModal.ts @@ -76,6 +76,10 @@ export class LeaderboardModal extends BaseModal { >(${start} - ${end})`; } + const refreshTime = html`(${translateText("leaderboard_modal.refresh_time")})`; const content = html`
${this.activeTab === "clans" ? dateRange : ""} + ${this.activeTab === "players" ? refreshTime : ""}
`, onBack: this.close, diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index 91a07bde0..5f771604e 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -17,6 +17,7 @@ vi.mock("../../src/client/Utils", () => ({ "leaderboard_modal.title": "Leaderboard", "leaderboard_modal.ranked_tab": "Ranked", "leaderboard_modal.clans_tab": "Clans", + "leaderboard_modal.refresh_time": "Refreshed every 1 hour", "leaderboard_modal.error": "Something went wrong", "leaderboard_modal.rank": "Rank", "leaderboard_modal.clan": "Clan", From a1b3afe5341eaf7856d7f9102c9e1151c8e59af4 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 13 Feb 2026 00:00:56 +0100 Subject: [PATCH 5/5] Fix cluster deletion (#3185) ## Description: When a train station is removed, the clusters are recomputed. However the cluster recomputation code has not been changed from the original rail network implementation, which was a tree. The deletion code made assumptions that are not true anymore since we introduced loops in the network. As a result the cluster recomputation was very inefficient, although the data was correct. Changes: - Fix clusters computation when a structure is deleted - Structures are frequently deleted in bulk: atom/hydro/MIRV. Re-computing the clusters when a single structure is deleted would be inefficient because the recomputed cluster would probably need to be recomputed again in the same tick. Instead, when a structure is deleted, flag the cluster as "dirty", and recompute all the dirty clusters once per tick only. Previous performances (hydro over a dense area): image Now: 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: IngloriousTom --- src/core/GameRunner.ts | 7 +++ .../RecomputeRailClusterExecution.ts | 20 ++++++++ src/core/game/RailNetwork.ts | 1 + src/core/game/RailNetworkImpl.ts | 47 +++++++++++++------ src/core/game/TrainStation.ts | 6 ++- 5 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 src/core/execution/RecomputeRailClusterExecution.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 619fb2645..856e19691 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,6 +1,7 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; import { getConfig } from "./configuration/ConfigLoader"; import { Executor } from "./execution/ExecutionManager"; +import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllPlayers, @@ -16,6 +17,7 @@ import { PlayerInfo, PlayerProfile, PlayerType, + UnitType, } from "./game/Game"; import { createGame } from "./game/GameImpl"; import { TileRef } from "./game/GameMap"; @@ -105,6 +107,11 @@ export class GameRunner { this.game.addExecution(...this.execManager.nationExecutions()); } this.game.addExecution(new WinCheckExecution()); + if (!this.game.config().isUnitDisabled(UnitType.Factory)) { + this.game.addExecution( + new RecomputeRailClusterExecution(this.game.railNetwork()), + ); + } } public addTurn(turn: Turn): void { diff --git a/src/core/execution/RecomputeRailClusterExecution.ts b/src/core/execution/RecomputeRailClusterExecution.ts new file mode 100644 index 000000000..c346ca481 --- /dev/null +++ b/src/core/execution/RecomputeRailClusterExecution.ts @@ -0,0 +1,20 @@ +import { Execution, Game } from "../game/Game"; +import { RailNetwork } from "../game/RailNetwork"; + +export class RecomputeRailClusterExecution implements Execution { + constructor(private railNetwork: RailNetwork) {} + + isActive(): boolean { + return true; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + init(mg: Game, ticks: number): void {} + + tick(ticks: number): void { + this.railNetwork.recomputeClusters(); + } +} diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 52d9fbe0d..7ad57c610 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -9,4 +9,5 @@ export interface RailNetwork { findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; overlappingRailroads(tile: TileRef): number[]; + recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 61227c6a9..b35fb80e8 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -86,6 +86,7 @@ export class RailNetworkImpl implements RailNetwork { private gridCellSize: number = 4; private railGrid: RailSpatialGrid; private nextId: number = 0; + private dirtyClusters = new Set(); constructor( private game: Game, @@ -106,26 +107,48 @@ export class RailNetworkImpl implements RailNetwork { } } + recomputeClusters() { + if (this.dirtyClusters.size === 0) return; + + for (const cluster of this.dirtyClusters) { + const allOriginalStations = new Set(cluster.stations); + while (allOriginalStations.size > 0) { + const nextStation = allOriginalStations.values().next().value; + const allConnectedStations = this.computeCluster(nextStation); + // Filter stations that are connected to the current cluster + for (const connectedStation of allConnectedStations) { + allOriginalStations.delete(connectedStation); + } + // Those stations were disconnected: new cluster + if (allOriginalStations.size > 0) { + const newCluster = new Cluster(); + // Switching their cluster will automatically remove them from their current cluster + newCluster.addStations(allConnectedStations); + } + } + } + this.dirtyClusters.clear(); + } + removeStation(unit: Unit): void { const station = this._stationManager.findStation(unit); if (!station) return; - const neighbors = station.neighbors(); this.disconnectFromNetwork(station); this._stationManager.removeStation(station); + station.unit.setTrainStation(false); const cluster = station.getCluster(); if (!cluster) return; - if (neighbors.length === 1) { - cluster.removeStation(station); - } else if (neighbors.length > 1) { - for (const neighbor of neighbors) { - const stations = this.computeCluster(neighbor); - const newCluster = new Cluster(); - newCluster.addStations(stations); - } + + cluster.removeStation(station); + if (cluster.size() === 0) { + this.deleteCluster(cluster); + this.dirtyClusters.delete(cluster); + return; } - station.unit.setTrainStation(false); + + this.dirtyClusters.add(cluster); } /** @@ -258,10 +281,6 @@ export class RailNetworkImpl implements RailNetwork { this.railGrid.unregister(rail); } station.clearRailroads(); - const cluster = station.getCluster(); - if (cluster !== null && cluster.size() === 1) { - this.deleteCluster(cluster); - } } private deleteCluster(cluster: Cluster) { diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 6a38c9f8f..e2b687a6f 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -53,7 +53,7 @@ export class TrainStation { id: number = -1; // assigned by StationManager private readonly stopHandlers: Partial> = {}; - private cluster: Cluster | null; + private cluster: Cluster | null = null; private railroads: Set = new Set(); // Quick lookup from neighboring station to connecting railroad private railroadByNeighbor: Map = new Map(); @@ -129,6 +129,10 @@ export class TrainStation { } setCluster(cluster: Cluster | null) { + // Properly disconnect cluster if it's already set + if (this.cluster !== null) { + this.cluster.removeStation(this); + } this.cluster = cluster; }