mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -117,6 +117,7 @@ export const UserMeResponseSchema = z.object({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
friends: z.array(z.string()),
|
||||
subscription: z
|
||||
.object({
|
||||
tier: z.string(),
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function createGameRunner(
|
||||
random.nextID(),
|
||||
p.isLobbyCreator ?? false,
|
||||
p.clanTag,
|
||||
p.friends ?? [],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
|
||||
adfree: false,
|
||||
flares,
|
||||
achievements: { singleplayerMap: [] },
|
||||
friends: [],
|
||||
subscription: null,
|
||||
},
|
||||
} as UserMeResponse;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user