Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion src/core/game/TeamAssignment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerInfo, PlayerType, Team } from "./Game";
import { ColoredTeams, PlayerInfo, PlayerType, Team } from "./Game";

export function assignTeams(
players: PlayerInfo[],
Expand Down Expand Up @@ -84,6 +84,59 @@ export function assignTeams(
result.set(player, team);
}

// Only rename numbered teams (8+ team mode), not colored teams
const coloredTeamValues = Object.values(ColoredTeams);
const isNumberedTeams = !teams.some((t) => coloredTeamValues.includes(t));

if (isNumberedTeams) {
// Build reverse map: team → assigned players
const teamToPlayers = new Map<Team, PlayerInfo[]>();
for (const [pi, team] of result.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);
Comment thread
deshack marked this conversation as resolved.
}
}

// Collision check: repeatedly remove renames that collide with existing
// team names or with each other until no more removals occur.
let changed = true;
while (changed) {
changed = false;
const existingNames = new Set(teams.filter((t) => !renameMap.has(t)));
const newNames = Array.from(renameMap.values());
for (const [oldTeam, newName] of renameMap.entries()) {
if (
existingNames.has(newName) ||
newNames.filter((n) => n === newName).length > 1
) {
renameMap.delete(oldTeam);
changed = true;
}
}
}

// Apply renames to teams array in-place (preserves index order for teamSpawnArea)
for (let i = 0; i < teams.length; i++) {
teams[i] = renameMap.get(teams[i]) ?? teams[i];
}

// Apply renames to result map
for (const [pi, team] of result.entries()) {
if (team !== "kicked" && renameMap.has(team)) {
result.set(pi, renameMap.get(team)!);
}
}
}

return result;
}

Expand All @@ -102,3 +155,38 @@ 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.clanTag !== null) {
clanCounts.set(player.clanTag, (clanCounts.get(player.clanTag) ?? 0) + 1);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 > 2 / 3 &&
secondCount / total >= 0.25
) {
return `${topTag} / ${secondTag}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return null;
}
87 changes: 86 additions & 1 deletion tests/TeamAssignment.test.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand Down Expand Up @@ -165,3 +168,85 @@ 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,
false,
clan ?? null,
);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 three distinct clans each hold one player", () => {
const players = [
human("1", "ALPHA"),
human("2", "BETA"),
human("3", "GAMMA"),
];
expect(computeClanTeamName(players)).toBeNull();
});

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");
});
});
Loading