diff --git a/resources/lang/en.json b/resources/lang/en.json index 25946b50e..da20af064 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -363,6 +363,10 @@ "ffa": "Free for All", "teams": "Teams" }, + "public_game_modifier": { + "random_spawn": "Random Spawn", + "compact_map": "Compact Map" + }, "select_lang": { "title": "Select Language" }, diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 639ff1344..e3058b627 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -14,6 +14,7 @@ import { UnitType, mapCategories, } from "../core/game/Game"; +import { getCompactMapNationCount } from "../core/game/NationCreation"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, @@ -944,6 +945,7 @@ export class HostLobbyModal 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 { @@ -953,7 +955,7 @@ export class HostLobbyModal extends LitElement { if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { return this.clients.length; } - return this.nationCount; + return getCompactMapNationCount(this.nationCount, this.compactMap); } } diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 4caaab182..52cc2e2ab 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -7,6 +7,7 @@ import { GameMode, hasUnusualThumbnailSize, HumansVsNations, + PublicGameModifiers, Quads, Trios, } from "../core/game/Game"; @@ -114,6 +115,10 @@ export class PublicLobby extends LitElement { : `${modeLabel} ${teamDetailLabel}`; } + const modifierLabel = this.getModifierLabels( + lobby.gameConfig.publicGameModifiers, + ); + const mapImageSrc = this.mapImages.get(lobby.gameID); const isUnusualThumbnailSize = hasUnusualThumbnailSize( lobby.gameConfig.gameMap, @@ -156,17 +161,29 @@ export class PublicLobby extends LitElement { .join("")}` : translateText("public_lobby.join")} -
- - ${fullModeLabel} - - - ${translateText( - `map.${lobby.gameConfig.gameMap - .toLowerCase() - .replace(/[\s.]+/g, "")}`, - )} - +
+ ${fullModeLabel} + ${modifierLabel.map( + (label) => + html`${label}`, + )} + ${translateText( + `map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`, + )}
@@ -293,6 +310,22 @@ export class PublicLobby extends LitElement { return { label: null, isFullLabel: false }; } + private getModifierLabels( + publicGameModifiers: PublicGameModifiers | undefined, + ): string[] { + if (!publicGameModifiers) { + return []; + } + const labels: string[] = []; + if (publicGameModifiers.isRandomSpawn) { + labels.push(translateText("public_game_modifier.random_spawn")); + } + if (publicGameModifiers.isCompact) { + labels.push(translateText("public_game_modifier.compact_map")); + } + return labels; + } + private lobbyClicked(lobby: GameInfo) { if (this.isButtonDebounced) return; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index fc520af80..291a94321 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -171,6 +171,12 @@ export const GameConfigSchema = z.object({ gameType: z.enum(GameType), gameMode: z.enum(GameMode), gameMapSize: z.enum(GameMapSize), + publicGameModifiers: z + .object({ + isCompact: z.boolean(), + isRandomSpawn: z.boolean(), + }) + .optional(), disableNations: z.boolean(), bots: z.number().int().min(0).max(400), infiniteGold: z.boolean(), diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f05bccda6..96a7938ef 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -7,6 +7,7 @@ import { Gold, Player, PlayerInfo, + PublicGameModifiers, Team, TerraNullius, Tick, @@ -34,6 +35,7 @@ export interface ServerConfig { map: GameMapType, mode: GameMode, numPlayerTeams: TeamCountConfig | undefined, + isCompactMap?: boolean, ): number; numWorkers(): number; workerIndex(gameID: GameID): number; @@ -57,6 +59,8 @@ export interface ServerConfig { stripePublishableKey(): string; allowedFlares(): string[] | undefined; enableMatchmaking(): boolean; + getRandomPublicGameModifiers(): PublicGameModifiers; + supportsCompactMapForTeams(map: GameMapType): boolean; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 80fc254e5..4a8f3d8c5 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -12,6 +12,7 @@ import { Player, PlayerInfo, PlayerType, + PublicGameModifiers, Quads, TerrainType, TerraNullius, @@ -176,11 +177,16 @@ export abstract class DefaultServerConfig implements ServerConfig { map: GameMapType, mode: GameMode, numPlayerTeams: TeamCountConfig | undefined, + isCompactMap?: boolean, ): number { const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; const r = Math.random(); const base = r < 0.3 ? l : r < 0.6 ? m : s; let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l); + // Apply compact map 75% player reduction + if (isCompactMap) { + p = Math.max(3, Math.floor(p * 0.25)); + } if (numPlayerTeams === undefined) return p; switch (numPlayerTeams) { case Duos: @@ -218,6 +224,20 @@ export abstract class DefaultServerConfig implements ServerConfig { enableMatchmaking(): boolean { return false; } + + getRandomPublicGameModifiers(): PublicGameModifiers { + return { + isRandomSpawn: Math.random() < 0.1, // 10% chance + isCompact: Math.random() < 0.05, // 5% chance + }; + } + + supportsCompactMapForTeams(map: GameMapType): boolean { + // Maps with smallest player count < 50 don't support compact map in team games + // The smallest player count is the 3rd number in numPlayersConfig + const [, , smallest] = numPlayersConfig[map] ?? [50, 30, 20]; + return smallest >= 50; + } } export class DefaultConfig implements Config { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 8686d7a83..cefcc90e8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -193,6 +193,11 @@ export enum GameMapSize { Normal = "Normal", } +export interface PublicGameModifiers { + isCompact: boolean; + isRandomSpawn: boolean; +} + export interface UnitInfo { cost: (game: Game, player: Player) => Gold; // Determines if its owner changes when its tile is conquered. diff --git a/src/core/game/NationCreation.ts b/src/core/game/NationCreation.ts index 076ccfd72..3e4d15807 100644 --- a/src/core/game/NationCreation.ts +++ b/src/core/game/NationCreation.ts @@ -2,6 +2,7 @@ import { PseudoRandom } from "../PseudoRandom"; import { GameStartInfo } from "../Schemas"; import { Cell, + GameMapSize, GameMode, GameType, HumansVsNations, @@ -14,6 +15,7 @@ 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. */ export function createNationsForGame( gameStart: GameStartInfo, @@ -31,13 +33,23 @@ export function createNationsForGame( new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()), ); + const isCompactMap = gameStart.config.gameMapSize === GameMapSize.Compact; + const isHumansVsNations = gameStart.config.gameMode === GameMode.Team && gameStart.config.playerTeams === HumansVsNations; - // For non-HumansVsNations modes, simply use the manifest nations + // 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 manifestNations.map(toNation); + return effectiveNations.map(toNation); } // HumansVsNations mode: balance nation count to match human count @@ -71,6 +83,20 @@ export function createNationsForGame( return nations; } +// For compact maps, only 25% of nations are used (minimum 1). +export function getCompactMapNationCount( + manifestNationCount: number, + isCompactMap: boolean, +): number { + if (manifestNationCount === 0) { + return 0; + } + if (isCompactMap) { + return Math.max(1, Math.floor(manifestNationCount * 0.25)); + } + return manifestNationCount; +} + const PLURAL_NOUN = Symbol("plural!"); const NOUN = Symbol("noun!"); diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 361fa332e..c7bbda1e8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -93,25 +93,43 @@ export class MapPlaylist { const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; + let { isCompact, isRandomSpawn } = config.getRandomPublicGameModifiers(); + + // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) + if ( + playerTeams === Duos || + playerTeams === Trios || + playerTeams === Quads + ) { + isRandomSpawn = false; + } + + // Maps with smallest player count < 50 don't support compact map in team games + // The smallest player count is the 3rd number in numPlayersConfig + if (mode === GameMode.Team && !config.supportsCompactMapForTeams(map)) { + isCompact = false; + } + // Create the default public game config (from your GameManager) return { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams), + maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, - gameMapSize: GameMapSize.Normal, + gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, + publicGameModifiers: { isCompact, isRandomSpawn }, difficulty: playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, instantBuild: false, - randomSpawn: false, + randomSpawn: isRandomSpawn, disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, gameMode: mode, playerTeams, - bots: 400, + bots: isCompact ? 100 : 400, spawnImmunityDuration: 5 * 10, disabledUnits: [], } satisfies GameConfig; diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 330ba91ed..3199e5b81 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -1,6 +1,6 @@ import { JWK } from "jose"; import { GameEnv, ServerConfig } from "../../src/core/configuration/Config"; -import { GameMapType } from "../../src/core/game/Game"; +import { GameMapType, PublicGameModifiers } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { @@ -82,4 +82,10 @@ export class TestServerConfig implements ServerConfig { gitCommit(): string { throw new Error("Method not implemented."); } + getRandomPublicGameModifiers(): PublicGameModifiers { + return { isCompact: false, isRandomSpawn: false }; + } + supportsCompactMapForTeams(map: GameMapType): boolean { + throw new Error("Method not implemented."); + } }