From 3a8ff6304a63c6a3c26eb758a9fcda0fcc90fead Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Sun, 6 Jul 2025 20:09:18 -0400 Subject: [PATCH] Quads (#1347) ## Description: - Add trios and quads - Add translations for duos, trios, quads - Add duos, trios, quads to the public lobby rotation - Increase the frequency of team games ![image](https://github.com/user-attachments/assets/a7bac4e7-9db2-486d-87bc-5779dd64da08) ![image](https://github.com/user-attachments/assets/c1b9225e-2193-4776-b97f-be01a1bdeeed) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- resources/lang/en.json | 3 ++ src/client/HostLobbyModal.ts | 20 ++++++++---- src/client/PublicLobby.ts | 6 +++- src/client/SinglePlayerModal.ts | 17 +++++++--- src/core/Schemas.ts | 12 ++++++- src/core/configuration/Config.ts | 7 ++--- src/core/configuration/DefaultConfig.ts | 9 +++--- src/core/game/Game.ts | 2 ++ src/core/game/GameImpl.ts | 42 ++++++++++++++++--------- src/server/MapPlaylist.ts | 33 +++++++++++++------ 10 files changed, 106 insertions(+), 45 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 359479449..61edf088c 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -178,6 +178,9 @@ "public_lobby": { "join": "Join next Game", "waiting": "players waiting", + "teams_Duos": "Duos (teams of 2)", + "teams_Trios": "Trios (teams of 3)", + "teams_Quads": "Quads (teams of 4)", "teams": "{num} teams" }, "username": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 0270e169b..de3b7e39b 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -8,10 +8,12 @@ import { Duos, GameMapType, GameMode, + Quads, + Trios, UnitType, mapCategories, } from "../core/game/Game"; -import { GameConfig, GameInfo } from "../core/Schemas"; +import { GameConfig, GameInfo, TeamCountConfig } from "../core/Schemas"; import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; @@ -30,7 +32,7 @@ export class HostLobbyModal extends LitElement { @state() private selectedDifficulty: Difficulty = Difficulty.Medium; @state() private disableNPCs = false; @state() private gameMode: GameMode = GameMode.FFA; - @state() private teamCount: number | typeof Duos = 2; + @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; @state() private infiniteGold: boolean = false; @state() private infiniteTroops: boolean = false; @@ -196,7 +198,7 @@ export class HostLobbyModal extends LitElement { ${translateText("host_modal.team_count")}
- ${[Duos, 2, 3, 4, 5, 6, 7].map( + ${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map( (o) => html`
this.handleTeamCountSelection(o)} > -
${o}
+
+ ${typeof o === "string" + ? translateText(`public_lobby.teams_${o}`) + : translateText("public_lobby.teams", { + num: o, + })} +
`, )} @@ -465,8 +473,8 @@ export class HostLobbyModal extends LitElement { this.putGameConfig(); } - private async handleTeamCountSelection(value: number | typeof Duos) { - this.teamCount = value === Duos ? Duos : Number(value); + private async handleTeamCountSelection(value: TeamCountConfig) { + this.teamCount = value; this.putGameConfig(); } diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index d6a92dd85..0490dd9f0 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -151,7 +151,11 @@ export class PublicLobby extends LitElement { : "text-blue-600"} bg-white rounded-sm px-1" > ${lobby.gameConfig.gameMode === GameMode.Team - ? translateText("public_lobby.teams", { num: teamCount ?? 0 }) + ? typeof teamCount === "string" + ? translateText(`public_lobby.teams_${teamCount}`) + : translateText("public_lobby.teams", { + num: teamCount ?? 0, + }) : translateText("game_mode.ffa")}
- ${["Duos", 2, 3, 4, 5, 6, 7].map( + ${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map( (o) => html`
this.handleTeamCountSelection(o)} > -
${o}
+
+ ${typeof o === "string" + ? translateText(`public_lobby.teams_${o}`) + : translateText(`public_lobby.teams`, { num: o })} +
`, )} @@ -358,8 +365,8 @@ export class SinglePlayerModal extends LitElement { this.gameMode = value; } - private handleTeamCountSelection(value: number | string) { - this.teamCount = value === "Duos" ? Duos : Number(value); + private handleTeamCountSelection(value: TeamCountConfig) { + this.teamCount = value; } private getRandomMap(): GameMapType { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 0faadbe18..1f94f4179 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -9,6 +9,8 @@ import { GameMode, GameType, PlayerType, + Quads, + Trios, UnitType, } from "./game/Game"; import { PatternDecoder } from "./PatternDecoder"; @@ -131,6 +133,14 @@ export enum LogSeverity { // Utility types // +const TeamCountConfigSchema = z.union([ + z.number(), + z.literal(Duos), + z.literal(Trios), + z.literal(Quads), +]); +export type TeamCountConfig = z.infer; + export const GameConfigSchema = z.object({ gameMap: z.enum(GameMapType), difficulty: z.enum(Difficulty), @@ -143,7 +153,7 @@ export const GameConfigSchema = z.object({ instantBuild: z.boolean(), maxPlayers: z.number().optional(), disabledUnits: z.enum(UnitType).array().optional(), - playerTeams: z.union([z.number(), z.literal(Duos)]).optional(), + playerTeams: TeamCountConfigSchema.optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 461d99c55..0daf4d89f 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -1,9 +1,8 @@ import { Colord } from "colord"; import { JWK } from "jose"; -import { GameConfig, GameID } from "../Schemas"; +import { GameConfig, GameID, TeamCountConfig } from "../Schemas"; import { Difficulty, - Duos, Game, GameMapType, GameMode, @@ -32,7 +31,7 @@ export interface ServerConfig { lobbyMaxPlayers( map: GameMapType, mode: GameMode, - numPlayerTeams: number | undefined, + numPlayerTeams: TeamCountConfig | undefined, ): number; numWorkers(): number; workerIndex(gameID: GameID): number; @@ -87,7 +86,7 @@ export interface Config { instantBuild(): boolean; numSpawnPhaseTurns(): number; userSettings(): UserSettings; - playerTeams(): number | typeof Duos; + playerTeams(): TeamCountConfig; startManpower(playerInfo: PlayerInfo): number; populationIncreaseRate(player: Player | PlayerView): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 42464f282..7e7d328c3 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -2,7 +2,6 @@ import { JWK } from "jose"; import { z } from "zod/v4"; import { Difficulty, - Duos, Game, GameMapType, GameMode, @@ -20,7 +19,7 @@ import { import { TileRef } from "../game/GameMap"; import { PlayerView } from "../game/GameView"; import { UserSettings } from "../game/UserSettings"; -import { GameConfig, GameID } from "../Schemas"; +import { GameConfig, GameID, TeamCountConfig } from "../Schemas"; import { assertNever, simpleHash, within } from "../Util"; import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { PastelTheme } from "./PastelTheme"; @@ -167,13 +166,13 @@ export abstract class DefaultServerConfig implements ServerConfig { lobbyMaxPlayers( map: GameMapType, mode: GameMode, - numPlayerTeams: number | undefined, + numPlayerTeams: TeamCountConfig | undefined, ): number { const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; const r = Math.random(); const base = r < 0.3 ? l : r < 0.6 ? m : s; let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l); - if (numPlayerTeams !== undefined) { + if (typeof numPlayerTeams === "number") { p -= p % numPlayerTeams; } return p; @@ -279,7 +278,7 @@ export class DefaultConfig implements Config { defensePostDefenseBonus(): number { return 5; } - playerTeams(): number | typeof Duos { + playerTeams(): TeamCountConfig { return this._gameConfig.playerTeams ?? 0; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 48ae1822c..bdb956555 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -41,6 +41,8 @@ export enum Difficulty { export type Team = string; export const Duos = "Duos" as const; +export const Trios = "Trios" as const; +export const Quads = "Quads" as const; export const ColoredTeams: Record = { Red: "Red", diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 99341a99c..2e3719a24 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -21,9 +21,11 @@ import { PlayerID, PlayerInfo, PlayerType, + Quads, Team, TerrainType, TerraNullius, + Trios, Unit, UnitInfo, UnitType, @@ -74,7 +76,7 @@ export class GameImpl implements Game { private updates: GameUpdates = createGameUpdatesMap(); private unitGrid: UnitGrid; - private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue]; + private playerTeams: Team[]; private botTeam: Team = ColoredTeams.Bot; private _railNetwork: RailNetwork = createRailNetwork(this); @@ -101,25 +103,37 @@ export class GameImpl implements Game { } private populateTeams() { - if (this._config.playerTeams() === Duos) { - this.playerTeams = []; - const numTeams = Math.ceil( - (this._humans.length + this._nations.length) / 2, - ); - for (let i = 0; i < numTeams; i++) { - this.playerTeams.push("Team " + (i + 1)); + let numPlayerTeams = this._config.playerTeams(); + if (typeof numPlayerTeams !== "number") { + const players = this._humans.length + this._nations.length; + switch (numPlayerTeams) { + case Duos: + numPlayerTeams = Math.ceil(players / 2); + break; + case Trios: + numPlayerTeams = Math.ceil(players / 3); + break; + case Quads: + numPlayerTeams = Math.ceil(players / 4); + break; + default: + throw new Error(`Unknown TeamCountConfig ${numPlayerTeams}`); } - } else { - const numPlayerTeams = this._config.playerTeams() as number; - if (numPlayerTeams < 2) - throw new Error(`Too few teams: ${numPlayerTeams}`); + } + if (numPlayerTeams < 2) { + throw new Error(`Too few teams: ${numPlayerTeams}`); + } else if (numPlayerTeams < 8) { + this.playerTeams = [ColoredTeams.Red, ColoredTeams.Blue]; if (numPlayerTeams >= 3) this.playerTeams.push(ColoredTeams.Yellow); if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Green); if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Purple); if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange); if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Teal); - if (numPlayerTeams >= 8) - throw new Error(`Too many teams: ${numPlayerTeams}`); + } else { + this.playerTeams = []; + for (let i = 1; i <= numPlayerTeams; i++) { + this.playerTeams.push(`Team ${i}`); + } } } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 6c5c85f3f..d71353574 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,13 +1,16 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { Difficulty, + Duos, GameMapType, GameMode, GameType, + Quads, + Trios, UnitType, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; -import { GameConfig } from "../core/Schemas"; +import { GameConfig, TeamCountConfig } from "../core/Schemas"; import { logger } from "./Logger"; const log = logger.child({}); @@ -45,19 +48,31 @@ interface MapWithMode { mode: GameMode; } +const TEAM_COUNTS = [ + 2, + 3, + 4, + 5, + 6, + 7, + Duos, + Trios, + Quads, +] as const satisfies TeamCountConfig[]; + export class MapPlaylist { private mapsPlaylist: MapWithMode[] = []; public gameConfig(): GameConfig { const { map, mode } = this.getNextMap(); - const numPlayerTeams = - mode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined; + const playerTeams = + mode === GameMode.Team ? this.getTeamCount() : undefined; // Create the default public game config (from your GameManager) return { gameMap: map, - maxPlayers: config.lobbyMaxPlayers(map, mode, numPlayerTeams), + maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams), gameType: GameType.Public, difficulty: Difficulty.Medium, infiniteGold: false, @@ -65,12 +80,16 @@ export class MapPlaylist { instantBuild: false, disableNPCs: mode === GameMode.Team, gameMode: mode, - playerTeams: numPlayerTeams, + playerTeams, bots: 400, disabledUnits: [UnitType.Train, UnitType.Factory], } satisfies GameConfig; } + private getTeamCount(): TeamCountConfig { + return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; + } + private getNextMap(): MapWithMode { if (this.mapsPlaylist.length === 0) { const numAttempts = 10000; @@ -98,7 +117,6 @@ export class MapPlaylist { const ffa1: GameMapType[] = rand.shuffleArray([...maps]); const ffa2: GameMapType[] = rand.shuffleArray([...maps]); - const ffa3: GameMapType[] = rand.shuffleArray([...maps]); const team: GameMapType[] = rand.shuffleArray([...maps]); this.mapsPlaylist = []; @@ -109,9 +127,6 @@ export class MapPlaylist { if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) { return false; } - if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) { - return false; - } if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) { return false; }