diff --git a/packages/engine-lib/src/game/TeamAssignment.ts b/packages/engine-lib/src/game/TeamAssignment.ts index 5457714028..75afd24819 100644 --- a/packages/engine-lib/src/game/TeamAssignment.ts +++ b/packages/engine-lib/src/game/TeamAssignment.ts @@ -108,11 +108,16 @@ export function assignTeams( } } - const placePlayer = (p: PlayerInfo) => { + // groupLeft counts this player and the members of their friend group still + // to be seated after them. Among teams with equally many friends, prefer + // the one that seats the most of the group, so the first friend doesn't + // take the last seat beside a stranger. + const placePlayer = (p: PlayerInfo, groupLeft: number) => { const myFriends = p.clientID !== null ? friendGraph.get(p.clientID) : undefined; let bestTeam: Team | null = null; let bestFriendCount = -1; + let bestGroupSeats = -1; let bestSize = isDuosTriosQuads ? -1 : Infinity; for (const t of teams) { const size = teamPlayerCount.get(t) ?? 0; @@ -123,12 +128,16 @@ export function assignTeams( if (teamByClientID.get(friendID) === t) friendsOnTeam++; } } + const groupSeats = Math.min(groupLeft, maxTeamSize - size); if ( friendsOnTeam > bestFriendCount || (friendsOnTeam === bestFriendCount && - (isDuosTriosQuads ? size > bestSize : size < bestSize)) + (groupSeats > bestGroupSeats || + (groupSeats === bestGroupSeats && + (isDuosTriosQuads ? size > bestSize : size < bestSize)))) ) { bestFriendCount = friendsOnTeam; + bestGroupSeats = groupSeats; bestSize = size; bestTeam = t; } @@ -153,13 +162,51 @@ export function assignTeams( (p) => p.playerType !== PlayerType.Nation, ); - for (const p of otherPlayers.concat(nationPlayers)) { - placePlayer(p); + // Seat each friend group back to back, largest first, so strangers who + // joined between two friends can't fill the first friend's team before + // the second is placed. + for (const group of friendGroups(otherPlayers, friendGraph)) { + group.forEach((p, i) => placePlayer(p, group.length - i)); + } + for (const p of nationPlayers) { + placePlayer(p, 1); } return result; } +// Splits players into groups connected by friend edges, each group in the +// order it was reached, largest groups first (ties keep join order). Players +// with no friends among `players` are groups of one. +function friendGroups( + players: PlayerInfo[], + friendGraph: Map>, +): PlayerInfo[][] { + const byClientID = new Map(); + for (const p of players) { + if (p.clientID !== null) byClientID.set(p.clientID, p); + } + const grouped = new Set(); + const groups: PlayerInfo[][] = []; + for (const start of players) { + if (grouped.has(start)) continue; + grouped.add(start); + const group = [start]; + for (let i = 0; i < group.length; i++) { + const clientID = group[i].clientID; + if (clientID === null) continue; + for (const friendID of friendGraph.get(clientID) ?? []) { + const friend = byClientID.get(friendID); + if (friend === undefined || grouped.has(friend)) continue; + grouped.add(friend); + group.push(friend); + } + } + groups.push(group); + } + return groups.sort((a, b) => b.length - a.length); +} + export function assignTeamsLobbyPreview( players: PlayerInfo[], teams: Team[], diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index b0b62dc05a..695398530a 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -306,6 +306,61 @@ describe("assignTeams", () => { expect(result.get(players[3])).not.toEqual(result.get(players[0])); }); + // Public Duos/Trios/Quads pack the fullest open team first. Placing one + // friend at a time let the first friend take the last seat beside a + // stranger, so the second friend found that team full and was split off. + const duoTeams = [ColoredTeams.Red, ColoredTeams.Blue, ColoredTeams.Yellow]; + + it("should keep duo friends together when a stranger joined first", () => { + const players = [ + createPlayerWithFriends("s1", []), + createPlayerWithFriends("a", ["b"]), + createPlayerWithFriends("b", ["a"]), + createPlayerWithFriends("s2", []), + createPlayerWithFriends("s3", []), + createPlayerWithFriends("s4", []), + ]; + + const result = assignTeams(players, duoTeams, true); + + expect(result.get(players[1])).toEqual(result.get(players[2])); + expect(result.get(players[0])).not.toEqual(result.get(players[1])); + }); + + it("should keep duo friends together when they joined far apart", () => { + const players = [ + createPlayerWithFriends("a", ["b"]), + createPlayerWithFriends("s1", []), + createPlayerWithFriends("s2", []), + createPlayerWithFriends("b", ["a"]), + createPlayerWithFriends("s3", []), + createPlayerWithFriends("s4", []), + ]; + + const result = assignTeams(players, duoTeams, true); + + expect(result.get(players[0])).toEqual(result.get(players[3])); + }); + + it("should seat a friend group on a team with room for all of them", () => { + // A lone clan member already holds Red, so the trio of friends must + // start on an empty team rather than the fullest one. + const players = [ + createPlayerWithFriends("c1", [], "CLANA"), + createPlayerWithFriends("a", ["b", "c"]), + createPlayerWithFriends("b", []), + createPlayerWithFriends("c", []), + createPlayerWithFriends("s1", []), + createPlayerWithFriends("s2", []), + ]; + + const result = assignTeams(players, teams, true, 3); + + expect(result.get(players[1])).toEqual(result.get(players[2])); + expect(result.get(players[1])).toEqual(result.get(players[3])); + expect(result.get(players[0])).not.toEqual(result.get(players[1])); + }); + // Matchmade games: the server pins each player to a team slot via // PlayerInfo.teamIndex and the matcher's split must be honored verbatim. const createPinnedPlayer = (