Remove hardcoded numPlayersConfig, calculate it based on the maps land tiles 🔧 (#2874)

## 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)

<img width="930" height="1033" alt="Screenshot_2026-01-12_152758"
src="https://github.com/user-attachments/assets/e1791740-e263-47b3-8b27-4f9aa358d381"
/>
<img width="926" height="324" alt="Screenshot_2026-01-12_152814"
src="https://github.com/user-attachments/assets/78d6789b-374f-4f8b-b50f-f6f08395572b"
/>

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
This commit is contained in:
FloPinguin
2026-01-13 06:18:47 +01:00
committed by GitHub
parent 7353d785fb
commit 464a4a817a
9 changed files with 122 additions and 187 deletions
+2 -4
View File
@@ -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
-50
View File
@@ -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<keyof typeof GameMapType, string> = {
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 = "";
-11
View File
@@ -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 {
-105
View File
@@ -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<GameMapType, [number, number, number]>;
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 {
+28
View File
@@ -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<number> {
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
}
}
+87 -12
View File
@@ -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<Record<GameMapName, number>> = {
@@ -88,13 +87,13 @@ export class MapPlaylist {
constructor(private disableTeams: boolean = false) {}
public gameConfig(): GameConfig {
public async gameConfig(): Promise<GameConfig> {
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<boolean> {
// 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<number> {
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) => {
+1 -1
View File
@@ -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()),
},
);
+1 -1
View File
@@ -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(() => {
+3 -3
View File
@@ -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<number> {
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<boolean> {
throw new Error("Method not implemented.");
}
}