From f6412a59797fed5f70503eec17804b030c32dbce Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 29 Dec 2025 06:01:32 +0100 Subject: [PATCH] =?UTF-8?q?Re-Enable=20HumansVsNations=20=F0=9F=8E=89=20(#?= =?UTF-8?q?2689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: **HumansVsNations is back!** The original PR had an issue: only the nations listed in the map’s `manifest.json` were being spawned, which resulted in completely unbalanced games. What did I change with this PR? - The number of humans and nations is now always the same. - If a map contains too many nations, we take a random subset. - If a map doesn’t contain enough nations, we dynamically add additional ones. These get random spawn locations, and their names are taken from the new name generator `NationNames.ts`. The name generator was taken from the closed PR #2245 (idea from @VariableVince). These changes apply to private lobbies and singleplayer as well. In singleplayer, you now simply play a 1-vs-1 against a nation. For public lobbies, we use 50% of the regular team-game player count. The remaining 50% are nations. We are also using the Hard difficulty for HumansVsNations. At the moment, it’s important that nations cheat a little because humans can donate troops, whereas nations cannot, at least not yet. In the future, we may make that work. We might need to adjust the difficulty or do fine-tuning depending on the humans’ win rate in production. Ideally, we want a ~50% win rate; otherwise, the mode may become boring. Over time, humans will likely develop strategies that nations can’t counter, in which case we’ll need to improve the nation AI. Here is a screenshot showing that the number of nations now matches the number of humans in the private lobby UI: Screenshot 2025-12-25 004023 The `PuplicLobby` display was a bit bugged for HumansVsNations: Screenshot 2025-12-23 221832 So I fixed it to look like this; Screenshot 2025-12-23 224127 ## 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 --------- Co-authored-by: iamlewis --- resources/lang/en.json | 3 +- src/client/HostLobbyModal.ts | 21 +- src/client/PublicLobby.ts | 16 +- src/core/GameRunner.ts | 17 +- src/core/configuration/DefaultConfig.ts | 3 +- src/core/execution/NationExecution.ts | 12 +- src/core/game/Game.ts | 2 +- src/core/game/NationUtils.ts | 306 ++++++++++++++++++++++++ src/server/MapPlaylist.ts | 4 +- 9 files changed, 362 insertions(+), 22 deletions(-) create mode 100644 src/core/game/NationUtils.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index c2e03cedc..47f147538 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 247ebe441..12ea776ae 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 363540dd2..a2c08996c 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 92eba215b..d434ba49e 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 ec22579a9..9d40aab1c 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 835998a1b..961591378 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 4a33d9b80..5920dddfd 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 000000000..076ccfd72 --- /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 22c258f59..6950a24f6 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,