From 395c51dff39184681bf4dea68ffb70594ef3ddbf Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Thu, 26 Feb 2026 19:51:04 +0100 Subject: [PATCH] Name teams after clans See #3300 --- src/core/game/GameImpl.ts | 44 +++++++++++++++++++- src/core/game/TeamAssignment.ts | 32 +++++++++++++++ tests/TeamAssignment.test.ts | 71 ++++++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 202ed12d4..f37fef37e 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -48,7 +48,7 @@ import { RailNetwork } from "./RailNetwork"; import { createRailNetwork } from "./RailNetworkImpl"; import { Stats } from "./Stats"; import { StatsImpl } from "./StatsImpl"; -import { assignTeams } from "./TeamAssignment"; +import { assignTeams, computeClanTeamName } from "./TeamAssignment"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; @@ -211,6 +211,48 @@ export class GameImpl implements Game { ...this._nations.map((n) => n.playerInfo), ]; const playerToTeam = assignTeams(allPlayers, this.playerTeams); + + // Only rename numbered teams (8+ team mode), not colored teams + const isNumberedTeams = !this.playerTeams.some((t) => + Object.values(ColoredTeams).includes(t as any), + ); + if (isNumberedTeams) { + // Build reverse map: team → assigned players + const teamToPlayers = new Map(); + for (const [pi, team] of playerToTeam.entries()) { + if (team === "kicked") continue; + if (!teamToPlayers.has(team)) teamToPlayers.set(team, []); + teamToPlayers.get(team)!.push(pi); + } + + // Compute candidate names + const renameMap = new Map(); + for (const [oldTeam, teamPlayers] of teamToPlayers.entries()) { + const newName = computeClanTeamName(teamPlayers); + if (newName !== null && newName !== oldTeam) { + renameMap.set(oldTeam, newName); + } + } + + // Collision check: remove any renames that produce duplicate names + const newNames = Array.from(renameMap.values()); + for (const [oldTeam, newName] of renameMap.entries()) { + if (newNames.filter((n) => n === newName).length > 1) { + renameMap.delete(oldTeam); + } + } + + // Apply renames to playerTeams array (preserves index order for teamSpawnArea) + this.playerTeams = this.playerTeams.map((t) => renameMap.get(t) ?? t); + + // Apply renames to playerToTeam + for (const [pi, team] of playerToTeam.entries()) { + if (team !== "kicked" && renameMap.has(team)) { + playerToTeam.set(pi, renameMap.get(team)!); + } + } + } + for (const [playerInfo, team] of playerToTeam.entries()) { if (team === "kicked") { console.warn(`Player ${playerInfo.name} was kicked from team`); diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 0251c4466..3d0428a48 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -102,3 +102,35 @@ export function assignTeamsLobbyPreview( export function getMaxTeamSize(numPlayers: number, numTeams: number): number { return Math.ceil(numPlayers / numTeams); } + +export function computeClanTeamName(players: PlayerInfo[]): string | null { + const humans = players.filter((p) => p.playerType === PlayerType.Human); + if (humans.length === 0) return null; + + const clanCounts = new Map(); + for (const player of humans) { + if (player.clan !== null) { + clanCounts.set(player.clan, (clanCounts.get(player.clan) ?? 0) + 1); + } + } + if (clanCounts.size === 0) return null; + + const sorted = Array.from(clanCounts.entries()).sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]), + ); + const [topTag, topCount] = sorted[0]; + const total = humans.length; + + // Unanimous or majority + if (topCount / total > 0.5) return topTag; + + // Coalition: top two clans cover the majority of humans + if (sorted.length >= 2) { + const [secondTag, secondCount] = sorted[1]; + if ((topCount + secondCount) / total > 0.5) { + return `${topTag} / ${secondTag}`; + } + } + + return null; +} diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index c3e11671b..a5507cac5 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -1,5 +1,8 @@ import { ColoredTeams, PlayerInfo, PlayerType } from "../src/core/game/Game"; -import { assignTeams } from "../src/core/game/TeamAssignment"; +import { + assignTeams, + computeClanTeamName, +} from "../src/core/game/TeamAssignment"; const teams = [ColoredTeams.Red, ColoredTeams.Blue]; @@ -164,3 +167,69 @@ describe("assignTeams", () => { expect(result.get(players[13])).toEqual(ColoredTeams.Orange); }); }); + +describe("computeClanTeamName", () => { + const human = (id: string, clan?: string): PlayerInfo => { + const name = clan ? `[${clan}]Player${id}` : `Player${id}`; + return new PlayerInfo(name, PlayerType.Human, null, id); + }; + + const bot = (id: string): PlayerInfo => + new PlayerInfo(`Bot${id}`, PlayerType.Bot, null, id); + + it("returns clan tag when all humans share the same clan", () => { + const players = [human("1", "ALPHA"), human("2", "ALPHA")]; + expect(computeClanTeamName(players)).toBe("ALPHA"); + }); + + it("returns majority clan tag when one clan has more than 50% of humans", () => { + const players = [ + human("1", "ALPHA"), + human("2", "ALPHA"), + human("3", "ALPHA"), + human("4", "BETA"), + ]; + expect(computeClanTeamName(players)).toBe("ALPHA"); + }); + + it("returns coalition name when top two clans together cover all humans", () => { + const players = [human("1", "ALPHA"), human("2", "BETA")]; + expect(computeClanTeamName(players)).toBe("ALPHA / BETA"); + }); + + it("returns majority tag when majority clan exists despite untagged players", () => { + const players = [ + human("1", "ALPHA"), + human("2", "ALPHA"), + human("3", "ALPHA"), + human("4"), + ]; + expect(computeClanTeamName(players)).toBe("ALPHA"); + }); + + it("returns coalition name when two clans together cover the majority of humans", () => { + const players = [ + human("1", "ALPHA"), + human("2", "ALPHA"), + human("3", "BETA"), + human("4", "BETA"), + human("5"), + ]; + expect(computeClanTeamName(players)).toBe("ALPHA / BETA"); + }); + + it("returns null when no players have clan tags", () => { + const players = [human("1"), human("2"), human("3")]; + expect(computeClanTeamName(players)).toBeNull(); + }); + + it("returns null when all players are bots", () => { + const players = [bot("1"), bot("2")]; + expect(computeClanTeamName(players)).toBeNull(); + }); + + it("ignores bots when computing clan name", () => { + const players = [human("1", "ALPHA"), bot("2")]; + expect(computeClanTeamName(players)).toBe("ALPHA"); + }); +});