diff --git a/resources/lang/en.json b/resources/lang/en.json index 30b631518..675ee69e2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -427,7 +427,8 @@ "teams_Quads": "Quads (teams of 4)", "teams_Humans Vs Nations": "Humans vs Nations", "starting_gold": "Starting gold", - "crowded": "Crowded modifier" + "crowded": "Crowded modifier", + "hard_nations": "Hard Nations" }, "team_colors": { "red": "Red", @@ -469,7 +470,8 @@ "random_spawn": "Random Spawn", "compact_map": "Compact Map", "crowded": "Crowded", - "starting_gold": "5M Starting Gold" + "hard_nations": "Hard Nations", + "starting_gold": "{amount}M Starting Gold" }, "select_lang": { "title": "Select Language" diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 682db741a..526adf036 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -5,7 +5,6 @@ import { GameMapType, GameMode, HumansVsNations, - PublicGameModifiers, Quads, Trios, } from "../core/game/Game"; @@ -16,7 +15,12 @@ import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; -import { getMapName, renderDuration, translateText } from "./Utils"; +import { + getMapName, + getModifierLabels, + renderDuration, + translateText, +} from "./Utils"; const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]"; @@ -198,7 +202,7 @@ export class GameModeSelector extends LitElement { const mapName = getMapName(lobby.gameConfig?.gameMap); - const modifierLabels = this.getModifierLabels( + const modifierLabels = getModifierLabels( lobby.gameConfig?.publicGameModifiers, ); // Sort by length for visual consistency (shorter labels first) @@ -283,16 +287,6 @@ export class GameModeSelector extends LitElement { ); } - private getModifierLabels(mods: PublicGameModifiers | undefined): string[] { - if (!mods) return []; - return [ - mods.isRandomSpawn && translateText("public_game_modifier.random_spawn"), - mods.isCompact && translateText("public_game_modifier.compact_map"), - mods.isCrowded && translateText("public_game_modifier.crowded"), - mods.startingGold && translateText("public_game_modifier.starting_gold"), - ].filter((x): x is string => !!x); - } - private getLobbyTitle(lobby: PublicGameInfo): string { const config = lobby.gameConfig!; if (config.gameMode === GameMode.FFA) { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index f94cb0a33..d7ce0f60a 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -109,6 +109,8 @@ export interface ModifierInfo { labelKey: string; /** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */ badgeKey: string; + /** Parameters to pass to translateText for the badge key */ + badgeParams?: Record; /** The raw value if applicable (e.g. startingGold amount) */ value?: number; } @@ -139,10 +141,19 @@ export function getActiveModifiers( badgeKey: "public_game_modifier.crowded", }); } + if (modifiers.isHardNations) { + result.push({ + labelKey: "host_modal.hard_nations", + badgeKey: "public_game_modifier.hard_nations", + }); + } if (modifiers.startingGold) { result.push({ labelKey: "host_modal.starting_gold", badgeKey: "public_game_modifier.starting_gold", + badgeParams: { + amount: Math.round(modifiers.startingGold / 1_000_000), + }, value: modifiers.startingGold, }); } @@ -155,7 +166,9 @@ export function getActiveModifiers( export function getModifierLabels( modifiers: PublicGameModifiers | undefined, ): string[] { - return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey)); + return getActiveModifiers(modifiers).map((m) => + translateText(m.badgeKey, m.badgeParams), + ); } export function renderDuration(totalSeconds: number): string { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 86c74292d..428531562 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -215,6 +215,7 @@ export const GameConfigSchema = z.object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), isCrowded: z.boolean(), + isHardNations: z.boolean(), startingGold: z.number().int().min(0).optional(), }) .optional(), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 3c6eb50a8..d5d456785 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -236,6 +236,7 @@ export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; isCrowded: boolean; + isHardNations: boolean; startingGold?: number; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index f44762416..f2d661161 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -89,16 +89,33 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ { config: HumansVsNations, weight: 20 }, ]; -type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold"; +type ModifierKey = + | "isRandomSpawn" + | "isCompact" + | "isCrowded" + | "isHardNations" + | "startingGold" + | "startingGoldHigh"; // Each entry represents one "ticket" in the pool. More tickets = higher chance of selection. const SPECIAL_MODIFIER_POOL: ModifierKey[] = [ ...Array(4).fill("isRandomSpawn"), - ...Array(7).fill("isCompact"), + ...Array(8).fill("isCompact"), ...Array(1).fill("isCrowded"), - ...Array(6).fill("startingGold"), + ...Array(1).fill("isHardNations"), + ...Array(8).fill("startingGold"), + ...Array(1).fill("startingGoldHigh"), ]; +// Modifiers that cannot be active at the same time. +const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [ + ["startingGold", "startingGoldHigh"], + ["isHardNations", "startingGoldHigh"], +]; + +// Probability of hard nations modifier in HumansVsNations games. +const HARD_NATIONS_HVN_PROBABILITY = 0.2; // 20% + export class MapPlaylist { private playlists: Record = { ffa: [], @@ -111,17 +128,15 @@ export class MapPlaylist { return this.getSpecialConfig(); } - // TODO: consider moving modifier to special lobby. - const mode = type === "ffa" ? GameMode.FFA : GameMode.Team; const map = this.getNextMap(type); const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - const modifiers = this.getRandomPublicGameModifiers(); + const modifiers = this.getRandomPublicGameModifiers(playerTeams); const { startingGold } = modifiers; - let { isCompact, isRandomSpawn, isCrowded } = modifiers; + let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -132,11 +147,16 @@ export class MapPlaylist { isRandomSpawn = false; } - // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games - // (not enough players after 75% player reduction for compact maps) + // Hard nations modifier only applies when nations are present + if (mode === GameMode.Team && playerTeams !== HumansVsNations) { + isHardNations = false; + } + + // Check if compact map would leave every team with at least 2 players if ( + isCompact && mode === GameMode.Team && - !(await this.supportsCompactMapForTeams(map)) + !(await this.supportsCompactMapForTeams(map, playerTeams!)) ) { isCompact = false; } @@ -167,10 +187,11 @@ export class MapPlaylist { isCompact, isRandomSpawn, isCrowded, + isHardNations, startingGold, }, startingGold, - difficulty: Difficulty.Medium, + difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, @@ -180,7 +201,10 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, - spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, + spawnImmunityDuration: this.getSpawnImmunityDuration( + playerTeams, + startingGold, + ), disabledUnits: [], } satisfies GameConfig; } @@ -190,12 +214,16 @@ export class MapPlaylist { const map = this.getNextMap("special"); const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - const supportsCompact = - mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map)); + const excludedModifiers: ModifierKey[] = []; + + const supportsCompact = + mode !== GameMode.Team || + (await this.supportsCompactMapForTeams(map, playerTeams!)); if (!supportsCompact) { excludedModifiers.push("isCompact"); } + if ( playerTeams === Duos || playerTeams === Trios || @@ -204,8 +232,29 @@ export class MapPlaylist { excludedModifiers.push("isRandomSpawn"); } - let { isCrowded, startingGold, isCompact, isRandomSpawn } = - this.getRandomSpecialGameModifiers(excludedModifiers); + // Hard nations: excluded for non-HvN team modes (no nations present). + // For HumansVsNations: rolled independently (not via pool). + // For FFA: stays in the pool for normal ticket-based selection. + let hardNationsFromIndependentRoll: boolean | undefined; + let poolCountReduction = 0; + if (mode === GameMode.Team && playerTeams !== HumansVsNations) { + excludedModifiers.push("isHardNations"); + } else if (playerTeams === HumansVsNations) { + excludedModifiers.push("isHardNations"); + excludedModifiers.push("startingGoldHigh"); // Nations are disabled if that modifier is active + hardNationsFromIndependentRoll = + Math.random() < HARD_NATIONS_HVN_PROBABILITY; + poolCountReduction = hardNationsFromIndependentRoll ? 1 : 0; + } + + const poolResult = this.getRandomSpecialGameModifiers( + excludedModifiers, + undefined, + poolCountReduction, + ); + let { isCrowded, startingGold, isCompact, isRandomSpawn } = poolResult; + let isHardNations = + hardNationsFromIndependentRoll ?? poolResult.isHardNations; let crowdedMaxPlayers: number | undefined; if (isCrowded) { @@ -216,10 +265,21 @@ export class MapPlaylist { // Map doesn't support crowded. Drop it and pick one replacement only // if it was the sole modifier, so the lobby always has at least one. isCrowded = false; - if (!isRandomSpawn && !isCompact && startingGold === undefined) { + if ( + !isRandomSpawn && + !isCompact && + !isHardNations && + startingGold === undefined + ) { excludedModifiers.push("isCrowded"); - ({ isRandomSpawn, isCompact, startingGold } = - this.getRandomSpecialGameModifiers(excludedModifiers, 1)); + const fallback = this.getRandomSpecialGameModifiers( + excludedModifiers, + 1, + poolCountReduction, + ); + ({ isRandomSpawn, isCompact, startingGold } = fallback); + isHardNations = + hardNationsFromIndependentRoll ?? fallback.isHardNations; } } } @@ -230,6 +290,11 @@ export class MapPlaylist { (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), ); + const disableNations = + (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); + return { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, @@ -241,20 +306,24 @@ export class MapPlaylist { isCompact, isRandomSpawn, isCrowded, + isHardNations, startingGold, }, startingGold, - difficulty: Difficulty.Medium, + difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, instantBuild: false, randomSpawn: isRandomSpawn, - disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, + disableNations, gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, - spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, + spawnImmunityDuration: this.getSpawnImmunityDuration( + playerTeams, + startingGold, + ), disabledUnits: [], } satisfies GameConfig; } @@ -375,30 +444,39 @@ export class MapPlaylist { return TEAM_WEIGHTS[0].config; } - private getRandomPublicGameModifiers(): PublicGameModifiers { + private getRandomPublicGameModifiers( + playerTeams?: TeamCountConfig, + ): PublicGameModifiers { return { - isRandomSpawn: Math.random() < 0.1, // 10% chance + isRandomSpawn: Math.random() < 0.05, // 5% chance isCompact: Math.random() < 0.05, // 5% chance isCrowded: Math.random() < 0.05, // 5% chance startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance + isHardNations: + playerTeams === HumansVsNations + ? Math.random() < HARD_NATIONS_HVN_PROBABILITY + : Math.random() < 0.025, // 2.5% chance }; } private getRandomSpecialGameModifiers( excludedModifiers: ModifierKey[] = [], count?: number, + countReduction: number = 0, ): PublicGameModifiers { // Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4 const modifierCountRoll = Math.floor(Math.random() * 10) + 1; - const k = - count ?? - (modifierCountRoll <= 3 - ? 1 - : modifierCountRoll <= 7 - ? 2 - : modifierCountRoll <= 9 - ? 3 - : 4); + const k = Math.max( + 0, + (count ?? + (modifierCountRoll <= 3 + ? 1 + : modifierCountRoll <= 7 + ? 2 + : modifierCountRoll <= 9 + ? 3 + : 4)) - countReduction, + ); // Shuffle the pool, then pick the first k unique modifier keys. const pool = SPECIAL_MODIFIER_POOL.filter( @@ -408,23 +486,79 @@ export class MapPlaylist { const selected = new Set(); for (const key of pool) { if (selected.size >= k) break; - selected.add(key); + // Skip if a mutually exclusive modifier is already selected + const blocked = MUTUALLY_EXCLUSIVE_MODIFIERS.some( + ([a, b]) => + (key === a && selected.has(b)) || (key === b && selected.has(a)), + ); + if (!blocked) selected.add(key); } return { isRandomSpawn: selected.has("isRandomSpawn"), isCompact: selected.has("isCompact"), isCrowded: selected.has("isCrowded"), - startingGold: selected.has("startingGold") ? 5_000_000 : undefined, + isHardNations: selected.has("isHardNations"), + startingGold: selected.has("startingGoldHigh") + ? 25_000_000 + : selected.has("startingGold") + ? 5_000_000 + : undefined, }; } - // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games - // (not enough players after 75% player reduction for compact maps) - private async supportsCompactMapForTeams(map: GameMapType): Promise { + // Check whether a compact map still gives every team at least 2 players, + // using the worst-case player tier (smallest) from lobbyMaxPlayers. + private async supportsCompactMapForTeams( + map: GameMapType, + playerTeams: TeamCountConfig, + ): Promise { const landTiles = await getMapLandTiles(map); - const [, , smallest] = this.calculateMapPlayerCounts(landTiles); - return smallest >= 50; + const [l, , s] = this.calculateMapPlayerCounts(landTiles); + // Worst case: smallest tier with team mode 1.5x multiplier, capped at l + let p = Math.min(Math.ceil(s * 1.5), l); + // Apply compact 75% player reduction + p = Math.max(3, Math.floor(p * 0.25)); + // Apply team adjustment + p = this.adjustForTeams(p, playerTeams); + // Check at least 2 players per team + return this.playersPerTeam(p, playerTeams) >= 2; + } + + private playersPerTeam( + adjustedPlayerCount: number, + playerTeams: TeamCountConfig, + ): number { + switch (playerTeams) { + case Duos: + return Math.min(2, adjustedPlayerCount); + case Trios: + return Math.min(3, adjustedPlayerCount); + case Quads: + return Math.min(4, adjustedPlayerCount); + case HumansVsNations: + return adjustedPlayerCount; // adjustedPlayerCount is the human count + default: + return Math.floor(adjustedPlayerCount / playerTeams); + } + } + + /** + * Centralised spawn-immunity duration logic. + * - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity) + * - 25M starting gold: 2:30 (extra time to compensate for high gold) + * - 5M starting gold: 30s + * - Default: 5s + */ + private getSpawnImmunityDuration( + playerTeams?: TeamCountConfig, + startingGold?: number, + ): number { + if (playerTeams === HumansVsNations) return 5 * 10; + if (startingGold !== undefined && startingGold >= 25_000_000) + return 150 * 10; + if (startingGold) return 30 * 10; + return 5 * 10; } private async getCrowdedMaxPlayers( diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 94b625943..c4b4b8d67 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -80,7 +80,12 @@ export class TestServerConfig implements ServerConfig { throw new Error("Method not implemented."); } getRandomPublicGameModifiers(): PublicGameModifiers { - return { isCompact: false, isRandomSpawn: false, isCrowded: false }; + return { + isCompact: false, + isRandomSpawn: false, + isCrowded: false, + isHardNations: false, + }; } async supportsCompactMapForTeams(): Promise { throw new Error("Method not implemented.");