Name teams after clans

See #3300
This commit is contained in:
Mattia Migliorini
2026-02-26 19:51:04 +01:00
parent a7b137b3b7
commit 395c51dff3
3 changed files with 145 additions and 2 deletions
+43 -1
View File
@@ -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<Team, PlayerInfo[]>();
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<Team, Team>();
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`);
+32
View File
@@ -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<string, number>();
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;
}
+70 -1
View File
@@ -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");
});
});