Files
OpenFrontIO/src/server/MapPlaylist.ts
T
RickD004 7dc5d472a7 Change name of map "The Straits" into "Danish Straits" (#3929)
## Description:

Renames TheStraits map. The people that suggested this map told me they
would prefer a more specific name for the map, rather than the generic
one it has right now. So im renaming it into Danish Straits

This map is for v32, it has not been released, it should be fine to
rename


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

tri.star1011
2026-05-15 08:59:28 +00:00

793 lines
24 KiB
TypeScript

import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config";
import {
Difficulty,
Duos,
GameMapName,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
PublicGameModifiers,
Quads,
RankedType,
Trios,
UnitType,
mapCategories,
} from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas";
import { logger } from "./Logger";
import { getMapLandTiles } from "./MapLandTiles";
const log = logger.child({});
const ARCADE_MAPS = new Set(mapCategories.arcade);
const SPECIAL_ONLY_MAPS = new Set<GameMapType>([GameMapType.ArchipelagoSea]);
// Hard cap on player count for performance. Applied after compact-map reduction.
const MAX_PLAYER_COUNT = 125;
// 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>> = {
Achiran: 5,
Aegean: 6,
Africa: 7,
Alps: 4,
AmazonRiver: 3,
Antarctica: 1,
ArchipelagoSea: 3,
Arctic: 6,
Asia: 6,
Australia: 4,
Baikal: 5,
BajaCalifornia: 4,
BeringSea: 5,
BeringStrait: 2,
BetweenTwoSeas: 5,
BlackSea: 6,
BosphorusStraits: 3,
Britannia: 5,
Caucasus: 5,
Conakry: 3,
DanishStraits: 5,
DeglaciatedAntarctica: 4,
Didier: 1,
DidierFrance: 1,
Dyslexdria: 8,
EastAsia: 5,
Europe: 7,
FalklandIslands: 4,
FaroeIslands: 4,
FourIslands: 4,
GatewayToTheAtlantic: 5,
GreatLakes: 6,
GulfOfStLawrence: 4,
Halkidiki: 4,
Hawaii: 4,
Iceland: 4,
Italia: 6,
Japan: 6,
Lemnos: 3,
Lisbon: 4,
LosAngeles: 8,
Luna: 6,
Manicouagan: 4,
MareNostrum: 6,
Mars: 3,
Mena: 6,
MiddleEast: 8,
MilkyWay: 8,
Montreal: 6,
NewYorkCity: 3,
NileDelta: 4,
NorthAmerica: 5,
NorthwestPassage: 5,
Pangaea: 5,
Passage: 4,
Pluto: 6,
SanFrancisco: 3,
Sierpinski: 10,
SouthAmerica: 5,
StraitOfGibraltar: 5,
StraitOfHormuz: 4,
StraitOfMalacca: 4,
Surrounded: 4,
Svalmel: 8,
TaiwanStrait: 5,
TheBox: 3,
TradersDream: 4,
TwoLakes: 6,
World: 20,
Yenisei: 6,
};
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ config: 2, weight: 10 },
{ config: 3, weight: 10 },
{ config: 4, weight: 10 },
{ config: 5, weight: 10 },
{ config: 6, weight: 10 },
{ config: 7, weight: 10 },
{ config: Duos, weight: 5 },
{ config: Trios, weight: 7.5 },
{ config: Quads, weight: 7.5 },
{ config: HumansVsNations, weight: 20 },
];
// Maps with a preferred team count in team / special games.
// For these maps: team-playlist frequency is doubled, and the preferred
// team count overrides the random TEAM_WEIGHTS roll with SPECIAL_TEAM_FORCE_CHANCE.
const SPECIAL_TEAM_FORCE_CHANCE = 0.75;
const SPECIAL_TEAM_FREQ_MULTIPLIER = 2;
const SPECIAL_TEAM_MAPS: ReadonlyMap<GameMapType, TeamCountConfig> = new Map([
[GameMapType.Baikal, 2],
[GameMapType.FourIslands, 4],
[GameMapType.Luna, 2],
]);
type ModifierKey =
| "isRandomSpawn"
| "isCompact"
| "isCrowded"
| "isHardNations"
| "startingGold1M"
| "startingGold5M"
| "startingGold25M"
| "goldMultiplier"
| "isAlliancesDisabled"
| "isPortsDisabled"
| "isNukesDisabled"
| "isSAMsDisabled"
| "isPeaceTime"
| "isWaterNukes";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
// Weights are roughly informed by the community "favorite modifier" poll.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(4).fill("isCompact"),
...Array<ModifierKey>(2).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(2).fill("startingGold1M"),
...Array<ModifierKey>(4).fill("startingGold5M"),
...Array<ModifierKey>(3).fill("startingGold25M"),
...Array<ModifierKey>(6).fill("goldMultiplier"),
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
...Array<ModifierKey>(1).fill("isPortsDisabled"),
...Array<ModifierKey>(1).fill("isNukesDisabled"),
...Array<ModifierKey>(1).fill("isSAMsDisabled"),
...Array<ModifierKey>(1).fill("isPeaceTime"),
...Array<ModifierKey>(4).fill("isWaterNukes"),
];
// Maps where water nukes have a higher chance on top of the normal pool
// Water nukes are especially fun here
const WATER_NUKES_BOOSTED_MAPS: ReadonlySet<GameMapType> = new Set([
GameMapType.FourIslands,
GameMapType.Baikal,
GameMapType.Luna,
GameMapType.ArchipelagoSea,
]);
// Maps that are entirely land.
// - Water nukes forced on 75% of the time (overrides WATER_NUKES_BOOSTED_MAPS)
// - The "ports disabled" modifier is only allowed when water nukes is on
const FULL_LAND_MAPS: ReadonlySet<GameMapType> = new Set([
GameMapType.TheBox,
GameMapType.Alps,
]);
// Modifiers that cannot be active at the same time.
const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["startingGold5M", "startingGold25M"],
["startingGold5M", "startingGold1M"],
["startingGold25M", "startingGold1M"],
["isHardNations", "startingGold25M"],
["isNukesDisabled", "isSAMsDisabled"],
["isNukesDisabled", "isWaterNukes"],
];
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
special: [],
team: [],
};
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
if (type === "special") {
return this.getSpecialConfig();
}
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap(type);
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
let isCompact: boolean | undefined =
this.playlists[type].length % 3 === 0 || undefined;
if (
isCompact &&
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map, playerTeams!))
) {
isCompact = undefined;
}
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: {
isCompact,
},
difficulty:
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
nations:
mode === GameMode.Team && playerTeams !== HumansVsNations
? "disabled"
: "default",
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(playerTeams),
disabledUnits: [],
} satisfies GameConfig;
}
private async getSpecialConfig(): Promise<GameConfig> {
const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap("special");
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const excludedModifiers: ModifierKey[] = [];
// Check if compact map would leave every team with at least 2 players
const supportsCompact =
mode !== GameMode.Team ||
(await this.supportsCompactMapForTeams(map, playerTeams!));
if (!supportsCompact) {
excludedModifiers.push("isCompact");
}
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
playerTeams === Quads
) {
excludedModifiers.push("isRandomSpawn");
}
// No gold multi on FourIslands team games - Too high chance of 3h long stalemates
if (map === GameMapType.FourIslands && mode === GameMode.Team) {
excludedModifiers.push("goldMultiplier");
}
// Hard nations modifier only applies when nations are present (not HvN, which is always hard)
if (mode === GameMode.Team) {
excludedModifiers.push("isHardNations");
}
// On special team maps nukes-disabled makes cross-water attacks
// nearly impossible (extreme warship spam).
if (mode === GameMode.Team && SPECIAL_TEAM_MAPS.has(map)) {
excludedModifiers.push("isNukesDisabled");
}
if (playerTeams === HumansVsNations) {
excludedModifiers.push("startingGold25M"); // Nations are disabled if that modifier is active (Because of PVP immunity)
excludedModifiers.push("isPeaceTime"); // Nations don't have PVP immunity
}
// Boost water nukes chance
// When boosted, water nukes is forced on and takes one modifier slot.
const waterNukesBoostChance = FULL_LAND_MAPS.has(map)
? 0.75
: WATER_NUKES_BOOSTED_MAPS.has(map)
? 0.5
: 0;
const boostWaterNukes = Math.random() < waterNukesBoostChance;
if (boostWaterNukes) {
excludedModifiers.push("isWaterNukes", "isNukesDisabled");
}
// On full-land maps, ports-disabled is only allowed alongside water nukes
if (FULL_LAND_MAPS.has(map) && !boostWaterNukes) {
excludedModifiers.push("isPortsDisabled");
}
const poolResult = this.getRandomSpecialGameModifiers(
excludedModifiers,
undefined,
boostWaterNukes ? 1 : 0,
);
let {
isCrowded,
startingGold,
isCompact,
isRandomSpawn,
goldMultiplier,
isAlliancesDisabled,
isHardNations,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
} = poolResult;
if (boostWaterNukes) {
isWaterNukes = true;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, !!isCompact);
if (crowdedMaxPlayers !== undefined) {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
} else {
// 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 = undefined;
if (
!isRandomSpawn &&
!isCompact &&
!isHardNations &&
startingGold === undefined &&
goldMultiplier === undefined &&
!isAlliancesDisabled &&
!isPortsDisabled &&
!isNukesDisabled &&
!isSAMsDisabled &&
!isPeaceTime &&
!isWaterNukes
) {
excludedModifiers.push("isCrowded");
const fallback = this.getRandomSpecialGameModifiers(
excludedModifiers,
1,
);
({
isRandomSpawn,
isCompact,
startingGold,
goldMultiplier,
isAlliancesDisabled,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
} = fallback);
({ isHardNations } = fallback);
}
}
}
const maxPlayers = Math.max(
2,
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
);
const nations: GameConfig["nations"] =
(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)
? "disabled"
: "default";
// Build disabledUnits from modifiers
const disabledUnits: UnitType[] = [];
if (isPortsDisabled) {
disabledUnits.push(UnitType.Port);
}
if (isNukesDisabled) {
disabledUnits.push(
UnitType.MissileSilo,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.SAMLauncher,
);
}
if (isSAMsDisabled) {
disabledUnits.push(UnitType.SAMLauncher);
}
// 4min peace = 240s = 2400 ticks
const peaceTimeDuration = isPeaceTime ? 240 * 10 : undefined;
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers,
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
goldMultiplier,
isAlliancesDisabled,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
},
startingGold,
goldMultiplier,
disableAlliances: isAlliancesDisabled ? true : undefined,
difficulty:
isHardNations || playerTeams === HumansVsNations
? Difficulty.Hard
: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn ? true : false,
nations,
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration:
peaceTimeDuration ??
this.getSpawnImmunityDuration(playerTeams, startingGold),
disabledUnits,
waterNukes: isWaterNukes ? true : undefined,
} satisfies GameConfig;
}
public get1v1Config(): GameConfig {
const maps = [
GameMapType.Australia, // 40%
GameMapType.Australia,
GameMapType.Iceland, // 20%
GameMapType.Asia, // 20%
GameMapType.EuropeClassic, // 20%
];
const isCompact = Math.random() < 0.5;
return {
donateGold: false,
donateTroops: false,
gameMap: maps[Math.floor(Math.random() * maps.length)],
maxPlayers: 2,
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
difficulty: Difficulty.Medium, // Doesn't matter, nations are disabled
rankedType: RankedType.OneVOne,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: isCompact ? 10 : 15,
instantBuild: false,
randomSpawn: false,
nations: "disabled",
gameMode: GameMode.FFA,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: 30 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
private getNextMap(type: PublicGameType): GameMapType {
const playlist = this.playlists[type];
if (playlist.length === 0) {
playlist.push(...this.generateNewPlaylist(type));
}
return playlist.shift()!;
}
private generateNewPlaylist(type: PublicGameType): GameMapType[] {
const maps = this.buildMapsList(type);
const rand = new PseudoRandom(Date.now());
const playlist: GameMapType[] = [];
const numAttempts = 10000;
for (let attempt = 0; attempt < numAttempts; attempt++) {
playlist.length = 0;
// Re-shuffle every attempt so retries can explore different orderings.
const source = rand.shuffleArray([...maps]);
let success = true;
while (source.length > 0) {
if (!this.addNextMapNonConsecutive(playlist, source)) {
success = false;
break;
}
}
if (success) {
log.info(`Generated map playlist in ${attempt} attempts`);
return playlist;
}
}
log.warn(
`Failed to generate non-consecutive playlist after ${numAttempts} attempts, falling back to shuffle`,
);
return rand.shuffleArray([...maps]);
}
private addNextMapNonConsecutive(
playlist: GameMapType[],
source: GameMapType[],
): boolean {
const nonConsecutiveNum = 5;
const lastMaps = playlist.slice(-nonConsecutiveNum);
for (let i = 0; i < source.length; i++) {
const map = source[i];
if (!lastMaps.includes(map)) {
source.splice(i, 1);
playlist.push(map);
return true;
}
}
return false;
}
private buildMapsList(type: PublicGameType): GameMapType[] {
const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
const map = GameMapType[key];
if (
type !== "special" &&
(ARCADE_MAPS.has(map) || SPECIAL_ONLY_MAPS.has(map))
) {
return;
}
let freq = FREQUENCY[key] ?? 0;
// Boost frequency for special team maps in the team playlist
if (type === "team" && SPECIAL_TEAM_MAPS.has(map)) {
freq *= SPECIAL_TEAM_FREQ_MULTIPLIER;
}
for (let i = 0; i < freq; i++) {
maps.push(map);
}
});
return maps;
}
private getTeamCount(map: GameMapType): TeamCountConfig {
// Override team count for specific maps
const forcedTeamCount = SPECIAL_TEAM_MAPS.get(map);
if (
forcedTeamCount !== undefined &&
Math.random() < SPECIAL_TEAM_FORCE_CHANCE
) {
return forcedTeamCount;
}
const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0);
const roll = Math.random() * totalWeight;
let cumulativeWeight = 0;
for (const { config, weight } of TEAM_WEIGHTS) {
cumulativeWeight += weight;
if (roll < cumulativeWeight) {
return config;
}
}
return TEAM_WEIGHTS[0].config;
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
countReduction: number = 0,
): PublicGameModifiers {
// Roll how many modifiers to pick: 30% → 1, 50% → 2, 20% → 3
const modifierCounts = [1, 1, 1, 2, 2, 2, 2, 2, 3, 3];
const rolled =
modifierCounts[Math.floor(Math.random() * modifierCounts.length)];
const k = Math.max(0, (count ?? rolled) - countReduction);
// Shuffle the pool, then pick the first k unique modifier keys.
const pool = SPECIAL_MODIFIER_POOL.filter(
(key) => !excludedModifiers.includes(key),
).sort(() => Math.random() - 0.5);
const selected = new Set<ModifierKey>();
for (const key of pool) {
if (selected.size >= k) break;
// 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") || undefined,
isCompact: selected.has("isCompact") || undefined,
isCrowded: selected.has("isCrowded") || undefined,
isHardNations: selected.has("isHardNations") || undefined,
startingGold: selected.has("startingGold25M")
? 25_000_000
: selected.has("startingGold5M")
? 5_000_000
: selected.has("startingGold1M")
? 1_000_000
: undefined,
goldMultiplier: selected.has("goldMultiplier") ? 2 : undefined,
isAlliancesDisabled: selected.has("isAlliancesDisabled") || undefined,
isPortsDisabled: selected.has("isPortsDisabled") || undefined,
isNukesDisabled: selected.has("isNukesDisabled") || undefined,
isSAMsDisabled: selected.has("isSAMsDisabled") || undefined,
isPeaceTime: selected.has("isPeaceTime") || undefined,
isWaterNukes: selected.has("isWaterNukes") || undefined,
};
}
// 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<boolean> {
const landTiles = await getMapLandTiles(map);
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, then cap for performance
p = Math.min(Math.max(3, Math.floor(p * 0.25)), MAX_PLAYER_COUNT);
// Apply team adjustment
p = this.adjustForTeams(p, playerTeams);
// Check at least 2 players per team AND at least 2 teams
return (
this.playersPerTeam(p, playerTeams) >= 2 &&
this.numberOfTeams(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);
}
}
private numberOfTeams(
adjustedPlayerCount: number,
playerTeams: TeamCountConfig,
): number {
switch (playerTeams) {
case Duos:
return Math.floor(adjustedPlayerCount / 2);
case Trios:
return Math.floor(adjustedPlayerCount / 3);
case Quads:
return Math.floor(adjustedPlayerCount / 4);
case HumansVsNations:
return 2; // always 2 teams
default:
return playerTeams; // numeric value IS the team count
}
}
/**
* Centralised spawn-immunity duration logic.
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
* - 25M starting gold: 2:30min (extra time to compensate for high gold)
* - 5M starting gold: SAM build time + 15s (enough to build a SAM)
* - 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 !== undefined && startingGold >= 5_000_000)
return SAM_CONSTRUCTION_TICKS + 15 * 10;
return 5 * 10;
}
private async getCrowdedMaxPlayers(
map: GameMapType,
isCompact: boolean,
): Promise<number | undefined> {
const landTiles = await getMapLandTiles(map);
const [rawFirstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
const firstPlayerCount = Math.min(rawFirstPlayerCount, MAX_PLAYER_COUNT);
if (firstPlayerCount <= 60) {
return isCompact ? 60 : MAX_PLAYER_COUNT;
}
return undefined;
}
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));
}
// Cap for performance
p = Math.min(p, MAX_PLAYER_COUNT);
return this.adjustForTeams(p, numPlayerTeams);
}
private adjustForTeams(
playerCount: number,
numPlayerTeams: TeamCountConfig | undefined,
): number {
if (numPlayerTeams === undefined) return playerCount;
let p = playerCount;
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
* 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 = Math.max(roundToNearest5((landTiles / 1_000_000) * 50), 5);
return [base, roundToNearest5(base * 0.75), roundToNearest5(base * 0.5)];
}
}