Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 51 additions & 4 deletions packages/engine-lib/src/game/TeamAssignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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<ClientID, Set<ClientID>>,
): PlayerInfo[][] {
const byClientID = new Map<ClientID, PlayerInfo>();
for (const p of players) {
if (p.clientID !== null) byClientID.set(p.clientID, p);
}
const grouped = new Set<PlayerInfo>();
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[],
Expand Down
55 changes: 55 additions & 0 deletions tests/TeamAssignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Loading