diff --git a/resources/lang/en.json b/resources/lang/en.json index 8d41f9170..516d9645a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -23,7 +23,8 @@ "none": "None", "copied": "Copied!", "click_to_copy": "Click to copy", - "enabled": "Enabled" + "enabled": "Enabled", + "map_default": "Map default" }, "main": { "title": "OpenFront (ALPHA)", @@ -186,7 +187,8 @@ "options_title": "Options", "bots": "Tribes: ", "bots_disabled": "Disabled", - "disable_nations": "Disable Nations", + "nations": "Nations: ", + "nations_disabled": "Disabled", "instant_build": "Instant build", "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", @@ -401,8 +403,9 @@ "options_title": "Options", "bots": "Tribes: ", "bots_disabled": "Disabled", + "nations": "Nations: ", + "nations_disabled": "Disabled", "player_immunity_duration": "PVP immunity duration (minutes)", - "disable_nations": "Disable Nations", "max_timer": "Game length (minutes)", "mins_placeholder": "Mins", "instant_build": "Instant build", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 978fa12ac..1851b4e56 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -8,7 +8,6 @@ import { GameMapSize, GameMapType, GameMode, - HumansVsNations, UnitType, } from "../core/game/Game"; import { @@ -33,11 +32,13 @@ import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { getBotsForCompactMap, + getNationsForCompactMap, getRandomMapType, getUpdatedDisabledUnits, parseBoundedFloatFromInput, parseBoundedIntegerFromInput, preventDisallowedKeys, + sliderToNationsConfig, toOptionalNumber, } from "./utilities/GameConfigHelpers"; @@ -45,7 +46,8 @@ import { export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @state() private selectedDifficulty: Difficulty = Difficulty.Easy; - @state() private disableNations = false; + @state() private nations: number = 0; + @state() private defaultNationCount: number = 0; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @@ -75,11 +77,11 @@ export class HostLobbyModal extends BaseModal { @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; - @state() private nationCount: number = 0; @property({ attribute: false }) eventBus: EventBus | null = null; - // Add a new timer for debouncing bot changes + // Timers for debouncing slider changes private botsUpdateTimer: number | null = null; + private nationsUpdateTimer: number | null = null; private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -242,7 +244,7 @@ export class HostLobbyModal extends BaseModal { }, difficulty: { selected: this.selectedDifficulty, - disabled: this.disableNations, + disabled: this.nations === 0, }, gameMode: { selected: this.gameMode, @@ -257,14 +259,13 @@ export class HostLobbyModal extends BaseModal { labelKey: "host_modal.bots", disabledKey: "host_modal.bots_disabled", }, + nations: { + value: this.nations, + defaultValue: this.defaultNationCount, + labelKey: "host_modal.nations", + disabledKey: "host_modal.nations_disabled", + }, toggles: [ - { - labelKey: "host_modal.disable_nations", - checked: this.disableNations, - hidden: - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations, - }, { labelKey: "host_modal.instant_build", checked: this.instantBuild, @@ -307,6 +308,7 @@ export class HostLobbyModal extends BaseModal { @game-mode-selected=${this.handleConfigGameModeSelected} @team-count-selected=${this.handleConfigTeamCountSelected} @bots-changed=${this.handleBotsChange} + @nations-changed=${this.handleNationsChange} @option-toggle-changed=${this.handleConfigOptionToggleChanged} @unit-toggle-changed=${this.handleConfigUnitToggleChanged} > @@ -318,9 +320,7 @@ export class HostLobbyModal extends BaseModal { .lobbyCreatorClientID=${this.lobbyCreatorClientID} .currentClientID=${this.lobbyCreatorClientID} .teamCount=${this.teamCount} - .nationCount=${this.nationCount} - .disableNations=${this.disableNations} - .isCompactMap=${this.compactMap} + .nationCount=${this.nations} .onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)} > @@ -420,11 +420,16 @@ export class HostLobbyModal extends BaseModal { clearTimeout(this.botsUpdateTimer); this.botsUpdateTimer = null; } + if (this.nationsUpdateTimer !== null) { + clearTimeout(this.nationsUpdateTimer); + this.nationsUpdateTimer = null; + } // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; this.selectedDifficulty = Difficulty.Easy; - this.disableNations = false; + this.nations = 0; + this.defaultNationCount = 0; this.gameMode = GameMode.FFA; this.teamCount = 2; this.bots = 400; @@ -444,7 +449,6 @@ export class HostLobbyModal extends BaseModal { this.lobbyId = ""; this.clients = []; this.lobbyCreatorClientID = ""; - this.nationCount = 0; this.goldMultiplier = false; this.goldMultiplierValue = undefined; this.startingGold = false; @@ -504,9 +508,6 @@ export class HostLobbyModal extends BaseModal { 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; @@ -677,6 +678,11 @@ export class HostLobbyModal extends BaseModal { private handleCompactMapChange = (val: boolean) => { this.compactMap = val; this.bots = getBotsForCompactMap(this.bots, val); + this.nations = getNationsForCompactMap( + this.nations, + this.defaultNationCount, + val, + ); this.putGameConfig(); }; @@ -704,10 +710,21 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; - private handleDisableNationsChange = async (val: boolean) => { - this.disableNations = val; - console.log(`updating disable nations to ${this.disableNations}`); - this.putGameConfig(); + private handleNationsChange = (e: Event) => { + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; + if (isNaN(value) || value < 0 || value > 400) { + return; + } + this.nations = value; + + if (this.nationsUpdateTimer !== null) { + clearTimeout(this.nationsUpdateTimer); + } + this.nationsUpdateTimer = window.setTimeout(() => { + this.putGameConfig(); + this.nationsUpdateTimer = null; + }, 300); }; private async handleGameModeSelection(value: GameMode) { @@ -755,14 +772,10 @@ export class HostLobbyModal extends BaseModal { ? spawnImmunityTicks : undefined, playerTeams: this.teamCount, - ...(this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ? { - disableNations: false, - } - : { - disableNations: this.disableNations, - }), + nations: sliderToNationsConfig( + this.nations, + this.defaultNationCount, + ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, goldMultiplier: @@ -823,14 +836,14 @@ export class HostLobbyModal extends BaseModal { const manifest = await mapData.manifest(); // Only update if the map hasn't changed if (this.selectedMap === currentMap) { - this.nationCount = manifest.nations.length; + this.defaultNationCount = manifest.nations.length; + this.nations = this.compactMap + ? Math.max(0, Math.floor(manifest.nations.length * 0.25)) + : manifest.nations.length; } } catch (error) { console.warn("Failed to load nation count", error); - // Only update if the map hasn't changed - if (this.selectedMap === currentMap) { - this.nationCount = 0; - } + // Leave existing values unchanged so the UI stays consistent } } } diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 5ed20369a..892fe7537 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -19,12 +19,7 @@ import { PublicGameInfo, } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { - GameMapSize, - GameMode, - GameType, - HumansVsNations, -} from "../core/game/Game"; +import { GameMode, GameType, HumansVsNations } from "../core/game/Game"; import { getApiBase } from "./Api"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; @@ -34,6 +29,7 @@ import "./components/CopyButton"; import "./components/LobbyConfigItem"; import "./components/LobbyPlayerView"; import { modalHeader } from "./components/ui/ModalHeader"; +import { nationsConfigToSlider } from "./utilities/GameConfigHelpers"; @customElement("join-lobby-modal") export class JoinLobbyModal extends BaseModal { @@ -134,11 +130,10 @@ export class JoinLobbyModal extends BaseModal { .lobbyCreatorClientID=${hostClientID} .currentClientID=${this.currentClientID} .teamCount=${this.gameConfig?.playerTeams ?? 2} - .nationCount=${this.nationCount} - .disableNations=${this.gameConfig?.disableNations ?? - false} - .isCompactMap=${this.gameConfig?.gameMapSize === - GameMapSize.Compact} + .nationCount=${nationsConfigToSlider( + this.gameConfig?.nations ?? "default", + this.nationCount, + )} > ` : ""} diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 0b36f13a0..ba7508107 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -8,7 +8,6 @@ import { GameMapType, GameMode, GameType, - HumansVsNations, UnitType, } from "../core/game/Game"; import { TeamCountConfig } from "../core/Schemas"; @@ -26,18 +25,21 @@ import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; import { getBotsForCompactMap, + getNationsForCompactMap, getRandomMapType, getUpdatedDisabledUnits, parseBoundedFloatFromInput, parseBoundedIntegerFromInput, preventDisallowedKeys, + sliderToNationsConfig, toOptionalNumber, } from "./utilities/GameConfigHelpers"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; + const DEFAULT_OPTIONS = { selectedMap: GameMapType.World, selectedDifficulty: Difficulty.Easy, - disableNations: false, bots: 400, infiniteGold: false, infiniteTroops: false, @@ -61,7 +63,8 @@ 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 nations: number = 0; + @state() private defaultNationCount: number = 0; @state() private bots: number = DEFAULT_OPTIONS.bots; @state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold; @state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops; @@ -88,12 +91,15 @@ export class SinglePlayerModal extends BaseModal { ...DEFAULT_OPTIONS.disabledUnits, ]; + private mapLoader = terrainMapFileLoader; + connectedCallback() { super.connectedCallback(); document.addEventListener( "userMeResponse", this.handleUserMeResponse as EventListener, ); + void this.loadNationCount(); } disconnectedCallback() { @@ -265,7 +271,7 @@ export class SinglePlayerModal extends BaseModal { }, difficulty: { selected: this.selectedDifficulty, - disabled: this.disableNations, + disabled: this.nations === 0, }, gameMode: { selected: this.gameMode, @@ -280,14 +286,13 @@ export class SinglePlayerModal extends BaseModal { labelKey: "single_modal.bots", disabledKey: "single_modal.bots_disabled", }, + nations: { + value: this.nations, + defaultValue: this.defaultNationCount, + labelKey: "single_modal.nations", + disabledKey: "single_modal.nations_disabled", + }, toggles: [ - { - labelKey: "single_modal.disable_nations", - checked: this.disableNations, - hidden: - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations, - }, { labelKey: "single_modal.instant_build", checked: this.instantBuild, @@ -322,6 +327,7 @@ export class SinglePlayerModal extends BaseModal { @game-mode-selected=${this.handleConfigGameModeSelected} @team-count-selected=${this.handleConfigTeamCountSelected} @bots-changed=${this.handleBotsChange} + @nations-changed=${this.handleNationsChange} @option-toggle-changed=${this.handleConfigOptionToggleChanged} @unit-toggle-changed=${this.handleConfigUnitToggleChanged} > @@ -366,7 +372,7 @@ export class SinglePlayerModal extends BaseModal { // Check if any options other than map and difficulty have been changed from defaults private hasOptionsChanged(): boolean { return ( - this.disableNations !== DEFAULT_OPTIONS.disableNations || + this.nations !== this.defaultNationCount || this.bots !== DEFAULT_OPTIONS.bots || this.infiniteGold !== DEFAULT_OPTIONS.infiniteGold || this.infiniteTroops !== DEFAULT_OPTIONS.infiniteTroops || @@ -387,8 +393,9 @@ export class SinglePlayerModal extends BaseModal { this.selectedDifficulty = DEFAULT_OPTIONS.selectedDifficulty; this.gameMode = DEFAULT_OPTIONS.gameMode; this.useRandomMap = DEFAULT_OPTIONS.useRandomMap; - this.disableNations = DEFAULT_OPTIONS.disableNations; this.bots = DEFAULT_OPTIONS.bots; + this.nations = 0; + this.defaultNationCount = 0; this.infiniteGold = DEFAULT_OPTIONS.infiniteGold; this.infiniteTroops = DEFAULT_OPTIONS.infiniteTroops; this.compactMap = DEFAULT_OPTIONS.compactMap; @@ -404,8 +411,14 @@ export class SinglePlayerModal extends BaseModal { this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue; } + protected onOpen(): void { + void this.loadNationCount(); + } + private handleSelectRandomMap() { this.useRandomMap = true; + this.selectedMap = getRandomMapType(); + void this.loadNationCount(); } private handleConfigRandomMapSelected = () => { @@ -415,6 +428,7 @@ export class SinglePlayerModal extends BaseModal { private handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; + void this.loadNationCount(); } private handleConfigMapSelected = (e: Event) => { @@ -444,6 +458,11 @@ export class SinglePlayerModal extends BaseModal { private handleCompactMapChange(val: boolean) { this.compactMap = val; this.bots = getBotsForCompactMap(this.bots, val); + this.nations = getNationsForCompactMap( + this.nations, + this.defaultNationCount, + val, + ); } private handleConfigOptionToggleChanged = (e: Event) => { @@ -454,9 +473,6 @@ export class SinglePlayerModal extends BaseModal { 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; @@ -496,6 +512,15 @@ export class SinglePlayerModal extends BaseModal { this.bots = value; }; + private handleNationsChange = (e: Event) => { + const customEvent = e as CustomEvent<{ value: number }>; + const value = customEvent.detail.value; + if (isNaN(value) || value < 0 || value > 400) { + return; + } + this.nations = value; + }; + private handleMaxTimerToggle = ( checked: boolean, value: number | string | undefined, @@ -604,11 +629,6 @@ export class SinglePlayerModal extends BaseModal { finalMaxTimerValue = Math.max(1, Math.min(120, this.maxTimerValue)); } - // If random map is selected, choose a random map now - if (this.useRandomMap) { - this.selectedMap = getRandomMapType(); - } - console.log( `Starting single player game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]}${this.useRandomMap ? " (Randomly selected)" : ""}`, ); @@ -657,14 +677,10 @@ export class SinglePlayerModal extends BaseModal { 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, - }), + nations: sliderToNationsConfig( + this.nations, + this.defaultNationCount, + ), ...(this.goldMultiplier && this.goldMultiplierValue ? { goldMultiplier: this.goldMultiplierValue } : {}), @@ -682,4 +698,22 @@ export class SinglePlayerModal extends BaseModal { ); this.close(); } + + private async loadNationCount() { + const currentMap = this.selectedMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.defaultNationCount = manifest.nations.length; + this.nations = this.compactMap + ? Math.max(0, Math.floor(manifest.nations.length * 0.25)) + : manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + // Leave existing values unchanged so the UI stays consistent + } + } } diff --git a/src/client/components/FluentSlider.ts b/src/client/components/FluentSlider.ts index ce2f1e5af..c45dc967f 100644 --- a/src/client/components/FluentSlider.ts +++ b/src/client/components/FluentSlider.ts @@ -14,6 +14,8 @@ export class FluentSlider extends LitElement { @property({ type: Number }) step = 1; @property({ type: String }) labelKey = ""; @property({ type: String }) disabledKey = ""; + @property({ type: Number }) defaultValue: number | undefined = undefined; + @property({ type: String }) defaultLabelKey = ""; @state() private isEditing = false; @@ -131,7 +133,14 @@ export class FluentSlider extends LitElement { > ${this.value === 0 && this.disabledKey ? translateText(this.disabledKey) - : this.value} + : this.defaultValue !== undefined && + this.value === this.defaultValue && + this.defaultLabelKey + ? html`${this.value} + (${translateText(this.defaultLabelKey)})` + : this.value} `} diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts index 8300dd2b1..d0516b620 100644 --- a/src/client/components/GameConfigSettings.ts +++ b/src/client/components/GameConfigSettings.ts @@ -186,6 +186,13 @@ export interface GameConfigSettingsData { labelKey: string; disabledKey: string; }; + nations?: { + value: number; + defaultValue?: number; + labelKey: string; + disabledKey: string; + hidden?: boolean; + }; toggles: ToggleOptionConfig[]; inputCards: TemplateResult[]; }; @@ -246,6 +253,11 @@ export class GameConfigSettings extends LitElement { this.emit("bots-changed", customEvent.detail); }; + private handleNationsChanged = (event: Event) => { + const customEvent = event as CustomEvent<{ value: number }>; + this.emit("nations-changed", customEvent.detail); + }; + private handleUnitToggle = (unit: UnitType, checked: boolean) => { this.emit("unit-toggle-changed", { unit, checked }); }; @@ -423,6 +435,26 @@ export class GameConfigSettings extends LitElement { > + ${settings.options.nations && !settings.options.nations.hidden + ? html`
+ +
` + : nothing} ${settings.options.toggles.map((toggle) => this.renderOptionToggle(toggle), )} diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index d5b9c5259..9c0366f9e 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -13,7 +13,6 @@ import { Team, Trios, } from "../../core/game/Game"; -import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; @@ -36,8 +35,6 @@ export class LobbyTeamView extends LitElement { @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; - @property({ type: Boolean }) disableNations: boolean = false; - @property({ type: Boolean }) isCompactMap: boolean = false; private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; @@ -50,9 +47,7 @@ export class LobbyTeamView extends LitElement { changedProperties.has("gameMode") || changedProperties.has("clients") || changedProperties.has("teamCount") || - changedProperties.has("nationCount") || - changedProperties.has("disableNations") || - changedProperties.has("isCompactMap") + changedProperties.has("nationCount") ) { const teamsList = this.getTeamList(); this.computeTeamPreview(teamsList); @@ -72,8 +67,8 @@ export class LobbyTeamView extends LitElement { ? translateText("host_modal.player") : translateText("host_modal.players")} - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 + ${this.nationCount} + ${this.nationCount === 1 ? translateText("host_modal.nation_player") : translateText("host_modal.nation_players")} @@ -182,15 +177,14 @@ export class LobbyTeamView extends LitElement { } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { - const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? effectiveNationCount + ? this.nationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? effectiveNationCount + ? this.nationCount : this.teamMaxSize; const teamLabel = getTranslatedPlayerTeamLabel(preview.team); @@ -251,7 +245,7 @@ export class LobbyTeamView extends LitElement { private getTeamList(): Team[] { if (this.gameMode !== GameMode.Team) return []; - const playerCount = this.clients.length + this.getEffectiveNationCount(); + const playerCount = this.clients.length + this.nationCount; const config = this.teamCount; if (config === HumansVsNations) { @@ -315,7 +309,7 @@ export class LobbyTeamView extends LitElement { const assignment = assignTeamsLobbyPreview( players, teams, - this.getEffectiveNationCount(), + this.nationCount, ); const buckets = new Map(); for (const t of teams) buckets.set(t, []); @@ -339,9 +333,7 @@ export class LobbyTeamView extends LitElement { // Fallback: divide players across teams; guard against 0 and empty lobbies this.teamMaxSize = Math.max( 1, - Math.ceil( - (this.clients.length + this.getEffectiveNationCount()) / teams.length, - ), + Math.ceil((this.clients.length + this.nationCount) / teams.length), ); } this.teamPreview = teams.map((t) => ({ @@ -350,22 +342,6 @@ export class LobbyTeamView extends LitElement { })); } - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.isCompactMap); - } - private displayUsername(client: ClientInfo): string { if (!this.userSettings.anonymousNames()) { return client.username; diff --git a/src/client/components/map/MapPicker.ts b/src/client/components/map/MapPicker.ts index a8e752ec6..7661cc0d5 100644 --- a/src/client/components/map/MapPicker.ts +++ b/src/client/components/map/MapPicker.ts @@ -93,7 +93,7 @@ export class MapPicker extends LitElement { private renderFeaturedMaps() { let featuredMapList = featuredMaps; - if (!featuredMapList.includes(this.selectedMap)) { + if (!this.useRandomMap && !featuredMapList.includes(this.selectedMap)) { featuredMapList = [this.selectedMap, ...featuredMaps]; } return html`
diff --git a/src/client/utilities/GameConfigHelpers.ts b/src/client/utilities/GameConfigHelpers.ts index a149dee88..805581acf 100644 --- a/src/client/utilities/GameConfigHelpers.ts +++ b/src/client/utilities/GameConfigHelpers.ts @@ -1,4 +1,31 @@ import { GameMapType, UnitType } from "../../core/game/Game"; +import { GameConfig } from "../../core/Schemas"; + +/** + * Maps a slider value (0-400) to the nations config value. + * 0 → "disabled", value === defaultNationCount → "default", otherwise → number. + */ +export function sliderToNationsConfig( + sliderValue: number, + defaultNationCount: number, +): GameConfig["nations"] { + if (sliderValue === 0) return "disabled"; + if (sliderValue === defaultNationCount) return "default"; + return sliderValue; +} + +/** + * Maps a nations config value to a slider-friendly number. + * "disabled" → 0, "default" → defaultNationCount, number → number. + */ +export function nationsConfigToSlider( + nations: GameConfig["nations"], + defaultNationCount: number, +): number { + if (nations === "disabled") return 0; + if (nations === "default") return defaultNationCount; + return nations; +} export function toOptionalNumber( value: number | string | undefined, @@ -76,6 +103,26 @@ export function getBotsForCompactMap( return bots; } +export function getNationsForCompactMap( + nations: number, + defaultNationCount: number, + compactMapEnabled: boolean, +): number { + const compactCount = Math.max(0, Math.floor(defaultNationCount * 0.25)); + if (compactMapEnabled) { + // Only reduce if at the full default + if (nations === defaultNationCount) { + return compactCount; + } + return nations; + } + // Restoring from compact: if at the compact default, go back to full default + if (nations === compactCount) { + return defaultNationCount; + } + return nations; +} + export function getRandomMapType(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 428531562..067e83069 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -219,7 +219,12 @@ export const GameConfigSchema = z.object({ startingGold: z.number().int().min(0).optional(), }) .optional(), - disableNations: z.boolean(), + nations: z + .number() + .int() + .min(1) + .max(400) + .or(z.enum(["default", "disabled"])), bots: z.number().int().min(0).max(400), infiniteGold: z.boolean(), infiniteTroops: z.boolean(), diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index f47d5e793..2195371a3 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -224,7 +224,7 @@ export class DefaultConfig implements Config { } spawnNations(): boolean { - return !this._gameConfig.disableNations; + return this._gameConfig.nations !== "disabled"; } isUnitDisabled(unitType: UnitType): boolean { diff --git a/src/core/game/NationCreation.ts b/src/core/game/NationCreation.ts index b2bdc0b70..65a1135b4 100644 --- a/src/core/game/NationCreation.ts +++ b/src/core/game/NationCreation.ts @@ -13,9 +13,14 @@ import { import { Nation as ManifestNation } from "./TerrainMapLoader"; /** - * Creates the nations array for a game, handling HumansVsNations mode specially. - * In HumansVsNations mode, the number of nations matches the number of human players to ensure fair gameplay. - * For compact maps, only 25% of the nations are used. + * Creates the nations array for a game. + * If config.nations is a number (custom count), uses that exact count, + * generating additional nations with random names if needed. + * If config.nations is "disabled", returns no nations. + * If config.nations is "default": + * - Public HumansVsNations: matches nation count to human player count + * - Public compact maps: uses 25% of manifest nations + * - Otherwise: uses all manifest nations */ export function createNationsForGame( gameStart: GameStartInfo, @@ -23,10 +28,6 @@ export function createNationsForGame( numHumans: number, random: PseudoRandom, ): Nation[] { - if (gameStart.config.disableNations) { - return []; - } - const toNation = (n: ManifestNation): Nation => new Nation( new Cell(n.coordinates[0], n.coordinates[1]), @@ -39,38 +40,59 @@ export function createNationsForGame( gameStart.config.gameMode === GameMode.Team && gameStart.config.playerTeams === HumansVsNations; - // For compact maps, use only 25% of nations (minimum 1) - let effectiveNations = manifestNations; - if (isCompactMap && !isHumansVsNations) { - const targetCount = getCompactMapNationCount(manifestNations.length, true); - const shuffled = random.shuffleArray(manifestNations); - effectiveNations = shuffled.slice(0, targetCount); - } - - // For non-HumansVsNations modes, simply use the effective nations - if (!isHumansVsNations) { - return effectiveNations.map(toNation); - } - - // HumansVsNations mode: balance nation count to match human count - const isSingleplayer = gameStart.config.gameType === GameType.Singleplayer; - const targetNationCount = isSingleplayer ? 1 : numHumans; - - if (targetNationCount === 0) { + const configNations = gameStart.config.nations; + if (configNations === "disabled") { return []; } - - // If we have enough manifest nations, use a subset - if (manifestNations.length >= targetNationCount) { - // Shuffle manifest nations to add variety - const shuffled = random.shuffleArray(manifestNations); - return shuffled.slice(0, targetNationCount).map(toNation); + // If nations count is explicitly set, use that exact count + if (typeof configNations === "number") { + return createRandomNations( + configNations, + manifestNations, + toNation, + random, + ); } - // If we need more nations than defined in manifest, create additional ones - const nations: Nation[] = manifestNations.map(toNation); + if (gameStart.config.gameType === GameType.Public) { + // For HvN, balance nation count to match human count + if (isHumansVsNations) { + return createRandomNations(numHumans, manifestNations, toNation, random); + } + + // For compact maps, use only 25% of nations (minimum 1) + if (isCompactMap) { + const targetCount = getCompactMapNationCount( + manifestNations.length, + true, + ); + const shuffled = random.shuffleArray(manifestNations); + const slicedNations = shuffled.slice(0, targetCount); + return slicedNations.map(toNation); + } + } + + return manifestNations.map(toNation); +} + +/** + * Creates the requested number of nations from manifest data. + * If more nations are needed than available in the manifest, generates additional ones with random names. + */ +function createRandomNations( + targetCount: number, + manifestNations: ManifestNation[], + toNation: (n: ManifestNation) => Nation, + random: PseudoRandom, +): Nation[] { + const shuffled = random.shuffleArray(manifestNations); + if (targetCount <= manifestNations.length) { + return shuffled.slice(0, targetCount).map(toNation); + } + // Need more nations than defined in manifest, create additional ones + const nations: Nation[] = shuffled.map(toNation); const usedNames = new Set(nations.map((n) => n.playerInfo.name)); - const additionalCount = targetNationCount - manifestNations.length; + const additionalCount = targetCount - manifestNations.length; for (let i = 0; i < additionalCount; i++) { const name = generateUniqueNationName(random, usedNames); usedNames.add(name); @@ -81,7 +103,6 @@ export function createNationsForGame( ), ); } - return nations; } diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index c512896ee..bd7d2efc8 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -72,7 +72,7 @@ export class GameManager { gameType: GameType.Private, gameMapSize: GameMapSize.Normal, difficulty: Difficulty.Medium, - disableNations: false, + nations: "default", infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts index ea9916025..93e8c0d29 100644 --- a/src/server/GamePreviewBuilder.ts +++ b/src/server/GamePreviewBuilder.ts @@ -240,7 +240,7 @@ export function buildPreview( if (gc?.infiniteTroops) gameOptions.push("Infinite Troops"); if (gc?.instantBuild) gameOptions.push("Instant Build"); if (gc?.randomSpawn) gameOptions.push("Random Spawn"); - if (gc?.disableNations) gameOptions.push("Nations Disabled"); + if (gc?.nations === "disabled") gameOptions.push("Nations Disabled"); if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled"); if (gameOptions.length > 0) { diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index bb55c02c5..48cf120ef 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -112,8 +112,8 @@ export class GameServer { if (gameConfig.difficulty !== undefined) { this.gameConfig.difficulty = gameConfig.difficulty; } - if (gameConfig.disableNations !== undefined) { - this.gameConfig.disableNations = gameConfig.disableNations; + if (gameConfig.nations !== undefined) { + this.gameConfig.nations = gameConfig.nations; } if (gameConfig.bots !== undefined) { this.gameConfig.bots = gameConfig.bots; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 0af79ce38..0984367f8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -199,7 +199,10 @@ export class MapPlaylist { maxTimerValue: undefined, instantBuild: false, randomSpawn: isRandomSpawn, - disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, + nations: + mode === GameMode.Team && playerTeams !== HumansVsNations + ? "disabled" + : "default", gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, @@ -292,10 +295,12 @@ export class MapPlaylist { (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), ); - const disableNations = + const nations: GameConfig["nations"] = (mode === GameMode.Team && playerTeams !== HumansVsNations) || // Nations don't have PVP immunity, so 25M starting gold wouldn't work well with them - (startingGold !== undefined && startingGold >= 25_000_000); + (startingGold !== undefined && startingGold >= 25_000_000) + ? "disabled" + : "default"; return { donateGold: mode === GameMode.Team, @@ -318,7 +323,7 @@ export class MapPlaylist { maxTimerValue: undefined, instantBuild: false, randomSpawn: isRandomSpawn, - disableNations, + nations, gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, @@ -353,7 +358,7 @@ export class MapPlaylist { maxTimerValue: isCompact ? 10 : 15, instantBuild: false, randomSpawn: false, - disableNations: true, + nations: "disabled", gameMode: GameMode.FFA, bots: isCompact ? 100 : 400, spawnImmunityDuration: 30 * 10, diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 1eeefebb9..37abb76a3 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -9,7 +9,7 @@ import { GameMode, GameType, } from "../src/core/game/Game"; -import { AnalyticsRecord } from "../src/core/Schemas"; +import { AnalyticsRecord, GameConfig } from "../src/core/Schemas"; import { GOLD_INDEX_STEAL, GOLD_INDEX_TRADE, @@ -19,7 +19,7 @@ import { } from "../src/core/StatsSchemas"; describe("Ranking class", () => { - const mockConfig = { + const mockConfig: GameConfig = { gameMap: GameMapType.Montreal, difficulty: Difficulty.Medium, donateGold: false, @@ -27,7 +27,7 @@ describe("Ranking class", () => { gameType: GameType.Public, gameMode: GameMode.FFA, gameMapSize: GameMapSize.Normal, - disableNations: true, + nations: "disabled", bots: 0, infiniteGold: false, infiniteTroops: false, diff --git a/tests/TranslationSystem.test.ts b/tests/TranslationSystem.test.ts index 9bffa5e0a..a70e13404 100644 --- a/tests/TranslationSystem.test.ts +++ b/tests/TranslationSystem.test.ts @@ -234,7 +234,7 @@ function extractDataI18nKeys(content: string): Set { function extractTranslationKeyLikeAttrs(content: string): Set { const keys = new Set(); const keyLikeAttrRegex = - /\b(?:translationKey|labelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g; + /\b(?:translationKey|labelKey|defaultLabelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g; let match: RegExpExecArray | null; while ((match = keyLikeAttrRegex.exec(content)) !== null) { keys.add(match[1]); diff --git a/tests/core/pathfinding/_fixtures.ts b/tests/core/pathfinding/_fixtures.ts index 3e444744f..c372dfdae 100644 --- a/tests/core/pathfinding/_fixtures.ts +++ b/tests/core/pathfinding/_fixtures.ts @@ -11,6 +11,7 @@ import { import { createGame as createGameImpl } from "../../../src/core/game/GameImpl"; import { GameMapImpl } from "../../../src/core/game/GameMap"; import { UserSettings } from "../../../src/core/game/UserSettings"; +import { GameConfig } from "../../../src/core/Schemas"; import { TestConfig } from "../../util/TestConfig"; import { TestServerConfig } from "../../util/TestServerConfig"; @@ -131,13 +132,13 @@ export function createGame(data: TestMapData): Game { ); const serverConfig = new TestServerConfig(); - const gameConfig = { + const gameConfig: GameConfig = { gameMap: GameMapType.Asia, gameMapSize: GameMapSize.Normal, gameMode: GameMode.FFA, gameType: GameType.Singleplayer, difficulty: Difficulty.Medium, - disableNations: false, + nations: "default", donateGold: false, donateTroops: false, bots: 0, diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts index 46b7fd38f..8fb17c1d2 100644 --- a/tests/pathfinding/utils.ts +++ b/tests/pathfinding/utils.ts @@ -263,7 +263,7 @@ export async function setupFromPath( gameMode: GameMode.FFA, gameType: GameType.Singleplayer, difficulty: Difficulty.Medium, - disableNations: false, + nations: "default", donateGold: false, donateTroops: false, bots: 0, diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index e9b2722ee..95f5182bb 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -61,7 +61,7 @@ export async function setup( gameMode: GameMode.FFA, gameType: GameType.Singleplayer, difficulty: Difficulty.Medium, - disableNations: false, + nations: "default", donateGold: false, donateTroops: false, bots: 0,