From 464a4a817afccb89dee28363407c1d78aa1f3748 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Tue, 13 Jan 2026 06:18:47 +0100
Subject: [PATCH] =?UTF-8?q?Remove=20hardcoded=20numPlayersConfig,=20calcul?=
=?UTF-8?q?ate=20it=20based=20on=20the=20maps=20land=20tiles=20?=
=?UTF-8?q?=F0=9F=94=A7=20(#2874)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
The calculation is based on: 50 players per 1_000_000 land tiles,
limited at 125 players because of performance
Second number is 75% of that, third one 50%
That way, the player counts are staying mostly the same
Look at the "Dynamic Config" column, these are the new player counts:
(The 125 players limit is missing in that column, only relevant for the
twolakes map)
This PR also removes `MapDescription` from `Maps.ts` because its unused.
And this PR updates the map-generator `README.md` to reflect the changes
## Please complete the following:
- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
FloPinguin
---
map-generator/README.md | 6 +-
src/client/components/Maps.ts | 50 -----------
src/core/configuration/Config.ts | 11 ---
src/core/configuration/DefaultConfig.ts | 105 ------------------------
src/server/MapLandTiles.ts | 28 +++++++
src/server/MapPlaylist.ts | 99 +++++++++++++++++++---
src/server/Master.ts | 2 +-
src/server/Worker.ts | 2 +-
tests/util/TestServerConfig.ts | 6 +-
9 files changed, 122 insertions(+), 187 deletions(-)
create mode 100644 src/server/MapLandTiles.ts
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.");
}
}