Files
OpenFrontIO/src/core/game/NationCreation.ts
T
FloPinguin ebcb654825 Added a public game modifier system 😮 For more variety (#2801)
## Description:

Added a public game modifier system. It causes that

5% of public games are played on the compact version of the map
10% of public games have "Random Spawn" activated

Percentages can easily get changed via `DefaultConfig`.
We can also easily add more modifiers.
Modifiers can stack, so in rare cases you will play on a compact map
with random spawn 😄
More variety!

### "Compact Map" modifier implementation

- With the "Compact Map" modifier the lobby max player count gets
reduced to 25% and only 25% of the regular bots and only 25% of the
regular nations will spawn (because the map has only 25% of its regular
size)
- In private lobbies and singleplayer the nation reduction happens too
(When "Compact Map" is enabled).

### Restrictions

- Duos/Trios/Quads team modes do not get Random Spawn (defeats the
purpose)
- Maps with smallest player count < 50 do not get Compact Map in team
games (not enough players after the reduction to 25%). I have calculated
all the possible max player counts.

### How it looks like

Random Spawn modifier:

<img width="528" height="183" alt="Screenshot 2026-01-06 194959"
src="https://github.com/user-attachments/assets/2f729da9-80c3-4548-8205-71129da2a76a"
/>

Very rare case: Two modifiers at the same time and only 10 max players
have been chosen from `[GameMapType.FaroeIslands]: [20, 15, 10]`.
Because of the 75% reduction in player count only 3 players are allowed
(3 is the minimum). I think its funny that you can play a 1v1v1 in rare
occasions 😄

<img width="526" height="184" alt="Screenshot 2026-01-06 194938"
src="https://github.com/user-attachments/assets/834326eb-df03-41b7-b1db-1efa3f1013b5"
/>

### Funny side-effect

Team games with random spawn. That will be interesting. No more "Who is
better in donating troops to the frontline". Instead you have to heavily
coordinate with your teammates.

## 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
2026-01-06 19:37:58 -08:00

333 lines
7.9 KiB
TypeScript

import { PseudoRandom } from "../PseudoRandom";
import { GameStartInfo } from "../Schemas";
import {
Cell,
GameMapSize,
GameMode,
GameType,
HumansVsNations,
Nation,
PlayerInfo,
PlayerType,
} from "./Game";
import { Nation as ManifestNation } from "./TerrainMapLoader";
/**
* Creates the nations array for a game, handling HumansVsNations mode specially.
* In HumansVsNations mode, the number of nations matches the number of human players to ensure fair gameplay.
* For compact maps, only 25% of the nations are used.
*/
export function createNationsForGame(
gameStart: GameStartInfo,
manifestNations: ManifestNation[],
numHumans: number,
random: PseudoRandom,
): Nation[] {
if (gameStart.config.disableNations) {
return [];
}
const toNation = (n: ManifestNation): Nation =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
);
const isCompactMap = gameStart.config.gameMapSize === GameMapSize.Compact;
const isHumansVsNations =
gameStart.config.gameMode === GameMode.Team &&
gameStart.config.playerTeams === HumansVsNations;
// For compact maps, use only 25% of nations (minimum 1)
let effectiveNations = manifestNations;
if (isCompactMap && !isHumansVsNations) {
const targetCount = getCompactMapNationCount(manifestNations.length, true);
const shuffled = random.shuffleArray(manifestNations);
effectiveNations = shuffled.slice(0, targetCount);
}
// For non-HumansVsNations modes, simply use the effective nations
if (!isHumansVsNations) {
return effectiveNations.map(toNation);
}
// HumansVsNations mode: balance nation count to match human count
const isSingleplayer = gameStart.config.gameType === GameType.Singleplayer;
const targetNationCount = isSingleplayer ? 1 : numHumans;
if (targetNationCount === 0) {
return [];
}
// If we have enough manifest nations, use a subset
if (manifestNations.length >= targetNationCount) {
// Shuffle manifest nations to add variety
const shuffled = random.shuffleArray(manifestNations);
return shuffled.slice(0, targetNationCount).map(toNation);
}
// If we need more nations than defined in manifest, create additional ones
const nations: Nation[] = manifestNations.map(toNation);
const additionalCount = targetNationCount - manifestNations.length;
for (let i = 0; i < additionalCount; i++) {
const name = generateNationName(random);
nations.push(
new Nation(
undefined,
new PlayerInfo(name, PlayerType.Nation, null, random.nextID()),
),
);
}
return nations;
}
// For compact maps, only 25% of nations are used (minimum 1).
export function getCompactMapNationCount(
manifestNationCount: number,
isCompactMap: boolean,
): number {
if (manifestNationCount === 0) {
return 0;
}
if (isCompactMap) {
return Math.max(1, Math.floor(manifestNationCount * 0.25));
}
return manifestNationCount;
}
const PLURAL_NOUN = Symbol("plural!");
const NOUN = Symbol("noun!");
type NameTemplate = (string | typeof PLURAL_NOUN | typeof NOUN)[];
const NAME_TEMPLATES: NameTemplate[] = [
["World Famous", NOUN],
["Famous", PLURAL_NOUN],
["Comically Large", NOUN],
["Comically Small", NOUN],
["Massive", PLURAL_NOUN],
["Friendly", NOUN],
["Evil", NOUN],
["Malicious", NOUN],
["Spiteful", NOUN],
["Suspicious", NOUN],
["Canonically Evil", NOUN],
["Limited Edition", NOUN],
["Patent Pending", NOUN],
["Patented", NOUN],
["Space", NOUN],
["Defend The", PLURAL_NOUN],
["Anarchist", NOUN],
["Republic of", PLURAL_NOUN],
["Slippery", NOUN],
["Wealthy", PLURAL_NOUN],
["Certified", NOUN],
["Dr", NOUN],
["Runaway", NOUN],
["Chrome", NOUN],
["All New", NOUN],
["Top Shelf", PLURAL_NOUN],
["Invading", PLURAL_NOUN],
["Loyal To", PLURAL_NOUN],
["United States of", NOUN],
["United States of", PLURAL_NOUN],
["Flowing Rivers of", NOUN],
["House of", PLURAL_NOUN],
["Certified Organic", NOUN],
["Unregulated", NOUN],
[NOUN, "For Hire"],
[PLURAL_NOUN, "That Bite"],
[PLURAL_NOUN, "Are Opps"],
[NOUN, "Hotel"],
[PLURAL_NOUN, "The Movie"],
[NOUN, "Corporation"],
[PLURAL_NOUN, "Inc"],
[NOUN, "Democracy"],
[NOUN, "Network"],
[NOUN, "Railway"],
[NOUN, "Congress"],
[NOUN, "Alliance"],
[NOUN, "Island"],
[NOUN, "Kingdom"],
[NOUN, "Empire"],
[NOUN, "Dynasty"],
[NOUN, "Cartel"],
[NOUN, "Cabal"],
[NOUN, "Land"],
[NOUN, "Oligarchy"],
[NOUN, "Nationalist"],
[NOUN, "State"],
[NOUN, "Duchy"],
[NOUN, "Ocean"],
["Alternate", NOUN, "Universe"],
["Famous", NOUN, "Collection"],
["Supersonic", NOUN, "Spaceship"],
["Secret", NOUN, "Agenda"],
["Ballistic", NOUN, "Missile"],
["The", PLURAL_NOUN, "are SPIES"],
["Traveling", NOUN, "Circus"],
["The", PLURAL_NOUN, "Lied"],
["Sacred", NOUN, "Knowledge"],
["Quantum", NOUN, "Computer"],
["Hadron", NOUN, "Collider"],
["Large", NOUN, "Obliterator"],
["Interstellar", NOUN, "Cabal"],
["Interstellar", NOUN, "Army"],
["Interstellar", NOUN, "Pirates"],
["Interstellar", NOUN, "Dynasty"],
["Interstellar", NOUN, "Clan"],
["Galactic", NOUN, "Smugglers"],
["Galactic", NOUN, "Cabal"],
["Galactic", NOUN, "Alliance"],
["Galactic", NOUN, "Empire"],
["Galactic", NOUN, "Army"],
["Galactic", NOUN, "Crown"],
["Galactic", NOUN, "Pirates"],
["Galactic", NOUN, "Dynasty"],
["Galactic", NOUN, "Clan"],
["Alien", NOUN, "Army"],
["Alien", NOUN, "Cabal"],
["Alien", NOUN, "Alliance"],
["Alien", NOUN, "Empire"],
["Alien", NOUN, "Pirates"],
["Alien", NOUN, "Clan"],
["Grand", NOUN, "Empire"],
["Grand", NOUN, "Dynasty"],
["Grand", NOUN, "Army"],
["Grand", NOUN, "Cabal"],
["Grand", NOUN, "Alliance"],
["Royal", NOUN, "Army"],
["Royal", NOUN, "Cabal"],
["Royal", NOUN, "Empire"],
["Royal", NOUN, "Dynasty"],
["Holy", NOUN, "Dynasty"],
["Holy", NOUN, "Empire"],
["Holy", NOUN, "Alliance"],
["Eternal", NOUN, "Empire"],
["Eternal", NOUN, "Cabal"],
["Eternal", NOUN, "Alliance"],
["Eternal", NOUN, "Dynasty"],
["Invading", NOUN, "Cabal"],
["Invading", NOUN, "Empire"],
["Invading", NOUN, "Alliance"],
["Immortal", NOUN, "Pirates"],
["Shadow", NOUN, "Cabal"],
["Secret", NOUN, "Dynasty"],
["The Great", NOUN, "Army"],
["The", NOUN, "Matrix"],
];
const NOUNS = [
"Snail",
"Cow",
"Giraffe",
"Donkey",
"Horse",
"Mushroom",
"Salad",
"Kitten",
"Fork",
"Apple",
"Pancake",
"Tree",
"Fern",
"Seashell",
"Turtle",
"Casserole",
"Gnome",
"Frog",
"Cheese",
"Mold",
"Clown",
"Boat",
"Robot",
"Millionaire",
"Billionaire",
"Pigeon",
"Fish",
"Bumblebee",
"Jelly",
"Wizard",
"Worm",
"Rat",
"Pumpkin",
"Zombie",
"Grass",
"Bear",
"Skunk",
"Sandwich",
"Butter",
"Soda",
"Pickle",
"Potato",
"Book",
"Friend",
"Feather",
"Flower",
"Oil",
"Train",
"Fan",
"Salmon",
"Cod",
"Sink",
"Villain",
"Bug",
"Car",
"Soup",
"Puppy",
"Rock",
"Stick",
"Succulent",
"Nerd",
"Mercenary",
"Ninja",
"Burger",
"Tomato",
"Penguin",
];
function generateNationName(random: PseudoRandom): string {
const template = NAME_TEMPLATES[random.nextInt(0, NAME_TEMPLATES.length)];
const noun = NOUNS[random.nextInt(0, NOUNS.length)];
const result: string[] = [];
for (const part of template) {
if (part === PLURAL_NOUN) {
result.push(pluralize(noun));
} else if (part === NOUN) {
result.push(noun);
} else {
result.push(part);
}
}
return result.join(" ");
}
// Words from NOUNS that need irregular "-oes" plural
const O_TO_OES = new Set(["Potato", "Tomato"]);
function pluralize(noun: string): string {
if (
noun.endsWith("s") ||
noun.endsWith("ch") ||
noun.endsWith("sh") ||
noun.endsWith("x") ||
noun.endsWith("z")
) {
return `${noun}es`;
}
if (noun.endsWith("y") && !"aeiou".includes(noun[noun.length - 2])) {
return `${noun.slice(0, -1)}ies`;
}
if (O_TO_OES.has(noun)) {
return `${noun}es`;
}
return `${noun}s`;
}