Improve MapPlaylist 🎲 (#3904)

## Description:

### 1. `SPECIAL_MODIFIER_POOL` rebalanced
Ticket weights adjusted to roughly track the community "favorite
modifier" poll

<img width="486" height="724" alt="Screenshot 2026-05-11 210740"
src="https://github.com/user-attachments/assets/bb1d2461-beb3-41c0-8d7b-b604db5fc033"
/>

- `isRandomSpawn`: 2 to 4
- `goldMultiplier`: 4 to 6
- `isWaterNukes`: 3 to 4
- `startingGold25M`: 1 to 3
- `startingGold5M`: 5 to 4
- `startingGold1M`: 3 to 2

### 2. New `SPECIAL_TEAM_MAPS` config
Replaces the hardcoded per-map branches in `getTeamCount` and
`buildMapsList`. Each entry maps a `GameMapType` to its preferred
`TeamCountConfig`. Shared constants:
- `SPECIAL_TEAM_FORCE_CHANCE = 0.75` (probability of overriding the
random team weights roll)
- `SPECIAL_TEAM_FREQ_MULTIPLIER = 2` (frequency boost in the team
playlist)

Current entries: Baikal (2), FourIslands (4), Luna (2). Behavior
preserved for the existing maps, but adding another special team map is
now a one-line entry.

### 3. New `FULL_LAND_MAPS` config (TheBox, Alps)
- Water nukes forced on 75% of the time in the special rotation
(overrides `WATER_NUKES_BOOSTED_MAPS`, which still applies its 50% boost
to FourIslands, Baikal, Luna, ArchipelagoSea). Because they make a lot
of fun on these two maps.
- The `isPortsDisabled` modifier is excluded unless water nukes is
boosted on, since ports are pointless on full-land maps. Because this
happened:
<img width="516" height="292" alt="image"
src="https://github.com/user-attachments/assets/cd9ce31d-25d0-4b35-a8ba-bb3ec1c02b70"
/>

### 4. Misc
- Renamed `frequency` constant to `FREQUENCY` for consistency with other
module-level constants.

### 5. Exclude `isNukesDisabled` on special team maps in team mode

On `SPECIAL_TEAM_MAPS` (FourIslands, Baikal, Luna) in team mode, the
`isNukesDisabled` modifier is now excluded from the pool. Otherwise an
extreme warship spam will follow.

## 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-05-12 04:27:02 +02:00
committed by GitHub
parent 5279f9b4ec
commit 990eba6134
2 changed files with 58 additions and 25 deletions
+57 -24
View File
@@ -21,6 +21,7 @@ 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]);
@@ -29,7 +30,7 @@ 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>> = {
const FREQUENCY: Partial<Record<GameMapName, number>> = {
Achiran: 5,
Aegean: 6,
Africa: 7,
@@ -114,6 +115,17 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ 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"
@@ -131,21 +143,22 @@ type ModifierKey =
| "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>(2).fill("isRandomSpawn"),
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(4).fill("isCompact"),
...Array<ModifierKey>(2).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(3).fill("startingGold1M"),
...Array<ModifierKey>(5).fill("startingGold5M"),
...Array<ModifierKey>(1).fill("startingGold25M"),
...Array<ModifierKey>(4).fill("goldMultiplier"),
...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>(3).fill("isWaterNukes"),
...Array<ModifierKey>(4).fill("isWaterNukes"),
];
// Maps where water nukes have a higher chance on top of the normal pool
@@ -153,12 +166,18 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
const WATER_NUKES_BOOSTED_MAPS: ReadonlySet<GameMapType> = new Set([
GameMapType.FourIslands,
GameMapType.Baikal,
GameMapType.Alps,
GameMapType.TheBox,
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"],
@@ -260,6 +279,13 @@ export class MapPlaylist {
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
@@ -267,12 +293,21 @@ export class MapPlaylist {
// Boost water nukes chance
// When boosted, water nukes is forced on and takes one modifier slot.
const boostWaterNukes =
WATER_NUKES_BOOSTED_MAPS.has(map) && Math.random() < 0.5;
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,
@@ -519,10 +554,10 @@ export class MapPlaylist {
) {
return;
}
let freq = frequency[key] ?? 0;
// Double frequency for Baikal and FourIslands in team games
if (type === "team" && (key === "Baikal" || key === "FourIslands")) {
freq *= 2;
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);
@@ -532,15 +567,13 @@ export class MapPlaylist {
}
private getTeamCount(map: GameMapType): TeamCountConfig {
// Override team count for specific maps (75% chance)
if (map === GameMapType.Baikal && Math.random() < 0.75) {
return 2;
}
if (map === GameMapType.FourIslands && Math.random() < 0.75) {
return 4;
}
if (map === GameMapType.Luna && Math.random() < 0.75) {
return 2;
// 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);
+1 -1
View File
@@ -71,7 +71,7 @@ function getCategorizedMaps(): Set<string> {
function getFrequencyKeys(): Set<string> {
const content = fs.readFileSync(MAP_PLAYLIST, "utf8");
// Extract the frequency block
const freqMatch = content.match(/const frequency[\s\S]*?\{([\s\S]*?)\};/);
const freqMatch = content.match(/const FREQUENCY[\s\S]*?\{([\s\S]*?)\};/);
if (!freqMatch) {
throw new Error(
`Failed to parse frequency record from MapPlaylist.ts (first 200 chars: ${content.slice(0, 200)})`,