diff --git a/resources/lang/en.json b/resources/lang/en.json index d9966976e0..1739779358 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -404,6 +404,19 @@ "host_modal": { "title": "Create Private Lobby", "label": "Private", + "presets_title": "Presets", + "presets_select": "Saved presets", + "presets_default": "Default", + "presets_name": "Preset name", + "presets_name_placeholder": "Enter preset name", + "presets_apply": "Apply", + "presets_save": "Save", + "presets_delete": "Delete", + "presets_auto_apply": "Auto-apply last used preset", + "presets_saved": "Preset saved: {name}", + "presets_applied": "Preset applied: {name}", + "presets_deleted": "Preset deleted: {name}", + "presets_not_found": "Preset not found", "mode": "Mode", "team_count": "Number of Teams", "team_type": "Team Type", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 8b3e314f45..d024f7a2e6 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,11 +1,10 @@ import { TemplateResult, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, Duos, - GameMapSize, GameMapType, GameMode, HumansVsNations, @@ -27,49 +26,63 @@ import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyPlayerView"; +import "./components/LobbyPresetControls"; +import type { LobbyPresetControls } from "./components/LobbyPresetControls"; import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { + GameConfigPatch, + applyHostLobbyGameConfigPatch, + buildHostLobbyGameConfigPatch, + buildPrivateLobbyGameConfig, + resetHostLobbyGameConfigFormState, +} from "./utilities/GameConfigFormState"; import { renderToggleInputCard, renderToggleInputCardInput, } from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; + @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { - @state() private selectedMap: GameMapType = GameMapType.World; - @state() private selectedDifficulty: Difficulty = Difficulty.Easy; - @state() private disableNations = false; - @state() private gameMode: GameMode = GameMode.FFA; - @state() private teamCount: TeamCountConfig = 2; + @query("lobby-preset-controls") private presetControls?: LobbyPresetControls; + + @state() private selectedMap!: GameMapType; + @state() private selectedDifficulty!: Difficulty; + @state() private disableNations!: boolean; + @state() private gameMode!: GameMode; + @state() private teamCount!: TeamCountConfig; constructor() { super(); this.id = "page-host-lobby"; + this.resetGameSettingsToDefaults(); } - @state() private bots: number = 400; - @state() private spawnImmunity: boolean = false; - @state() private spawnImmunityDurationMinutes: number | undefined = undefined; - @state() private infiniteGold: boolean = false; - @state() private donateGold: boolean = false; - @state() private infiniteTroops: boolean = false; - @state() private donateTroops: boolean = false; - @state() private maxTimer: boolean = false; - @state() private maxTimerValue: number | undefined = undefined; - @state() private instantBuild: boolean = false; - @state() private randomSpawn: boolean = false; - @state() private compactMap: boolean = false; - @state() private goldMultiplier: boolean = false; - @state() private goldMultiplierValue: number | undefined = undefined; - @state() private startingGold: boolean = false; - @state() private startingGoldValue: number | undefined = undefined; + + @state() private bots!: number; + @state() private spawnImmunity!: boolean; + @state() private spawnImmunityDurationMinutes!: number | undefined; + @state() private infiniteGold!: boolean; + @state() private donateGold!: boolean; + @state() private infiniteTroops!: boolean; + @state() private donateTroops!: boolean; + @state() private maxTimer!: boolean; + @state() private maxTimerValue!: number | undefined; + @state() private instantBuild!: boolean; + @state() private randomSpawn!: boolean; + @state() private compactMap!: boolean; + @state() private goldMultiplier!: boolean; + @state() private goldMultiplierValue!: number | undefined; + @state() private startingGold!: boolean; + @state() private startingGoldValue!: number | undefined; @state() private lobbyId = ""; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; - @state() private useRandomMap: boolean = false; - @state() private disabledUnits: UnitType[] = []; + @state() private useRandomMap!: boolean; + @state() private disabledUnits!: UnitType[]; @state() private lobbyCreatorClientID: string = ""; @state() private nationCount: number = 0; @@ -136,6 +149,19 @@ export class HostLobbyModal extends BaseModal { } } + private handlePresetApply = async (patch: GameConfigPatch) => { + this.applyGameConfigPatch(patch); + await this.loadNationCount(); + await this.putGameConfig(); + }; + + private handlePresetReset = async () => { + this.resetGameSettingsToDefaults(); + this.requestUpdate(); + await this.loadNationCount(); + await this.putGameConfig(); + }; + render() { const maxTimerHandlers = this.createToggleHandlers( () => this.maxTimer, @@ -190,6 +216,14 @@ export class HostLobbyModal extends BaseModal {
+ + this.buildGameConfigPatch()} + .onApplyPreset=${this.handlePresetApply} + .onResetPreset=${this.handlePresetReset} + > +
{ this.lobbyId = lobby.gameID; if (!isValidGameID(this.lobbyId)) { @@ -658,6 +695,9 @@ export class HostLobbyModal extends BaseModal { composed: true, }), ); + if (autoApplyPreset) { + this.putGameConfig(); + } }); if (this.modalEl) { this.modalEl.onClose = () => { @@ -734,33 +774,11 @@ export class HostLobbyModal extends BaseModal { } // Reset all transient form state to ensure clean slate - this.selectedMap = GameMapType.World; - this.selectedDifficulty = Difficulty.Easy; - this.disableNations = false; - this.gameMode = GameMode.FFA; - this.teamCount = 2; - this.bots = 400; - this.spawnImmunity = false; - this.spawnImmunityDurationMinutes = undefined; - this.infiniteGold = false; - this.donateGold = false; - this.infiniteTroops = false; - this.donateTroops = false; - this.maxTimer = false; - this.maxTimerValue = undefined; - this.instantBuild = false; - this.randomSpawn = false; - this.compactMap = false; - this.useRandomMap = false; - this.disabledUnits = []; + this.resetGameSettingsToDefaults(); this.lobbyId = ""; this.clients = []; this.lobbyCreatorClientID = ""; this.nationCount = 0; - this.goldMultiplier = false; - this.goldMultiplierValue = undefined; - this.startingGold = false; - this.startingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -944,51 +962,29 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private resetGameSettingsToDefaults(): void { + resetHostLobbyGameConfigFormState(this); + } + + private buildFullGameConfig(): GameConfig { + return buildPrivateLobbyGameConfig(this); + } + + private buildGameConfigPatch(): Partial { + return buildHostLobbyGameConfigPatch(this); + } + + private applyGameConfigPatch(patch: GameConfigPatch): void { + applyHostLobbyGameConfigPatch(this, patch); + } + private async putGameConfig() { - const spawnImmunityTicks = this.spawnImmunityDurationMinutes - ? this.spawnImmunityDurationMinutes * 60 * 10 - : 0; const url = await this.constructUrl(); this.updateHistory(url); this.dispatchEvent( new CustomEvent("update-game-config", { detail: { - config: { - gameMap: this.selectedMap, - gameMapSize: this.compactMap - ? GameMapSize.Compact - : GameMapSize.Normal, - difficulty: this.selectedDifficulty, - bots: this.bots, - infiniteGold: this.infiniteGold, - donateGold: this.donateGold, - infiniteTroops: this.infiniteTroops, - donateTroops: this.donateTroops, - instantBuild: this.instantBuild, - randomSpawn: this.randomSpawn, - gameMode: this.gameMode, - disabledUnits: this.disabledUnits, - spawnImmunityDuration: this.spawnImmunity - ? spawnImmunityTicks - : undefined, - playerTeams: this.teamCount, - ...(this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ? { - disableNations: false, - } - : { - disableNations: this.disableNations, - }), - maxTimerValue: - this.maxTimer === true ? this.maxTimerValue : undefined, - goldMultiplier: - this.goldMultiplier === true - ? this.goldMultiplierValue - : undefined, - startingGold: - this.startingGold === true ? this.startingGoldValue : undefined, - } satisfies Partial, + config: this.buildGameConfigPatch(), }, bubbles: true, composed: true, @@ -1080,18 +1076,21 @@ export class HostLobbyModal extends BaseModal { } } -async function createLobby(creatorClientID: string): Promise { - const config = await getServerConfigFromClient(); +async function createLobby( + creatorClientID: string, + gameConfig: GameConfig, +): Promise { + const serverConfig = await getServerConfigFromClient(); try { const id = generateID(); const response = await fetch( - `/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`, + `/${serverConfig.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`, { method: "POST", headers: { "Content-Type": "application/json", }, - // body: JSON.stringify(data), // Include this if you need to send data + body: JSON.stringify(gameConfig), }, ); diff --git a/src/client/LobbyPresets.ts b/src/client/LobbyPresets.ts new file mode 100644 index 0000000000..95ef90e2e0 --- /dev/null +++ b/src/client/LobbyPresets.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { GameConfigSchema } from "../core/Schemas"; +import { generateCryptoRandomUUID } from "./Utils"; + +const LOBBY_PRESET_STORAGE_KEY = "lobbyPresets.v1"; + +export const LobbyPresetGameConfigPatchSchema = + GameConfigSchema.partial().extend({ + maxTimerValue: GameConfigSchema.shape.maxTimerValue + .unwrap() + .nullable() + .optional(), + goldMultiplier: GameConfigSchema.shape.goldMultiplier + .unwrap() + .nullable() + .optional(), + startingGold: GameConfigSchema.shape.startingGold + .unwrap() + .nullable() + .optional(), + spawnImmunityDuration: GameConfigSchema.shape.spawnImmunityDuration + .unwrap() + .nullable() + .optional(), + }); +export type LobbyPresetGameConfigPatch = z.infer< + typeof LobbyPresetGameConfigPatchSchema +>; + +export const LobbyPresetSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(40), + createdAt: z.number(), + updatedAt: z.number(), + config: LobbyPresetGameConfigPatchSchema, +}); +export type LobbyPreset = z.infer; + +export const LobbyPresetStoreSchema = z.object({ + version: z.literal(1), + presets: LobbyPresetSchema.array(), + lastUsedPresetId: z.string().optional(), + autoApplyLastUsed: z.boolean().optional(), +}); +export type LobbyPresetStore = z.infer; + +function emptyLobbyPresetStore(): LobbyPresetStore { + return { + version: 1, + presets: [], + }; +} + +export function loadLobbyPresetStore(): LobbyPresetStore { + if (typeof localStorage === "undefined") { + return emptyLobbyPresetStore(); + } + + const raw = localStorage.getItem(LOBBY_PRESET_STORAGE_KEY); + if (!raw) { + return emptyLobbyPresetStore(); + } + + try { + const parsed = JSON.parse(raw); + const result = LobbyPresetStoreSchema.safeParse(parsed); + if (result.success) { + return result.data; + } + } catch { + return emptyLobbyPresetStore(); + } + + return emptyLobbyPresetStore(); +} + +export function saveLobbyPresetStore(store: LobbyPresetStore): void { + if (typeof localStorage === "undefined") { + return; + } + + const result = LobbyPresetStoreSchema.safeParse(store); + if (!result.success) { + return; + } + + try { + localStorage.setItem(LOBBY_PRESET_STORAGE_KEY, JSON.stringify(result.data)); + } catch { + return; + } +} + +export function listPresets(): LobbyPreset[] { + return loadLobbyPresetStore().presets; +} + +export function upsertPreset(input: { + id?: string; + name: string; + config: LobbyPresetGameConfigPatch; +}): LobbyPreset { + const store = loadLobbyPresetStore(); + const now = Date.now(); + let preset: LobbyPreset | undefined; + + const existingIndex = input.id + ? store.presets.findIndex((candidate) => candidate.id === input.id) + : -1; + if (existingIndex >= 0) { + const existing = store.presets[existingIndex]; + // If the name differs, treat this as "save as" and create a new preset. + if (existing.name === input.name) { + preset = { + ...existing, + name: input.name, + config: input.config, + updatedAt: now, + }; + store.presets[existingIndex] = preset; + } + } + + if (!preset) { + preset = { + id: + existingIndex >= 0 + ? generateCryptoRandomUUID() + : (input.id ?? generateCryptoRandomUUID()), + name: input.name, + createdAt: now, + updatedAt: now, + config: input.config, + }; + store.presets.push(preset); + } + + saveLobbyPresetStore(store); + return preset; +} + +export function deletePreset(id: string): void { + const store = loadLobbyPresetStore(); + store.presets = store.presets.filter((preset) => preset.id !== id); + if (store.lastUsedPresetId === id) { + delete store.lastUsedPresetId; + } + saveLobbyPresetStore(store); +} + +export function setLastUsedPresetId(id: string | undefined): void { + const store = loadLobbyPresetStore(); + if (id === undefined) { + delete store.lastUsedPresetId; + } else { + store.lastUsedPresetId = id; + } + saveLobbyPresetStore(store); +} + +export function setAutoApplyLastUsed(enabled: boolean): void { + const store = loadLobbyPresetStore(); + store.autoApplyLastUsed = enabled; + saveLobbyPresetStore(store); +} diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 7a10f21aae..9f02a31dad 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -1,21 +1,19 @@ -import { TemplateResult, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { html, TemplateResult } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; 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"; -import { TeamCountConfig } from "../core/Schemas"; +import { GameConfig, TeamCountConfig } from "../core/Schemas"; import { generateID } from "../core/Util"; import { hasLinkedAccount } from "./Api"; import "./components/baseComponents/Button"; @@ -23,6 +21,8 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; +import "./components/LobbyPresetControls"; +import type { LobbyPresetControls } from "./components/LobbyPresetControls"; import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; @@ -30,6 +30,13 @@ import { crazyGamesSDK } from "./CrazyGamesSDK"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; +import { + applySinglePlayerGameConfigPatch, + buildSinglePlayerGameConfig, + buildSinglePlayerGameConfigPatch, + GameConfigPatch, + resetSinglePlayerGameConfigFormState, +} from "./utilities/GameConfigFormState"; import { renderToggleInputCard, renderToggleInputCardInput, @@ -60,38 +67,39 @@ const DEFAULT_OPTIONS = { @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { - @state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap; - @state() private selectedDifficulty: Difficulty = - DEFAULT_OPTIONS.selectedDifficulty; - @state() private disableNations: boolean = DEFAULT_OPTIONS.disableNations; - @state() private bots: number = DEFAULT_OPTIONS.bots; - @state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold; - @state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops; - @state() private compactMap: boolean = DEFAULT_OPTIONS.compactMap; - @state() private maxTimer: boolean = DEFAULT_OPTIONS.maxTimer; - @state() private maxTimerValue: number | undefined = - DEFAULT_OPTIONS.maxTimerValue; - @state() private instantBuild: boolean = DEFAULT_OPTIONS.instantBuild; - @state() private randomSpawn: boolean = DEFAULT_OPTIONS.randomSpawn; - @state() private useRandomMap: boolean = DEFAULT_OPTIONS.useRandomMap; - @state() private gameMode: GameMode = DEFAULT_OPTIONS.gameMode; - @state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount; + @query("lobby-preset-controls") private presetControls?: LobbyPresetControls; + + @state() private selectedMap!: GameMapType; + @state() private selectedDifficulty!: Difficulty; + @state() private disableNations!: boolean; + @state() private bots!: number; + @state() private infiniteGold!: boolean; + @state() private infiniteTroops!: boolean; + @state() private compactMap!: boolean; + @state() private maxTimer!: boolean; + @state() private maxTimerValue!: number | undefined; + @state() private instantBuild!: boolean; + @state() private randomSpawn!: boolean; + @state() private useRandomMap!: boolean; + @state() private gameMode!: GameMode; + @state() private teamCount!: TeamCountConfig; @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; - @state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier; - @state() private goldMultiplierValue: number | undefined = - DEFAULT_OPTIONS.goldMultiplierValue; - @state() private startingGold: boolean = DEFAULT_OPTIONS.startingGold; - @state() private startingGoldValue: number | undefined = - DEFAULT_OPTIONS.startingGoldValue; + @state() private goldMultiplier!: boolean; + @state() private goldMultiplierValue!: number | undefined; + @state() private startingGold!: boolean; + @state() private startingGoldValue!: number | undefined; - @state() private disabledUnits: UnitType[] = [ - ...DEFAULT_OPTIONS.disabledUnits, - ]; + @state() private disabledUnits!: UnitType[]; private userSettings: UserSettings = new UserSettings(); + constructor() { + super(); + this.resetGameSettingsToDefaults(); + } + connectedCallback() { super.connectedCallback(); document.addEventListener( @@ -108,6 +116,11 @@ export class SinglePlayerModal extends BaseModal { super.disconnectedCallback(); } + protected onOpen(): void { + const autoApplyPreset = this.presetControls?.syncFromStore(); + if (autoApplyPreset) this.applyGameConfigPatch(autoApplyPreset.config); + } + private toggleAchievements = () => { this.showAchievements = !this.showAchievements; }; @@ -165,6 +178,27 @@ export class SinglePlayerModal extends BaseModal { this.mapWins = winsMap; } + private handlePresetApply = async (patch: GameConfigPatch) => { + this.applyGameConfigPatch(patch); + }; + + private handlePresetReset = async () => { + this.resetGameSettingsToDefaults(); + this.requestUpdate(); + }; + + private buildGameConfigPatch(): Partial { + return buildSinglePlayerGameConfigPatch(this); + } + + private applyGameConfigPatch(patch: GameConfigPatch): void { + applySinglePlayerGameConfigPatch(this, patch); + } + + private resetGameSettingsToDefaults(): void { + resetSinglePlayerGameConfigFormState(this); + } + render() { const content = html`
+ this.buildGameConfigPatch()} + .onApplyPreset=${this.handlePresetApply} + .onResetPreset=${this.handlePresetReset} + > +
Object.values(UnitType).find((ut) => ut === u)) + .filter((ut): ut is UnitType => ut !== undefined); + const config: GameConfig = { + ...buildSinglePlayerGameConfig(this), + maxTimerValue: finalMaxTimerValue, + disabledUnits, + }; await crazyGamesSDK.requestMidgameAd(); @@ -930,41 +960,7 @@ export class SinglePlayerModal extends BaseModal { }, }, ], - config: { - gameMap: this.selectedMap, - gameMapSize: this.compactMap - ? GameMapSize.Compact - : GameMapSize.Normal, - gameType: GameType.Singleplayer, - gameMode: this.gameMode, - playerTeams: this.teamCount, - difficulty: this.selectedDifficulty, - maxTimerValue: finalMaxTimerValue, - bots: this.bots, - infiniteGold: this.infiniteGold, - donateGold: this.gameMode === GameMode.Team, - donateTroops: this.gameMode === GameMode.Team, - infiniteTroops: this.infiniteTroops, - instantBuild: this.instantBuild, - randomSpawn: this.randomSpawn, - disabledUnits: this.disabledUnits - .map((u) => Object.values(UnitType).find((ut) => ut === u)) - .filter((ut): ut is UnitType => ut !== undefined), - ...(this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ? { - disableNations: false, - } - : { - disableNations: this.disableNations, - }), - ...(this.goldMultiplier && this.goldMultiplierValue - ? { goldMultiplier: this.goldMultiplierValue } - : {}), - ...(this.startingGold && this.startingGoldValue !== undefined - ? { startingGold: this.startingGoldValue } - : {}), - }, + config, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, } satisfies JoinLobbyEvent, diff --git a/src/client/components/LobbyPresetControls.ts b/src/client/components/LobbyPresetControls.ts new file mode 100644 index 0000000000..becdc3cbaa --- /dev/null +++ b/src/client/components/LobbyPresetControls.ts @@ -0,0 +1,265 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { GameConfig } from "../../core/Schemas"; +import { + LobbyPreset, + LobbyPresetGameConfigPatch, + deletePreset, + loadLobbyPresetStore, + setAutoApplyLastUsed, + setLastUsedPresetId, + upsertPreset, +} from "../LobbyPresets"; +import { generateCryptoRandomUUID, translateText } from "../Utils"; +import { GameConfigPatch } from "../utilities/GameConfigFormState"; + +type GetConfigPatch = () => Partial; +type ApplyPreset = (patch: GameConfigPatch) => void | Promise; +type ResetPreset = () => void | Promise; + +@customElement("lobby-preset-controls") +export class LobbyPresetControls extends LitElement { + @property({ attribute: false }) getConfigPatch?: GetConfigPatch; + @property({ attribute: false }) onApplyPreset?: ApplyPreset; + @property({ attribute: false }) onResetPreset?: ResetPreset; + + @state() private presetOptions: Array<{ id: string; name: string }> = []; + @state() private selectedPresetId: string | undefined = undefined; + @state() private presetNameInput: string = ""; + @state() private autoApplyLastUsedPreset: boolean = false; + + private readonly presetSelectId = `preset-select-${generateCryptoRandomUUID()}`; + private readonly presetNameId = `preset-name-${generateCryptoRandomUUID()}`; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.style.display = "block"; + } + + public syncFromStore(preferredSelectionId?: string): LobbyPreset | undefined { + const store = loadLobbyPresetStore(); + + this.presetOptions = store.presets.map((preset) => ({ + id: preset.id, + name: preset.name, + })); + this.autoApplyLastUsedPreset = store.autoApplyLastUsed ?? false; + + const selectionId = + preferredSelectionId ?? + (this.autoApplyLastUsedPreset ? store.lastUsedPresetId : undefined); + const selectedPreset = selectionId + ? store.presets.find((preset) => preset.id === selectionId) + : undefined; + + this.selectedPresetId = selectedPreset?.id; + this.presetNameInput = selectedPreset?.name ?? ""; + + return selectedPreset; + } + + private showMessage(message: string, color: "green" | "red" = "green") { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { message, duration: 2000, color }, + }), + ); + } + + private async handlePresetSelectionChange(e: Event) { + const value = (e.target as HTMLSelectElement).value; + this.selectedPresetId = value || undefined; + + if (!this.selectedPresetId) { + this.presetNameInput = ""; + setLastUsedPresetId(undefined); + await this.onResetPreset?.(); + return; + } + + const store = loadLobbyPresetStore(); + const preset = store.presets.find( + (candidate) => candidate.id === this.selectedPresetId, + ); + if (!preset) { + this.showMessage(translateText("host_modal.presets_not_found"), "red"); + this.selectedPresetId = undefined; + this.presetNameInput = ""; + setLastUsedPresetId(undefined); + return; + } + + this.presetNameInput = preset.name; + await this.onApplyPreset?.(preset.config); + setLastUsedPresetId(preset.id); + this.showMessage( + translateText("host_modal.presets_applied", { name: preset.name }), + ); + } + + private handlePresetNameInput(e: Event) { + this.presetNameInput = (e.target as HTMLInputElement).value; + } + + private handlePresetSaveClick() { + const name = this.presetNameInput.trim(); + if (!name) return; + if (!this.getConfigPatch) return; + + const rawPatch = this.getConfigPatch(); + const config: LobbyPresetGameConfigPatch = { + ...rawPatch, + maxTimerValue: rawPatch.maxTimerValue ?? null, + goldMultiplier: rawPatch.goldMultiplier ?? null, + startingGold: rawPatch.startingGold ?? null, + spawnImmunityDuration: rawPatch.spawnImmunityDuration ?? null, + }; + + const preset = upsertPreset({ + id: this.selectedPresetId, + name, + config, + }); + + setLastUsedPresetId(preset.id); + this.syncFromStore(preset.id); + this.showMessage( + translateText("host_modal.presets_saved", { name: preset.name }), + ); + } + + private handlePresetDeleteClick() { + if (!this.selectedPresetId) return; + const store = loadLobbyPresetStore(); + const preset = store.presets.find( + (candidate) => candidate.id === this.selectedPresetId, + ); + + deletePreset(this.selectedPresetId); + this.syncFromStore(); + this.showMessage( + translateText("host_modal.presets_deleted", { + name: preset?.name ?? "", + }), + ); + } + + private handleAutoApplyPresetChange(e: Event) { + this.autoApplyLastUsedPreset = (e.target as HTMLInputElement).checked; + setAutoApplyLastUsed(this.autoApplyLastUsedPreset); + } + + render() { + const presetButtonClass = (enabled: boolean) => + `px-4 py-3 rounded-xl border transition-all duration-200 text-xs font-bold uppercase tracking-wider flex-1 ${ + enabled + ? "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-white" + : "bg-white/5 border-white/5 text-white/30 cursor-not-allowed" + }`; + const hasSelectedPreset = Boolean(this.selectedPresetId); + const hasPresetName = this.presetNameInput.trim().length > 0; + + return html` +
+
+
+ + + +
+

+ ${translateText("host_modal.presets_title")} +

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ `; + } +} diff --git a/src/client/utilities/GameConfigFormState.ts b/src/client/utilities/GameConfigFormState.ts new file mode 100644 index 0000000000..6532a24131 --- /dev/null +++ b/src/client/utilities/GameConfigFormState.ts @@ -0,0 +1,250 @@ +import { + GameMapSize, + GameMode, + HumansVsNations, + UnitType, +} from "../../core/game/Game"; +import { + createDefaultPrivateGameConfig, + createDefaultSingleplayerGameConfig, +} from "../../core/game/GameConfigDefaults"; +import { GameConfig } from "../../core/Schemas"; + +type FormState = Record & { requestUpdate?: () => void }; + +export type GameConfigPatch = Omit< + Partial, + "maxTimerValue" | "goldMultiplier" | "startingGold" | "spawnImmunityDuration" +> & { + maxTimerValue?: number | null; + goldMultiplier?: number | null; + startingGold?: number | null; + spawnImmunityDuration?: number | null; +}; + +const TICKS_PER_MINUTE = 60 * 10; + +function resetCommonGameConfigFormState( + state: FormState, + defaultConfig: GameConfig, +): void { + state.selectedMap = defaultConfig.gameMap; + state.useRandomMap = false; + state.compactMap = defaultConfig.gameMapSize === GameMapSize.Compact; + state.selectedDifficulty = defaultConfig.difficulty; + state.disableNations = defaultConfig.disableNations; + state.gameMode = defaultConfig.gameMode; + state.teamCount = defaultConfig.playerTeams ?? 2; + state.bots = defaultConfig.bots; + state.infiniteGold = defaultConfig.infiniteGold; + state.infiniteTroops = defaultConfig.infiniteTroops; + state.instantBuild = defaultConfig.instantBuild; + state.randomSpawn = defaultConfig.randomSpawn; + state.disabledUnits = [...(defaultConfig.disabledUnits ?? [])]; + + const defaultMaxTimerValue = defaultConfig.maxTimerValue; + state.maxTimer = defaultMaxTimerValue !== undefined; + state.maxTimerValue = defaultMaxTimerValue; + + const defaultGoldMultiplier = defaultConfig.goldMultiplier; + state.goldMultiplier = defaultGoldMultiplier !== undefined; + state.goldMultiplierValue = defaultGoldMultiplier; + + const defaultStartingGold = defaultConfig.startingGold; + state.startingGold = defaultStartingGold !== undefined; + state.startingGoldValue = defaultStartingGold; +} + +function buildCommonGameConfigPatch(state: FormState): Partial { + return { + gameMap: state.selectedMap, + gameMapSize: state.compactMap ? GameMapSize.Compact : GameMapSize.Normal, + difficulty: state.selectedDifficulty, + disableNations: + state.gameMode === GameMode.Team && state.teamCount === HumansVsNations + ? false + : state.disableNations, + gameMode: state.gameMode, + playerTeams: state.teamCount, + bots: state.bots, + infiniteGold: state.infiniteGold, + infiniteTroops: state.infiniteTroops, + instantBuild: state.instantBuild, + randomSpawn: state.randomSpawn, + disabledUnits: state.disabledUnits as UnitType[], + maxTimerValue: state.maxTimer === true ? state.maxTimerValue : undefined, + goldMultiplier: + state.goldMultiplier === true ? state.goldMultiplierValue : undefined, + startingGold: + state.startingGold === true ? state.startingGoldValue : undefined, + }; +} + +function applyCommonGameConfigPatch( + state: FormState, + patch: GameConfigPatch, +): void { + if ("gameMap" in patch && patch.gameMap !== undefined) { + state.selectedMap = patch.gameMap; + state.useRandomMap = false; + } + + if ("gameMapSize" in patch && patch.gameMapSize !== undefined) { + state.compactMap = patch.gameMapSize === GameMapSize.Compact; + } + + if ("difficulty" in patch && patch.difficulty !== undefined) { + state.selectedDifficulty = patch.difficulty; + } + + if ("disableNations" in patch && patch.disableNations !== undefined) { + state.disableNations = patch.disableNations; + } + + if ("gameMode" in patch && patch.gameMode !== undefined) { + state.gameMode = patch.gameMode; + } + + if ("playerTeams" in patch && patch.playerTeams !== undefined) { + state.teamCount = patch.playerTeams; + } + + if ("bots" in patch && patch.bots !== undefined) { + state.bots = patch.bots; + } + + if ("infiniteGold" in patch && patch.infiniteGold !== undefined) { + state.infiniteGold = patch.infiniteGold; + } + + if ("infiniteTroops" in patch && patch.infiniteTroops !== undefined) { + state.infiniteTroops = patch.infiniteTroops; + } + + if ("instantBuild" in patch && patch.instantBuild !== undefined) { + state.instantBuild = patch.instantBuild; + } + + if ("randomSpawn" in patch && patch.randomSpawn !== undefined) { + state.randomSpawn = patch.randomSpawn; + } + + if ("disabledUnits" in patch) { + state.disabledUnits = patch.disabledUnits ?? []; + } + + if ("maxTimerValue" in patch) { + const value = patch.maxTimerValue; + state.maxTimer = value !== undefined && value !== null; + state.maxTimerValue = value === null ? undefined : value; + } + + if ("goldMultiplier" in patch) { + const value = patch.goldMultiplier; + state.goldMultiplier = value !== undefined && value !== null; + state.goldMultiplierValue = value === null ? undefined : value; + } + + if ("startingGold" in patch) { + const value = patch.startingGold; + state.startingGold = value !== undefined && value !== null; + state.startingGoldValue = value === null ? undefined : value; + } +} + +export function resetHostLobbyGameConfigFormState(state: FormState): void { + const defaultConfig = createDefaultPrivateGameConfig(); + resetCommonGameConfigFormState(state, defaultConfig); + + state.donateGold = defaultConfig.donateGold; + state.donateTroops = defaultConfig.donateTroops; + + const defaultSpawnImmunityTicks = defaultConfig.spawnImmunityDuration; + state.spawnImmunity = defaultSpawnImmunityTicks !== undefined; + state.spawnImmunityDurationMinutes = + defaultSpawnImmunityTicks === undefined + ? undefined + : defaultSpawnImmunityTicks / TICKS_PER_MINUTE; +} + +export function buildHostLobbyGameConfigPatch( + state: FormState, +): Partial { + const patch = buildCommonGameConfigPatch(state); + + patch.donateGold = state.donateGold; + patch.donateTroops = state.donateTroops; + + const spawnImmunityTicks = state.spawnImmunityDurationMinutes + ? state.spawnImmunityDurationMinutes * TICKS_PER_MINUTE + : 0; + patch.spawnImmunityDuration = state.spawnImmunity + ? spawnImmunityTicks + : undefined; + + return patch; +} + +export function applyHostLobbyGameConfigPatch( + state: FormState, + patch: GameConfigPatch, +): void { + applyCommonGameConfigPatch(state, patch); + + if ("donateGold" in patch && patch.donateGold !== undefined) { + state.donateGold = patch.donateGold; + } + + if ("donateTroops" in patch && patch.donateTroops !== undefined) { + state.donateTroops = patch.donateTroops; + } + + if ("spawnImmunityDuration" in patch) { + const ticks = patch.spawnImmunityDuration; + state.spawnImmunity = ticks !== undefined && ticks !== null; + state.spawnImmunityDurationMinutes = + ticks === undefined || ticks === null + ? undefined + : ticks / TICKS_PER_MINUTE; + } + + state.requestUpdate?.(); +} + +export function buildPrivateLobbyGameConfig(state: FormState): GameConfig { + return { + ...createDefaultPrivateGameConfig(), + ...buildHostLobbyGameConfigPatch(state), + }; +} + +export function resetSinglePlayerGameConfigFormState(state: FormState): void { + resetCommonGameConfigFormState(state, createDefaultSingleplayerGameConfig()); +} + +export function buildSinglePlayerGameConfigPatch( + state: FormState, +): Partial { + return buildCommonGameConfigPatch(state); +} + +export function applySinglePlayerGameConfigPatch( + state: FormState, + patch: GameConfigPatch, +): void { + applyCommonGameConfigPatch(state, patch); + state.requestUpdate?.(); +} + +export function buildSinglePlayerGameConfig(state: FormState): GameConfig { + const defaultConfig = createDefaultSingleplayerGameConfig(); + const patch = buildSinglePlayerGameConfigPatch(state); + const gameMode = patch.gameMode ?? defaultConfig.gameMode; + + return { + ...defaultConfig, + ...patch, + donateGold: gameMode === GameMode.Team, + donateTroops: gameMode === GameMode.Team, + }; +} diff --git a/src/core/game/GameConfigDefaults.ts b/src/core/game/GameConfigDefaults.ts new file mode 100644 index 0000000000..d46b8b268e --- /dev/null +++ b/src/core/game/GameConfigDefaults.ts @@ -0,0 +1,47 @@ +import { GameConfig } from "../Schemas"; +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "./Game"; + +type BaseGameConfig = Omit; + +const DEFAULT_BASE_GAME_CONFIG: BaseGameConfig = { + donateGold: false, + donateTroops: false, + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + difficulty: Difficulty.Easy, + disableNations: false, + infiniteGold: false, + infiniteTroops: false, + maxTimerValue: undefined, + instantBuild: false, + randomSpawn: false, + gameMode: GameMode.FFA, + bots: 400, + disabledUnits: [], +}; + +function createDefaultGameConfig(gameType: GameType): GameConfig { + return { + ...DEFAULT_BASE_GAME_CONFIG, + gameType, + disabledUnits: [...(DEFAULT_BASE_GAME_CONFIG.disabledUnits ?? [])], + }; +} + +export function createDefaultPrivateGameConfig(): GameConfig { + return createDefaultGameConfig(GameType.Private); +} + +export function createDefaultPublicGameConfig(): GameConfig { + return createDefaultGameConfig(GameType.Public); +} + +export function createDefaultSingleplayerGameConfig(): GameConfig { + return createDefaultGameConfig(GameType.Singleplayer); +} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index d2588ae00a..533e772fe6 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -1,13 +1,12 @@ import { Logger } from "winston"; import WebSocket from "ws"; import { ServerConfig } from "../core/configuration/Config"; +import { GameType } from "../core/game/Game"; import { - Difficulty, - GameMapSize, - GameMapType, - GameMode, - GameType, -} from "../core/game/Game"; + createDefaultPrivateGameConfig, + createDefaultPublicGameConfig, + createDefaultSingleplayerGameConfig, +} from "../core/game/GameConfigDefaults"; import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas"; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; @@ -53,27 +52,24 @@ export class GameManager { gameConfig: GameConfig | undefined, creatorClientID?: string, ) { + const defaultConfig = (() => { + switch (gameConfig?.gameType) { + case GameType.Public: + return createDefaultPublicGameConfig(); + case GameType.Singleplayer: + return createDefaultSingleplayerGameConfig(); + case GameType.Private: + default: + return createDefaultPrivateGameConfig(); + } + })(); const game = new GameServer( id, this.log, Date.now(), this.config, { - donateGold: false, - donateTroops: false, - gameMap: GameMapType.World, - gameType: GameType.Private, - gameMapSize: GameMapSize.Normal, - difficulty: Difficulty.Medium, - disableNations: false, - infiniteGold: false, - infiniteTroops: false, - maxTimerValue: undefined, - instantBuild: false, - randomSpawn: false, - gameMode: GameMode.FFA, - bots: 400, - disabledUnits: [], + ...defaultConfig, ...gameConfig, }, creatorClientID, diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 9d5ba4f369..a63b9480dc 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -5,13 +5,13 @@ import { GameMapSize, GameMapType, GameMode, - GameType, HumansVsNations, PublicGameModifiers, Quads, RankedType, Trios, } from "../core/game/Game"; +import { createDefaultPublicGameConfig } from "../core/game/GameConfigDefaults"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig, TeamCountConfig } from "../core/Schemas"; import { logger } from "./Logger"; @@ -131,15 +131,15 @@ export class MapPlaylist { } } - // Create the default public game config (from your GameManager) + const defaultConfig = createDefaultPublicGameConfig(); return { + ...defaultConfig, donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, maxPlayers: crowdedMaxPlayers ?? (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), - gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, publicGameModifiers: { isCompact, @@ -149,18 +149,15 @@ export class MapPlaylist { }, startingGold, difficulty: - playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, - infiniteGold: false, - infiniteTroops: false, - maxTimerValue: undefined, - instantBuild: false, + playerTeams === HumansVsNations + ? Difficulty.Medium + : defaultConfig.difficulty, randomSpawn: isRandomSpawn, disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, gameMode: mode, playerTeams, - bots: isCompact ? 100 : 400, + bots: isCompact ? 100 : defaultConfig.bots, spawnImmunityDuration: 5 * 10, - disabledUnits: [], } satisfies GameConfig; } @@ -175,25 +172,17 @@ export class MapPlaylist { GameMapType.FalklandIslands, GameMapType.Sierpinski, ]; + const defaultConfig = createDefaultPublicGameConfig(); return { - donateGold: false, - donateTroops: false, + ...defaultConfig, gameMap: maps[Math.floor(Math.random() * maps.length)], maxPlayers: 2, - gameType: GameType.Public, gameMapSize: GameMapSize.Compact, - difficulty: Difficulty.Easy, rankedType: RankedType.OneVOne, - infiniteGold: false, - infiniteTroops: false, maxTimerValue: 10, // 10 minutes - instantBuild: false, - randomSpawn: false, disableNations: true, - gameMode: GameMode.FFA, bots: 100, spawnImmunityDuration: 30 * 10, - disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/client/LobbyPresets.test.ts b/tests/client/LobbyPresets.test.ts new file mode 100644 index 0000000000..ee9f5e776a --- /dev/null +++ b/tests/client/LobbyPresets.test.ts @@ -0,0 +1,108 @@ +import { + deletePreset, + loadLobbyPresetStore, + setAutoApplyLastUsed, + setLastUsedPresetId, + upsertPreset, +} from "../../src/client/LobbyPresets"; + +describe("LobbyPresets", () => { + beforeEach(() => { + localStorage.removeItem("lobbyPresets.v1"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("upsertPreset updates a preset when name is unchanged", () => { + const created = upsertPreset({ + name: "My Preset", + config: { bots: 123 }, + }); + + expect(created.id).toEqual(expect.any(String)); + expect(created.name).toBe("My Preset"); + expect(created.config.bots).toBe(123); + expect(created.createdAt).toBe(Date.now()); + expect(created.updatedAt).toBe(Date.now()); + + vi.setSystemTime(new Date("2026-01-01T00:00:10.000Z")); + + const updated = upsertPreset({ + id: created.id, + name: "My Preset", + config: { bots: 321 }, + }); + + expect(updated.id).toBe(created.id); + expect(updated.createdAt).toBe(created.createdAt); + expect(updated.updatedAt).toBe(Date.now()); + expect(updated.name).toBe("My Preset"); + expect(updated.config.bots).toBe(321); + + const store = loadLobbyPresetStore(); + expect(store.presets).toHaveLength(1); + expect(store.presets[0]).toMatchObject({ + id: created.id, + name: "My Preset", + createdAt: created.createdAt, + updatedAt: Date.now(), + config: { bots: 321 }, + }); + }); + + test("upsertPreset creates a new preset when name differs from existing", () => { + const created = upsertPreset({ + name: "Original Preset", + config: { bots: 100 }, + }); + + vi.setSystemTime(new Date("2026-01-01T00:01:00.000Z")); + + const duplicated = upsertPreset({ + id: created.id, + name: "Copied Preset", + config: { bots: 200 }, + }); + + expect(duplicated.id).not.toBe(created.id); + expect(duplicated.name).toBe("Copied Preset"); + expect(duplicated.createdAt).toBe(Date.now()); + expect(duplicated.updatedAt).toBe(Date.now()); + + const store = loadLobbyPresetStore(); + expect(store.presets).toHaveLength(2); + expect(store.presets.find((p) => p.id === created.id)).toMatchObject({ + id: created.id, + name: "Original Preset", + createdAt: created.createdAt, + updatedAt: created.updatedAt, + config: { bots: 100 }, + }); + expect(store.presets.find((p) => p.id === duplicated.id)).toMatchObject({ + id: duplicated.id, + name: "Copied Preset", + createdAt: duplicated.createdAt, + updatedAt: duplicated.updatedAt, + config: { bots: 200 }, + }); + }); + + test("deletePreset removes preset and clears lastUsedPresetId", () => { + const p1 = upsertPreset({ name: "Preset 1", config: { bots: 1 } }); + const p2 = upsertPreset({ name: "Preset 2", config: { bots: 2 } }); + + setLastUsedPresetId(p1.id); + setAutoApplyLastUsed(true); + + deletePreset(p1.id); + + const store = loadLobbyPresetStore(); + expect(store.presets.map((p) => p.id)).toEqual([p2.id]); + expect(store.lastUsedPresetId).toBeUndefined(); + expect(store.autoApplyLastUsed).toBe(true); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 940d2d5241..261110fa6d 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1 +1,67 @@ -// Add global mocks or configuration here if needed +type StorageLike = { + readonly length: number; + clear(): void; + getItem(key: string): string | null; + key(index: number): string | null; + removeItem(key: string): void; + setItem(key: string, value: string): void; +}; + +function isStorageLike(value: unknown): value is StorageLike { + const candidate = value as Partial | null | undefined; + if (!candidate) return false; + return ( + typeof candidate.getItem === "function" && + typeof candidate.setItem === "function" && + typeof candidate.removeItem === "function" && + typeof candidate.clear === "function" && + typeof candidate.key === "function" + ); +} + +function createMemoryStorage(): StorageLike { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string) { + return map.has(key) ? map.get(key)! : null; + }, + key(index: number) { + return Array.from(map.keys())[index] ?? null; + }, + removeItem(key: string) { + map.delete(String(key)); + }, + setItem(key: string, value: string) { + map.set(String(key), String(value)); + }, + }; +} + +function ensureWebStorage(storageKey: "localStorage" | "sessionStorage"): void { + let existing: unknown; + try { + existing = (globalThis as any)[storageKey]; + } catch { + existing = undefined; + } + + if (isStorageLike(existing)) { + return; + } + + Object.defineProperty(globalThis, storageKey, { + value: createMemoryStorage(), + writable: true, + enumerable: true, + configurable: true, + }); +} + +ensureWebStorage("localStorage"); +ensureWebStorage("sessionStorage");