Files
OpenFrontIO/tests/TeamAssignment.test.ts
T
Evan db501c68d2 Put friends on the same team (#3994)
Fixes #3911

## Description:

- Server captures `publicId` and `friends` from `getUserMe()` and
includes each player's in-game friend `clientID`s in `PlayerSchema` on
game start
- Team assignment treats friends as a **soft preference** (best-effort):
a non-clan player goes to the team where the most of their friends
already are; if that team is full they spill to the next-emptiest team
rather than getting kicked
- Clans remain strict (kick overflow) since clan membership is an
explicit opt-in; friends are implicit, so a friend-of-friend chain that
doesn't fit shouldn't bench anyone
- Friendship is symmetric — an edge from either direction counts, which
keeps things working when one side's `getUserMe` is stale
- Lobby preview unchanged — friend grouping only takes effect once the
game actually starts (avoids exposing friend lists in the lobby payload)


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-05-23 18:02:41 +01:00

313 lines
11 KiB
TypeScript

import { ColoredTeams, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { assignTeams } from "../src/core/game/TeamAssignment";
const teams = [ColoredTeams.Red, ColoredTeams.Blue];
describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
return new PlayerInfo(
`Player ${id}`,
PlayerType.Human,
null, // clientID (null for testing)
id,
false,
clan,
);
};
// 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"),
createPlayer("2"),
createPlayer("3"),
createPlayer("4"),
];
const result = assignTeams(players, teams);
// Check that players are assigned alternately
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual(ColoredTeams.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, teams);
// Check that clan members are on the same team
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.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, teams);
// Check that clan members are together and non-clan players balance teams
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.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, teams);
// Check that players are kicked when teams are full
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("kicked");
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Blue);
});
it("should handle empty player list", () => {
const result = assignTeams([], teams);
expect(result.size).toBe(0);
});
it("should handle single player", () => {
const players = [createPlayer("1")];
const result = assignTeams(players, teams);
expect(result.get(players[0])).toEqual(ColoredTeams.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, teams);
// Check that larger clans are assigned first
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 distribute players among a larger number of teams", () => {
const players = [
createPlayer("1", "CLANA"),
createPlayer("2", "CLANA"),
createPlayer("3", "CLANA"),
createPlayer("4", "CLANB"),
createPlayer("5", "CLANB"),
createPlayer("6", "CLANC"),
createPlayer("7"),
createPlayer("8"),
createPlayer("9"),
createPlayer("10"),
createPlayer("11"),
createPlayer("12"),
createPlayer("13"),
createPlayer("14"),
];
const result = assignTeams(players, [
ColoredTeams.Red,
ColoredTeams.Blue,
ColoredTeams.Yellow,
ColoredTeams.Green,
ColoredTeams.Purple,
ColoredTeams.Orange,
ColoredTeams.Teal,
]);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual("kicked");
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[6])).toEqual(ColoredTeams.Green);
expect(result.get(players[7])).toEqual(ColoredTeams.Purple);
expect(result.get(players[8])).toEqual(ColoredTeams.Orange);
expect(result.get(players[9])).toEqual(ColoredTeams.Teal);
expect(result.get(players[10])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[11])).toEqual(ColoredTeams.Green);
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);
});
});