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,