diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 78022ce90..828306b2c 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -117,6 +117,7 @@ export const UserMeResponseSchema = z.object({ }), ) .optional(), + friends: z.array(z.string()), subscription: z .object({ tier: z.string(), diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 1093982ad..f98dcedb5 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -53,6 +53,7 @@ export async function createGameRunner( random.nextID(), p.isLobbyCreator ?? false, p.clanTag, + p.friends ?? [], ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4a1636e19..46610373c 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -549,6 +549,7 @@ export const PlayerSchema = z.object({ clanTag: ClanTagSchema, cosmetics: PlayerCosmeticsSchema.optional(), isLobbyCreator: z.boolean().optional(), + friends: z.array(ID).optional(), }); export const GameStartInfoSchema = z.object({ diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 7a700ca55..85f338e28 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -580,6 +580,7 @@ export class PlayerInfo { public readonly id: PlayerID, public readonly isLobbyCreator: boolean = false, public readonly clanTag: string | null = null, + public readonly friends: ClientID[] = [], ) { this.displayName = formatPlayerDisplayName(this.name, this.clanTag); } diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index c8b8607e9..02bf47b5d 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -1,4 +1,5 @@ import { PseudoRandom } from "../PseudoRandom"; +import { ClientID } from "../Schemas"; import { simpleHash } from "../Util"; import { PlayerInfo, PlayerType, Team } from "./Game"; @@ -10,31 +11,24 @@ export function assignTeams( const result = new Map(); const teamPlayerCount = new Map(); - // Group players by clan + // Clans are strict: a clan goes to one team together, and any overflow + // members get kicked. (You opted into the clan, so we honor "all or + // nothing" for placement.) const clanGroups = new Map(); - const noClanPlayers: PlayerInfo[] = []; - - // Sort players into clan groups or no-clan list - for (const player of players) { - const clanTag = player.clanTag; - if (clanTag) { - if (!clanGroups.has(clanTag)) { - clanGroups.set(clanTag, []); - } - clanGroups.get(clanTag)!.push(player); + const nonClanPlayers: PlayerInfo[] = []; + for (const p of players) { + if (p.clanTag) { + if (!clanGroups.has(p.clanTag)) clanGroups.set(p.clanTag, []); + clanGroups.get(p.clanTag)!.push(p); } else { - noClanPlayers.push(player); + nonClanPlayers.push(p); } } - // Sort clans by size (largest first) - const sortedClanPlayers = Array.from(clanGroups.values()).sort( + const sortedClans = Array.from(clanGroups.values()).sort( (a, b) => b.length - a.length, ); - - // First, assign clan players - for (const clanPlayers of sortedClanPlayers) { - // Try to keep the clan together on the team with fewer players + for (const clan of sortedClans) { let team: Team | null = null; let teamSize = 0; for (const t of teams) { @@ -43,10 +37,8 @@ export function assignTeams( teamSize = p; team = t; } - if (team === null) continue; - - for (const player of clanPlayers) { + for (const player of clan) { if (teamSize < maxTeamSize) { teamSize++; result.set(player, team); @@ -57,31 +49,85 @@ export function assignTeams( teamPlayerCount.set(team, teamSize); } - // Then, assign non-clan players to balance teams - let nationPlayers = noClanPlayers.filter( - (player) => player.playerType === PlayerType.Nation, + // Friend edges are a soft preference: when placing a player, prefer the + // team where the most of their friends already are. If that team is full + // we spill onto the next-emptiest non-full team rather than kicking — you + // didn't opt into being grouped with friend-of-friend, so a chain that + // doesn't fit shouldn't bench anyone. + const presentClientIDs = new Set(); + for (const p of players) { + if (p.clientID !== null) presentClientIDs.add(p.clientID); + } + const friendGraph = new Map>(); + const addEdge = (a: ClientID, b: ClientID) => { + let s = friendGraph.get(a); + if (s === undefined) { + s = new Set(); + friendGraph.set(a, s); + } + s.add(b); + }; + for (const p of players) { + if (p.clientID === null) continue; + for (const friendID of p.friends) { + if (!presentClientIDs.has(friendID)) continue; + addEdge(p.clientID, friendID); + addEdge(friendID, p.clientID); + } + } + + const teamByClientID = new Map(); + for (const [player, team] of result.entries()) { + if (player.clientID !== null && team !== "kicked") { + teamByClientID.set(player.clientID, team); + } + } + + const placePlayer = (p: PlayerInfo) => { + const myFriends = + p.clientID !== null ? friendGraph.get(p.clientID) : undefined; + let bestTeam: Team | null = null; + let bestFriendCount = -1; + let bestSize = Infinity; + for (const t of teams) { + const size = teamPlayerCount.get(t) ?? 0; + if (size >= maxTeamSize) continue; + let friendsOnTeam = 0; + if (myFriends !== undefined) { + for (const friendID of myFriends) { + if (teamByClientID.get(friendID) === t) friendsOnTeam++; + } + } + if ( + friendsOnTeam > bestFriendCount || + (friendsOnTeam === bestFriendCount && size < bestSize) + ) { + bestFriendCount = friendsOnTeam; + bestSize = size; + bestTeam = t; + } + } + if (bestTeam === null) { + result.set(p, "kicked"); + return; + } + teamPlayerCount.set(bestTeam, (teamPlayerCount.get(bestTeam) ?? 0) + 1); + result.set(p, bestTeam); + if (p.clientID !== null) teamByClientID.set(p.clientID, bestTeam); + }; + + let nationPlayers = nonClanPlayers.filter( + (p) => p.playerType === PlayerType.Nation, ); if (nationPlayers.length > 0) { - // Shuffle only nations to randomize their team assignment const random = new PseudoRandom(simpleHash(nationPlayers[0].id)); nationPlayers = random.shuffleArray(nationPlayers); } - const otherPlayers = noClanPlayers.filter( - (player) => player.playerType !== PlayerType.Nation, + const otherPlayers = nonClanPlayers.filter( + (p) => p.playerType !== PlayerType.Nation, ); - - for (const player of otherPlayers.concat(nationPlayers)) { - let team: Team | null = null; - let teamSize = 0; - for (const t of teams) { - const p = teamPlayerCount.get(t) ?? 0; - if (team !== null && teamSize <= p) continue; - teamSize = p; - team = t; - } - if (team === null) continue; - teamPlayerCount.set(team, teamSize + 1); - result.set(player, team); + for (const p of otherPlayers.concat(nationPlayers)) { + placePlayer(p); } return result; diff --git a/src/server/Client.ts b/src/server/Client.ts index 51a8e7a77..d5bd00c09 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -21,5 +21,7 @@ export class Client { public clanTag: string | null, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, + public readonly publicId: string | undefined, + public readonly friends: string[], ) {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 9cd8b6b6f..59673e2fd 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -731,18 +731,29 @@ export class GameServer { // if no client connects/pings. this.lastPingUpdate = Date.now(); + const publicIdToClientID = new Map(); + for (const c of this.activeClients) { + if (c.publicId) publicIdToClientID.set(c.publicId, c.clientID); + } + const result = GameStartInfoSchema.safeParse({ gameID: this.id, lobbyCreatedAt: this.createdAt, visibleAt: this.visibleAt, config: this.gameConfig, - players: this.activeClients.map((c) => ({ - username: c.username, - clanTag: c.clanTag ?? null, - clientID: c.clientID, - cosmetics: c.cosmetics, - isLobbyCreator: this.lobbyCreatorID === c.clientID, - })), + players: this.activeClients.map((c) => { + const friendClientIDs = c.friends + .map((pid) => publicIdToClientID.get(pid)) + .filter((id): id is ClientID => id !== undefined); + return { + username: c.username, + clanTag: c.clanTag ?? null, + clientID: c.clientID, + cosmetics: c.cosmetics, + isLobbyCreator: this.lobbyCreatorID === c.clientID, + friends: friendClientIDs.length > 0 ? friendClientIDs : undefined, + }; + }), }); if (!result.success) { const error = z.prettifyError(result.error); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 340fbe681..59626396a 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -376,6 +376,8 @@ export async function startWorker() { } let flares: string[] | undefined; + let publicId: string | undefined; + let friends: string[] = []; const allowedFlares = ServerEnv.allowedFlares(); if (claims === null) { @@ -396,6 +398,8 @@ export async function startWorker() { return; } flares = result.response.player.flares; + publicId = result.response.player.publicId; + friends = result.response.player.friends; if (allowedFlares !== undefined) { const allowed = @@ -462,6 +466,8 @@ export async function startWorker() { censoredClanTag, ws, cosmeticResult.cosmetics, + publicId, + friends, ); const joinResult = gm.joinClient(client, clientMsg.gameID); diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts index 3b8fc9e2a..11fef844f 100644 --- a/tests/ResolveCosmetics.test.ts +++ b/tests/ResolveCosmetics.test.ts @@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse { adfree: false, flares, achievements: { singleplayerMap: [] }, + friends: [], subscription: null, }, } as UserMeResponse; diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index 999f02592..6a3c7c718 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -15,6 +15,26 @@ describe("assignTeams", () => { ); }; + // Friend grouping is keyed on clientID. By default we pass clientID = id + // for brevity, but tests can override clientID to verify the lookup uses + // clientID rather than PlayerInfo.id. + const createPlayerWithFriends = ( + id: string, + friends: string[], + clan?: string, + clientID: string = id, + ): PlayerInfo => { + return new PlayerInfo( + `Player ${id}`, + PlayerType.Human, + clientID, + id, // PlayerInfo.id + false, + clan, + friends, + ); + }; + it("should assign players to teams when no clans are present", () => { const players = [ createPlayer("1"), @@ -164,4 +184,129 @@ describe("assignTeams", () => { expect(result.get(players[12])).toEqual(ColoredTeams.Purple); expect(result.get(players[13])).toEqual(ColoredTeams.Orange); }); + + it("should keep two friends on the same team", () => { + const players = [ + createPlayerWithFriends("1", ["2"]), + createPlayerWithFriends("2", ["1"]), + createPlayerWithFriends("3", []), + createPlayerWithFriends("4", []), + ]; + + const result = assignTeams(players, teams); + + expect(result.get(players[0])).toEqual(result.get(players[1])); + expect(result.get(players[2])).not.toEqual(result.get(players[0])); + expect(result.get(players[3])).not.toEqual(result.get(players[0])); + }); + + it("should group a chain of friends transitively", () => { + // 6 players, 2 teams → maxTeamSize = 3 (enough room for a 3-friend chain) + const players = [ + createPlayerWithFriends("1", ["2"]), + createPlayerWithFriends("2", ["3"]), + createPlayerWithFriends("3", []), + createPlayerWithFriends("4", []), + createPlayerWithFriends("5", []), + createPlayerWithFriends("6", []), + ]; + + const result = assignTeams(players, teams); + + const teamOf1 = result.get(players[0]); + expect(result.get(players[1])).toEqual(teamOf1); + expect(result.get(players[2])).toEqual(teamOf1); + }); + + it("should treat one-directional friendship as a group", () => { + const players = [ + createPlayerWithFriends("1", ["2"]), + createPlayerWithFriends("2", []), // doesn't list 1 back + createPlayerWithFriends("3", []), + createPlayerWithFriends("4", []), + ]; + + const result = assignTeams(players, teams); + + expect(result.get(players[0])).toEqual(result.get(players[1])); + }); + + it("should merge friend and clan groups when they overlap", () => { + // 1 and 2 share clan CLANA, 2 is friends with 3 (no clan) + // → all three end up on the same team. 6 players, maxTeamSize = 3. + const players = [ + createPlayerWithFriends("1", [], "CLANA"), + createPlayerWithFriends("2", ["3"], "CLANA"), + createPlayerWithFriends("3", [], undefined), + createPlayerWithFriends("4", [], undefined), + createPlayerWithFriends("5", [], undefined), + createPlayerWithFriends("6", [], undefined), + ]; + + const result = assignTeams(players, teams); + + const teamOf1 = result.get(players[0]); + expect(result.get(players[1])).toEqual(teamOf1); + expect(result.get(players[2])).toEqual(teamOf1); + }); + + it("should spill friend-group overflow to other teams (no kicks)", () => { + // 4-player friend group + 2 strangers, maxTeamSize = ceil(6/2) = 3. + // Friend overflow spills to the other team rather than getting kicked. + const players = [ + createPlayerWithFriends("1", ["2", "3", "4"]), + createPlayerWithFriends("2", []), + createPlayerWithFriends("3", []), + createPlayerWithFriends("4", []), + createPlayerWithFriends("5", []), + createPlayerWithFriends("6", []), + ]; + + const result = assignTeams(players, teams); + + expect(result.get(players[0])).toEqual(ColoredTeams.Red); + expect(result.get(players[1])).toEqual(ColoredTeams.Red); + expect(result.get(players[2])).toEqual(ColoredTeams.Red); + expect(result.get(players[3])).toEqual(ColoredTeams.Blue); + expect(result.get(players[4])).toEqual(ColoredTeams.Blue); + expect(result.get(players[5])).toEqual(ColoredTeams.Blue); + }); + + it("should key friend grouping on clientID, not PlayerInfo.id", () => { + // clientID and PlayerInfo.id are distinct. The friends list references + // clientIDs ("client-2", "client-1"). If grouping ever regressed to + // keying on PlayerInfo.id ("player-1"/"player-2"), no edges would form + // and these two would land on opposite teams. + const players = [ + createPlayerWithFriends("player-1", ["client-2"], undefined, "client-1"), + createPlayerWithFriends("player-2", ["client-1"], undefined, "client-2"), + createPlayerWithFriends("player-3", [], undefined, "client-3"), + createPlayerWithFriends("player-4", [], undefined, "client-4"), + ]; + + const result = assignTeams(players, teams); + + expect(result.get(players[0])).toEqual(result.get(players[1])); + expect(result.get(players[2])).not.toEqual(result.get(players[0])); + expect(result.get(players[3])).not.toEqual(result.get(players[0])); + }); + + it("should still kick when every team is at capacity", () => { + // 5 friends in a clique, 2 teams, maxTeamSize = ceil(5/2) = 3. + // Total capacity is 6, so we have slack — nobody should get kicked. + // But if we force capacity below player count, kicks resume. + const players = [ + createPlayerWithFriends("1", ["2", "3", "4", "5"]), + createPlayerWithFriends("2", []), + createPlayerWithFriends("3", []), + createPlayerWithFriends("4", []), + createPlayerWithFriends("5", []), + ]; + + const result = assignTeams(players, teams, 2); + + // maxTeamSize=2, 2 teams → capacity 4, 5 players → 1 must be kicked. + const kicked = players.filter((p) => result.get(p) === "kicked"); + expect(kicked.length).toBe(1); + }); }); diff --git a/tests/server/KickPlayerAuthorization.test.ts b/tests/server/KickPlayerAuthorization.test.ts index 00aa1ac91..9dc04a1c0 100644 --- a/tests/server/KickPlayerAuthorization.test.ts +++ b/tests/server/KickPlayerAuthorization.test.ts @@ -51,6 +51,8 @@ function makeClient( null, ws as any, undefined, + undefined, + [], ); return { client, ws }; }