diff --git a/map-generator/README.md b/map-generator/README.md index 896e922ab..3d9a83eb1 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -105,10 +105,8 @@ The country will need to be added to `../src/client/data/countries.json` Using the `name` from your json: -- Add to the MapDescription `../src/client/components/Maps.ts` -- Add to the numPlayersConfig `../src/core/configuration/DefaultConfig.ts` -- Add to the mapCategories `../src/core/game/Game.ts` -- Add to the map playlist `../src/server/MapPlaylist.ts` +- Add to GameMapType and mapCategories in `../src/core/game/Game.ts` +- Add to the map playlist in `../src/server/MapPlaylist.ts` - Add to the `map` translation object in `../resources/lang/en.json` ## Notes diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts index de8e4f380..e46e4691b 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/Maps.ts @@ -4,56 +4,6 @@ import { Difficulty, GameMapType } from "../../core/game/Game"; import { terrainMapFileLoader } from "../TerrainMapFileLoader"; import { translateText } from "../Utils"; -// Add map descriptions -export const MapDescription: Record = { - World: "World", - GiantWorldMap: "Giant World Map", - Europe: "Europe", - EuropeClassic: "Europe Classic", - Mena: "MENA", - NorthAmerica: "North America", - Oceania: "Oceania", - BlackSea: "Black Sea", - Africa: "Africa", - Pangaea: "Pangaea", - Asia: "Asia", - Mars: "Mars", - SouthAmerica: "South America", - BritanniaClassic: "Britannia Classic", - Britannia: "Britannia", - GatewayToTheAtlantic: "Gateway to the Atlantic", - Australia: "Australia", - Iceland: "Iceland", - EastAsia: "East Asia", - BetweenTwoSeas: "Between Two Seas", - FaroeIslands: "Faroe Islands", - DeglaciatedAntarctica: "Deglaciated Antarctica", - FalklandIslands: "Falkland Islands", - Baikal: "Baikal", - Halkidiki: "Halkidiki", - StraitOfGibraltar: "Strait of Gibraltar", - Italia: "Italia", - Japan: "Japan", - Pluto: "Pluto", - Montreal: "Montreal", - NewYorkCity: "New York City", - Achiran: "Achiran", - BaikalNukeWars: "Baikal (Nuke Wars)", - FourIslands: "Four Islands", - Svalmel: "Svalmel", - GulfOfStLawrence: "Gulf of St. Lawrence", - Lisbon: "Lisbon", - Manicouagan: "Manicouagan", - Lemnos: "Lemnos", - TwoLakes: "Two Lakes", - Sierpinski: "Sierpinski", - StraitOfHormuz: "Strait of Hormuz", - Surrounded: "Surrounded", - Didier: "Didier", - DidierFrance: "Didier (France)", - AmazonRiver: "Amazon River", -}; - @customElement("map-display") export class MapDisplay extends LitElement { @property({ type: String }) mapKey = ""; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 58a83f68b..4c9fce54d 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -2,12 +2,9 @@ import { Colord } from "colord"; import { JWK } from "jose"; import { Game, - GameMapType, - GameMode, Gold, Player, PlayerInfo, - PublicGameModifiers, Team, TerraNullius, Tick, @@ -31,12 +28,6 @@ export interface ServerConfig { turnstileSecretKey(): string; turnIntervalMs(): number; gameCreationRate(): number; - lobbyMaxPlayers( - map: GameMapType, - mode: GameMode, - numPlayerTeams: TeamCountConfig | undefined, - isCompactMap?: boolean, - ): number; numWorkers(): number; workerIndex(gameID: GameID): number; workerPath(gameID: GameID): string; @@ -58,8 +49,6 @@ export interface ServerConfig { subdomain(): string; stripePublishableKey(): string; allowedFlares(): string[] | undefined; - getRandomPublicGameModifiers(): PublicGameModifiers; - supportsCompactMapForTeams(map: GameMapType): boolean; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 72f0bdc3e..7311cb60c 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -2,22 +2,16 @@ import { JWK } from "jose"; import { z } from "zod"; import { Difficulty, - Duos, Game, - GameMapType, GameMode, GameType, Gold, - HumansVsNations, Player, PlayerInfo, PlayerType, - PublicGameModifiers, - Quads, TerrainType, TerraNullius, Tick, - Trios, UnitInfo, UnitType, } from "../game/Game"; @@ -47,55 +41,6 @@ const JwksSchema = z.object({ .min(1), }); -const numPlayersConfig = { - [GameMapType.Africa]: [100, 70, 50], - [GameMapType.Asia]: [50, 40, 30], - [GameMapType.Australia]: [70, 40, 30], - [GameMapType.Achiran]: [40, 36, 30], - [GameMapType.Baikal]: [100, 70, 50], - [GameMapType.BaikalNukeWars]: [100, 70, 50], - [GameMapType.BetweenTwoSeas]: [70, 50, 40], - [GameMapType.BlackSea]: [50, 30, 30], - [GameMapType.Britannia]: [50, 30, 20], - [GameMapType.BritanniaClassic]: [50, 30, 20], - [GameMapType.DeglaciatedAntarctica]: [50, 40, 30], - [GameMapType.EastAsia]: [50, 30, 20], - [GameMapType.Europe]: [100, 70, 50], - [GameMapType.EuropeClassic]: [50, 30, 30], - [GameMapType.FalklandIslands]: [50, 30, 20], - [GameMapType.FourIslands]: [20, 15, 10], - [GameMapType.FaroeIslands]: [20, 15, 10], - [GameMapType.GatewayToTheAtlantic]: [100, 70, 50], - [GameMapType.GiantWorldMap]: [100, 70, 50], - [GameMapType.GulfOfStLawrence]: [60, 40, 30], - [GameMapType.Halkidiki]: [100, 50, 40], - [GameMapType.Iceland]: [50, 40, 30], - [GameMapType.Italia]: [50, 30, 20], - [GameMapType.Japan]: [20, 15, 10], - [GameMapType.Lisbon]: [50, 40, 30], - [GameMapType.Manicouagan]: [60, 40, 30], - [GameMapType.Mars]: [70, 40, 30], - [GameMapType.Mena]: [70, 50, 40], - [GameMapType.Montreal]: [60, 40, 30], - [GameMapType.NewYorkCity]: [60, 40, 30], - [GameMapType.NorthAmerica]: [70, 40, 30], - [GameMapType.Oceania]: [10, 10, 10], - [GameMapType.Pangaea]: [20, 15, 10], - [GameMapType.Pluto]: [100, 70, 50], - [GameMapType.SouthAmerica]: [70, 50, 40], - [GameMapType.StraitOfGibraltar]: [100, 70, 50], - [GameMapType.Svalmel]: [40, 36, 30], - [GameMapType.World]: [50, 30, 20], - [GameMapType.Lemnos]: [20, 15, 10], - [GameMapType.TwoLakes]: [60, 50, 40], - [GameMapType.StraitOfHormuz]: [40, 36, 30], - [GameMapType.Surrounded]: [42, 28, 14], // 3, 2, 1 player(s) per island - [GameMapType.Didier]: [50, 40, 30], - [GameMapType.DidierFrance]: [100, 70, 50], - [GameMapType.AmazonRiver]: [50, 40, 30], - [GameMapType.Sierpinski]: [20, 15, 10], -} as const satisfies Record; - export abstract class DefaultServerConfig implements ServerConfig { turnstileSecretKey(): string { return Env.TURNSTILE_SECRET_KEY ?? ""; @@ -176,42 +121,6 @@ export abstract class DefaultServerConfig implements ServerConfig { return 60 * 1000; } - lobbyMaxPlayers( - 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: - p -= p % 2; - break; - case Trios: - p -= p % 3; - break; - case Quads: - p -= p % 4; - break; - case HumansVsNations: - // Half the slots are for humans, the other half will get filled with nations - p = Math.floor(p / 2); - break; - default: - p -= p % numPlayerTeams; - break; - } - return p; - } - workerIndex(gameID: GameID): number { return simpleHash(gameID) % this.numWorkers(); } @@ -224,20 +133,6 @@ export abstract class DefaultServerConfig implements ServerConfig { workerPortByIndex(index: number): number { return 3001 + index; } - - 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/server/MapLandTiles.ts b/src/server/MapLandTiles.ts new file mode 100644 index 000000000..83d47bab8 --- /dev/null +++ b/src/server/MapLandTiles.ts @@ -0,0 +1,28 @@ +import { FetchGameMapLoader } from "src/core/game/FetchGameMapLoader"; +import { GameMapType } from "src/core/game/Game"; +import { GameMapLoader } from "src/core/game/GameMapLoader"; +import { logger } from "./Logger"; + +let mapLoader: GameMapLoader | null = null; + +const log = logger.child({ component: "MapLandTiles" }); + +// Gets or creates the map loader, uses FetchGameMapLoader pointing to the master server. +function getMapLoader(): GameMapLoader { + mapLoader ??= new FetchGameMapLoader("http://localhost:3000/maps"); + return mapLoader; +} + +// Gets the number of land tiles for a map +// FetchGameMapLoader already caches maps, so no need for additional caching here. +export async function getMapLandTiles(map: GameMapType): Promise { + try { + const loader = getMapLoader(); + const mapData = loader.getMapData(map); + const manifest = await mapData.manifest(); + return manifest.map.num_land_tiles; + } catch (error) { + log.error(`Failed to load manifest for ${map}: ${error}`, { map }); + return 1_000_000; // Default fallback + } +} diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 35a218a47..d220c8fe6 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,4 +1,3 @@ -import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { Difficulty, Duos, @@ -8,17 +7,17 @@ import { GameMode, GameType, HumansVsNations, + PublicGameModifiers, Quads, Trios, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig, TeamCountConfig } from "../core/Schemas"; import { logger } from "./Logger"; +import { getMapLandTiles } from "./MapLandTiles"; const log = logger.child({}); -const config = getServerConfigFromServer(); - // How many times each map should appear in the playlist. // Note: The Partial should eventually be removed for better type safety. const frequency: Partial> = { @@ -88,13 +87,13 @@ export class MapPlaylist { constructor(private disableTeams: boolean = false) {} - public gameConfig(): GameConfig { + public async gameConfig(): Promise { const { map, mode } = this.getNextMap(); const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - let { isCompact, isRandomSpawn } = config.getRandomPublicGameModifiers(); + let { isCompact, isRandomSpawn } = this.getRandomPublicGameModifiers(); // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -106,8 +105,11 @@ export class MapPlaylist { } // 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)) { + // The smallest player count is the 3rd number in the player counts array + if ( + mode === GameMode.Team && + !(await this.supportsCompactMapForTeams(map)) + ) { isCompact = false; } @@ -116,7 +118,7 @@ export class MapPlaylist { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams, isCompact), + maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, publicGameModifiers: { isCompact, isRandomSpawn }, @@ -138,10 +140,6 @@ export class MapPlaylist { } satisfies GameConfig; } - private getTeamCount(): TeamCountConfig { - return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; - } - private getNextMap(): MapWithMode { if (this.mapsPlaylist.length === 0) { const numAttempts = 10000; @@ -157,6 +155,83 @@ export class MapPlaylist { return this.mapsPlaylist.shift()!; } + private getTeamCount(): TeamCountConfig { + return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; + } + + private getRandomPublicGameModifiers(): PublicGameModifiers { + return { + isRandomSpawn: Math.random() < 0.1, // 10% chance + isCompact: Math.random() < 0.05, // 5% chance + }; + } + + private async supportsCompactMapForTeams(map: GameMapType): Promise { + // Maps with smallest player count < 50 don't support compact map in team games + // The smallest player count is the 3rd number in the player counts array + const landTiles = await getMapLandTiles(map); + const [, , smallest] = this.calculateMapPlayerCounts(landTiles); + return smallest >= 50; + } + + private async lobbyMaxPlayers( + map: GameMapType, + mode: GameMode, + numPlayerTeams: TeamCountConfig | undefined, + isCompactMap?: boolean, + ): Promise { + const landTiles = await getMapLandTiles(map); + const [l, m, s] = this.calculateMapPlayerCounts(landTiles); + 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: + p -= p % 2; + break; + case Trios: + p -= p % 3; + break; + case Quads: + p -= p % 4; + break; + case HumansVsNations: + // Half the slots are for humans, the other half will get filled with nations + p = Math.floor(p / 2); + break; + default: + p -= p % numPlayerTeams; + break; + } + return p; + } + + /** + * Calculate player counts from land tiles + * For every 1,000,000 land tiles, take 50 players + * Limit to max 125 players for performance + * Second value is 75% of calculated value, third is 50% + * All values are rounded to the nearest 5 + */ + private calculateMapPlayerCounts( + landTiles: number, + ): [number, number, number] { + const roundToNearest5 = (n: number) => Math.round(n / 5) * 5; + + const base = roundToNearest5((landTiles / 1_000_000) * 50); + const limitedBase = Math.min(Math.max(base, 5), 125); + return [ + limitedBase, + roundToNearest5(limitedBase * 0.75), + roundToNearest5(limitedBase * 0.5), + ]; + } + private shuffleMapsPlaylist(): boolean { const maps: GameMapType[] = []; (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { diff --git a/src/server/Master.ts b/src/server/Master.ts index 0e2f8317f..441135f04 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -307,7 +307,7 @@ async function schedulePublicGame(playlist: MapPlaylist) { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, - body: JSON.stringify(playlist.gameConfig()), + body: JSON.stringify(await playlist.gameConfig()), }, ); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 1dff7aaaf..780f76041 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -496,7 +496,7 @@ async function pollLobby(gm: GameManager) { log.info(`Lobby poll successful:`, data); if (data.assignment) { - const gameConfig = playlist.gameConfig(); + const gameConfig = await playlist.gameConfig(); gameConfig.gameMapSize = GameMapSize.Compact; const game = gm.createGame(gameId, gameConfig); setTimeout(() => { diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 28b058726..6a879ccd5 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, PublicGameModifiers } from "../../src/core/game/Game"; +import { PublicGameModifiers } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { @@ -49,7 +49,7 @@ export class TestServerConfig implements ServerConfig { gameCreationRate(): number { throw new Error("Method not implemented."); } - lobbyMaxPlayers(map: GameMapType): number { + async lobbyMaxPlayers(): Promise { throw new Error("Method not implemented."); } numWorkers(): number { @@ -82,7 +82,7 @@ export class TestServerConfig implements ServerConfig { getRandomPublicGameModifiers(): PublicGameModifiers { return { isCompact: false, isRandomSpawn: false }; } - supportsCompactMapForTeams(map: GameMapType): boolean { + async supportsCompactMapForTeams(): Promise { throw new Error("Method not implemented."); } }