From 6d5f218a449d5d770f9e1d8e35431ba56cd494f4 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 30 Mar 2025 19:37:48 -0700 Subject: [PATCH] sort teams based on clans (#381) ## Description: Teams are not sorted based on clans, clan members always stay on the same team. ## Please complete the following: - [x] I have added screenshots for all UI updates - [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 ## Please put your Discord username so you can be contacted if a bug or regression is found: --- .vscode/launch.json | 14 ++++ src/core/game/GameImpl.ts | 27 ++++++- src/core/game/PlayerImpl.ts | 2 +- src/core/game/TeamAssignment.ts | 71 +++++++++++++++++++ tests/TeamAssignment.test.ts | 122 ++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/core/game/TeamAssignment.ts create mode 100644 tests/TeamAssignment.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..e89c7e23e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Jest Tests", + "runtimeExecutable": "npm", + "runtimeArgs": ["run-script", "test"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 21bb752d6..258d864d6 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -37,6 +37,7 @@ import { UnitGrid } from "./UnitGrid"; import { StatsImpl } from "./StatsImpl"; import { Stats } from "./Stats"; import { simpleHash } from "../Util"; +import { assignTeams } from "./TeamAssignment"; export function createGame( humans: PlayerInfo[], @@ -89,7 +90,7 @@ export class GameImpl implements Game { nationMap: NationMap, private _config: Config, ) { - this._humans.forEach((p) => this.addPlayer(p, 100)); + this.addHumans(); this._terraNullius = new TerraNulliusImpl(); this._width = _map.width(); this._height = _map.height(); @@ -104,6 +105,22 @@ export class GameImpl implements Game { ); this.unitGrid = new UnitGrid(this._map); } + + private addHumans() { + if (this.config().gameConfig().gameMode != GameMode.Team) { + this._humans.forEach((p) => this.addPlayer(p, 0)); + return; + } + const playerToTeam = assignTeams(this._humans); + for (const [playerInfo, team] of playerToTeam.entries()) { + if (team == "kicked") { + console.warn(`Player ${playerInfo.name} was kicked from team`); + continue; + } + this.addPlayer(playerInfo, 0, team); + } + } + isOnEdgeOfMap(ref: TileRef): boolean { return this._map.isOnEdgeOfMap(ref); } @@ -330,13 +347,17 @@ export class GameImpl implements Game { return this.player(id); } - addPlayer(playerInfo: PlayerInfo, manpower: number): Player { + addPlayer( + playerInfo: PlayerInfo, + manpower: number, + team: Team = null, + ): Player { const player = new PlayerImpl( this, this.nextPlayerID, playerInfo, manpower, - this.maybeAssignTeam(playerInfo), + team ?? this.maybeAssignTeam(playerInfo), ); this._playersBySmallID.push(player); this.nextPlayerID++; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 57e5ac6c1..e3ae5acf0 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -573,7 +573,7 @@ export class PlayerImpl implements Player { if (this.team() == null || other.team() == null) { return false; } - return this._team == other.team(); + return this._team.name == other.team().name; } isFriendly(other: Player): boolean { diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts new file mode 100644 index 000000000..1d1dda4a0 --- /dev/null +++ b/src/core/game/TeamAssignment.ts @@ -0,0 +1,71 @@ +import { Player, PlayerInfo, Team, TeamName } from "./Game"; + +export function assignTeams( + players: PlayerInfo[], +): Map { + const result = new Map(); + let redTeamCount = 0; + let blueTeamCount = 0; + + // Group players by clan + const clanGroups = new Map(); + const noClanPlayers: PlayerInfo[] = []; + + // Sort players into clan groups or no-clan list + for (const player of players) { + if (player.clan) { + if (!clanGroups.has(player.clan)) { + clanGroups.set(player.clan, []); + } + clanGroups.get(player.clan)!.push(player); + } else { + noClanPlayers.push(player); + } + } + + const maxTeamSize = Math.ceil(players.length / 2); + + // Sort clans by size (largest first) + const sortedClans = Array.from(clanGroups.entries()).sort( + (a, b) => b[1].length - a[1].length, + ); + + // First, assign clan players + for (const [_, clanPlayers] of sortedClans) { + // Try to keep the clan together on the team with fewer players + if (redTeamCount <= blueTeamCount) { + // Assign to red team + for (const player of clanPlayers) { + if (redTeamCount < maxTeamSize) { + redTeamCount++; + result.set(player, { name: TeamName.Red }); + } else { + result.set(player, "kicked"); + } + } + } else { + // Assign to blue team + for (const player of clanPlayers) { + if (blueTeamCount < maxTeamSize) { + blueTeamCount++; + result.set(player, { name: TeamName.Blue }); + } else { + result.set(player, "kicked"); + } + } + } + } + + // Then, assign non-clan players to balance teams + for (const player of noClanPlayers) { + if (redTeamCount <= blueTeamCount) { + redTeamCount++; + result.set(player, { name: TeamName.Red }); + } else { + blueTeamCount++; + result.set(player, { name: TeamName.Blue }); + } + } + + return result; +} diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts new file mode 100644 index 000000000..861a17eca --- /dev/null +++ b/tests/TeamAssignment.test.ts @@ -0,0 +1,122 @@ +import { TeamName, PlayerType, PlayerInfo } from "../src/core/game/Game"; +import { assignTeams } from "../src/core/game/TeamAssignment"; + +describe("assignTeams", () => { + const createPlayer = (id: string, clan?: string): PlayerInfo => { + const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; + return new PlayerInfo( + "🏳️", // flag + name, + PlayerType.Human, + null, // clientID (null for testing) + id, + null, // nation (null for testing) + ); + }; + + it("should assign players to teams when no clans are present", () => { + const players = [ + createPlayer("1"), + createPlayer("2"), + createPlayer("3"), + createPlayer("4"), + ]; + + const result = assignTeams(players); + + // Check that players are assigned alternately + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + expect(result.get(players[1])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[2])).toEqual({ name: TeamName.Red }); + expect(result.get(players[3])).toEqual({ name: TeamName.Blue }); + }); + + it("should keep clan members together on the same team", () => { + const players = [ + createPlayer("1", "CLANA"), + createPlayer("2", "CLANA"), + createPlayer("3", "CLANB"), + createPlayer("4", "CLANB"), + ]; + + const result = assignTeams(players); + + // Check that clan members are on the same team + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + expect(result.get(players[1])).toEqual({ name: TeamName.Red }); + expect(result.get(players[2])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[3])).toEqual({ name: TeamName.Blue }); + }); + + it("should handle mixed clan and non-clan players", () => { + const players = [ + createPlayer("1", "CLANA"), + createPlayer("2", "CLANA"), + createPlayer("3"), + createPlayer("4"), + ]; + + const result = assignTeams(players); + + // Check that clan members are together and non-clan players balance teams + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + expect(result.get(players[1])).toEqual({ name: TeamName.Red }); + expect(result.get(players[2])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[3])).toEqual({ name: TeamName.Blue }); + }); + + it("should kick players when teams are full", () => { + const players = [ + createPlayer("1", "CLANA"), + createPlayer("2", "CLANA"), + createPlayer("3", "CLANA"), + createPlayer("4", "CLANA"), + createPlayer("5", "CLANB"), + createPlayer("6", "CLANB"), + ]; + + const result = assignTeams(players); + + // Check that players are kicked when teams are full + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + expect(result.get(players[1])).toEqual({ name: TeamName.Red }); + expect(result.get(players[2])).toEqual({ name: TeamName.Red }); + + expect(result.get(players[3])).toEqual("kicked"); + + expect(result.get(players[4])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[5])).toEqual({ name: TeamName.Blue }); + }); + + it("should handle empty player list", () => { + const result = assignTeams([]); + expect(result.size).toBe(0); + }); + + it("should handle single player", () => { + const players = [createPlayer("1")]; + const result = assignTeams(players); + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + }); + + it("should handle multiple clans with different sizes", () => { + const players = [ + createPlayer("1", "CLANA"), + createPlayer("2", "CLANA"), + createPlayer("3", "CLANA"), + createPlayer("4", "CLANB"), + createPlayer("5", "CLANB"), + createPlayer("6", "CLANC"), + ]; + + const result = assignTeams(players); + + // Check that larger clans are assigned first + expect(result.get(players[0])).toEqual({ name: TeamName.Red }); + expect(result.get(players[1])).toEqual({ name: TeamName.Red }); + expect(result.get(players[2])).toEqual({ name: TeamName.Red }); + expect(result.get(players[3])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[4])).toEqual({ name: TeamName.Blue }); + expect(result.get(players[5])).toEqual({ name: TeamName.Blue }); + }); +});