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
This commit is contained in:
Evan
2026-05-23 18:02:41 +01:00
committed by GitHub
parent db0ec97dc4
commit db501c68d2
11 changed files with 264 additions and 47 deletions
+1
View File
@@ -117,6 +117,7 @@ export const UserMeResponseSchema = z.object({
}),
)
.optional(),
friends: z.array(z.string()),
subscription: z
.object({
tier: z.string(),
+1
View File
@@ -53,6 +53,7 @@ export async function createGameRunner(
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
p.friends ?? [],
);
});
+1
View File
@@ -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({
+1
View File
@@ -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);
}
+86 -40
View File
@@ -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<PlayerInfo, Team | "kicked">();
const teamPlayerCount = new Map<Team, number>();
// 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<string, PlayerInfo[]>();
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<ClientID>();
for (const p of players) {
if (p.clientID !== null) presentClientIDs.add(p.clientID);
}
const friendGraph = new Map<ClientID, Set<ClientID>>();
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<ClientID, Team>();
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;
+2
View File
@@ -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[],
) {}
}
+18 -7
View File
@@ -731,18 +731,29 @@ export class GameServer {
// if no client connects/pings.
this.lastPingUpdate = Date.now();
const publicIdToClientID = new Map<string, ClientID>();
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);
+6
View File
@@ -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);
+1
View File
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
adfree: false,
flares,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
},
} as UserMeResponse;
+145
View File
@@ -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);
});
});
@@ -51,6 +51,8 @@ function makeClient(
null,
ws as any,
undefined,
undefined,
[],
);
return { client, ws };
}