diff --git a/resources/lang/en.json b/resources/lang/en.json
index 414222d57c..2b87d9ed6e 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -274,7 +274,8 @@
"teams_Duos": "of 2 (Duos)",
"teams_Trios": "of 3 (Trios)",
"teams_Quads": "of 4 (Quads)",
- "teams_hvn": "Humans Vs Nations",
+ "teams_hvn": "Humans vs Nations",
+ "teams_hvn_detailed": "{num} Humans vs {num} Nations",
"teams": "{num} teams",
"players_per_team": "of {num}"
},
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 247ebe441c..12ea776ae2 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -551,9 +551,9 @@ export class HostLobbyModal extends LitElement {
: translateText("host_modal.players")
}
•
- ${this.disableNations ? 0 : this.nationCount}
+ ${this.getEffectiveNationCount()}
${
- this.nationCount === 1
+ this.getEffectiveNationCount() === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")
}
@@ -564,7 +564,7 @@ export class HostLobbyModal extends LitElement {
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
- .nationCount=${this.disableNations ? 0 : this.nationCount}
+ .nationCount=${this.getEffectiveNationCount()}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
>
@@ -876,6 +876,21 @@ export class HostLobbyModal extends LitElement {
this.nationCount = 0;
}
}
+
+ /**
+ * Returns the effective nation count for display purposes.
+ * In HumansVsNations mode, this equals the number of human players.
+ * Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
+ */
+ private getEffectiveNationCount(): number {
+ if (this.disableNations) {
+ return 0;
+ }
+ if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
+ return this.clients.length;
+ }
+ return this.nationCount;
+ }
}
async function createLobby(creatorClientID: string): Promise {
diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts
index 363540dd2e..a2c08996c0 100644
--- a/src/client/PublicLobby.ts
+++ b/src/client/PublicLobby.ts
@@ -135,6 +135,7 @@ export class PublicLobby extends LitElement {
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
+ teamSize,
);
const teamDetailLabel = this.getTeamDetailLabel(
lobby.gameConfig.gameMode,
@@ -241,7 +242,7 @@ export class PublicLobby extends LitElement {
if (teamCount === Duos) return 2;
if (teamCount === Trios) return 3;
if (teamCount === Quads) return 4;
- if (teamCount === HumansVsNations) return Math.floor(maxPlayers / 2);
+ if (teamCount === HumansVsNations) return maxPlayers;
return undefined;
}
if (typeof teamCount === "number" && teamCount > 0) {
@@ -265,10 +266,13 @@ export class PublicLobby extends LitElement {
gameMode: GameMode,
teamCount: number | string | null,
teamTotal: number | undefined,
+ teamSize: number | undefined,
): string {
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
- if (teamCount === HumansVsNations)
- return translateText("public_lobby.teams_hvn");
+ if (teamCount === HumansVsNations && teamSize !== undefined)
+ return translateText("public_lobby.teams_hvn_detailed", {
+ num: teamSize,
+ });
const totalTeams =
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
return translateText("public_lobby.teams", { num: totalTeams });
@@ -282,7 +286,11 @@ export class PublicLobby extends LitElement {
): string | null {
if (gameMode !== GameMode.Team) return null;
- if (typeof teamCount === "string" && teamCount !== HumansVsNations) {
+ if (typeof teamCount === "string" && teamCount === HumansVsNations) {
+ return null;
+ }
+
+ if (typeof teamCount === "string") {
const teamKey = `public_lobby.teams_${teamCount}`;
const maybeTranslated = translateText(teamKey);
if (maybeTranslated !== teamKey) return maybeTranslated;
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 92eba215b7..d434ba49e3 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -9,7 +9,6 @@ import {
Game,
GameUpdates,
NameViewData,
- Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -26,6 +25,7 @@ import {
GameUpdateType,
GameUpdateViewData,
} from "./game/GameUpdates";
+import { createNationsForGame } from "./game/NationUtils";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
@@ -56,15 +56,12 @@ export async function createGameRunner(
);
});
- const nations = gameStart.config.disableNations
- ? []
- : gameMap.nations.map(
- (n) =>
- new Nation(
- new Cell(n.coordinates[0], n.coordinates[1]),
- new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
- ),
- );
+ const nations = createNationsForGame(
+ gameStart,
+ gameMap.nations,
+ humans.length,
+ random,
+ );
const game: Game = createGame(
humans,
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index ec22579a94..9d40aab1c8 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -187,7 +187,8 @@ export abstract class DefaultServerConfig implements ServerConfig {
p -= p % 4;
break;
case HumansVsNations:
- // For HumansVsNations, return the base team player count
+ // Half the slots are for humans, the other half will get filled with nations
+ p = Math.floor(p / 2);
break;
default:
p -= p % numPlayerTeams;
diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts
index 835998a1b8..9615913785 100644
--- a/src/core/execution/NationExecution.ts
+++ b/src/core/execution/NationExecution.ts
@@ -116,7 +116,15 @@ export class NationExecution implements Execution {
}
if (this.mg.inSpawnPhase()) {
- // select a tile near the position defined in the map manifest for the current nation
+ // Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution
+ if (this.nation.spawnCell === undefined) {
+ this.mg.addExecution(
+ new SpawnExecution(this.gameID, this.nation.playerInfo),
+ );
+ return;
+ }
+
+ // Select a tile near the position defined in the map manifest
const rl = this.randomSpawnLand();
if (rl === null) {
@@ -194,6 +202,8 @@ export class NationExecution implements Execution {
}
private randomSpawnLand(): TileRef | null {
+ if (this.nation.spawnCell === undefined) throw new Error("not initialized");
+
const delta = 25;
let tries = 0;
while (tries < 50) {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 4a33d9b804..5920dddfdd 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -312,7 +312,7 @@ export enum Relation {
export class Nation {
constructor(
- public readonly spawnCell: Cell,
+ public readonly spawnCell: Cell | undefined,
public readonly playerInfo: PlayerInfo,
) {}
}
diff --git a/src/core/game/NationUtils.ts b/src/core/game/NationUtils.ts
new file mode 100644
index 0000000000..076ccfd72b
--- /dev/null
+++ b/src/core/game/NationUtils.ts
@@ -0,0 +1,306 @@
+import { PseudoRandom } from "../PseudoRandom";
+import { GameStartInfo } from "../Schemas";
+import {
+ Cell,
+ 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.
+ */
+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 isHumansVsNations =
+ gameStart.config.gameMode === GameMode.Team &&
+ gameStart.config.playerTeams === HumansVsNations;
+
+ // For non-HumansVsNations modes, simply use the manifest nations
+ if (!isHumansVsNations) {
+ return manifestNations.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;
+}
+
+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`;
+}
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index 22c258f59f..6950a24f67 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -74,6 +74,7 @@ const TEAM_COUNTS = [
Duos,
Trios,
Quads,
+ HumansVsNations,
] as const satisfies TeamCountConfig[];
export class MapPlaylist {
@@ -95,7 +96,8 @@ export class MapPlaylist {
maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams),
gameType: GameType.Public,
gameMapSize: GameMapSize.Normal,
- difficulty: Difficulty.Easy,
+ difficulty:
+ playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,