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
+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);