mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 05:12:28 +00:00
## Description: Implements 2v2 ranked matchmaking end-to-end against the matchmaking API's 2v2 queues (API PR #419): core team pinning, the server's second checkin loop and game creation, and the client UI. ## Core — deterministic team pinning The matcher's assignment specifies exactly who plays with whom (`teams: [[a,d],[b,c]]`), but team assignment previously only did clan/friend balancing and could scramble the ELO-balanced split. - `PlayerSchema`/`PlayerInfo` gain an optional **`teamIndex`** — a server-stamped index into the game's team list, part of `GameStartInfo` so it's identical on every client (same category as `clanTag`/`friends`, which already feed deterministic team assignment). - `assignTeams` honors pins **unconditionally** — before clan/friend grouping and past `maxTeamSize` (the matcher's balancing is authoritative) — and seeds the counts that balancing of any unpinned players sees. Pinned players still participate in the friend graph, so an unpinned friend is pulled toward a pinned player's team. - publicIds never enter core: the game server resolves publicId → teamIndex per client at game start. ## Server - **One checkin long-poll per mode.** Both loops send `mode` explicitly (the API deployed ahead of the client, so no omit-for-back-compat needed). - **`get2v2Config()`**: Team mode, `playerTeams: 2`, `maxPlayers: 4`, always-compact map, donations enabled (matching public team games), `rankedType: "2v2"` (the API's 2v2 ingestion has shipped; `RankedType` gains `TwoVTwo`). - **The assignment payload is now used** (it was previously discarded): `players` → `allowedPublicIds` so only the matched accounts can take the slots (also hardens 1v1), and `teams` → `teamIndex` stamps at game start. A malformed assignment logs a warning and falls back to creating the game without pins rather than stranding matched players. - The 3-clients-per-IP cap on public games applies to matchmade games too (an allowlist doesn't stop one person multi-tabbing multiple accounts). It is now skipped in dev, where local testing (multi-tab, the 4-player e2e) is inherently same-IP — matching the existing dev/prod gating of Turnstile and the duplicate-account kick. ## Client - Ranked modal's 2v2 card is enabled; it passes the mode through `open-matchmaking` (dispatchers without a detail — homepage button, requeue URL — still mean 1v1). - Matchmaking modal joins with `&mode=1v1`/`&mode=2v2`, shows a 2v2 title (`matchmaking_modal.title_2v2` in en.json), and shows the real 2v2 ELO from the new `leaderboard.twoVtwo` field in `/users/@me` (the ranked modal's 2v2 card does too). - WinModal shows requeue for any ranked game and carries the mode back into the right queue (`/?requeue=2v2`). - 2v2 ranked stats surface in the player stats tree (labeled via `player_stats_tree.ranked_2v2`). ## Harnesses (`tests/matchmaking/`) - Contained: the fake server captures the `mode` query param; asserts each queue sends its mode explicitly. **10/10.** - E2E: `MM_MODE=2v2` runs four real browser players through the real local worker's 2v2 queue and rides the flow into the started game. Asserts same gameId for all four, the 2v2 config, allowlist admission, and a **deterministic 2 vs 2 in-game split read from each client's GameView** (the software-WebGL gate is spoofed in test pages only). **8/8.** 1v1 e2e still **6/6.** ## Verification - `npm test`: 2,053 tests pass, including 7 new (6 `assignTeams` pinning unit tests + a full-game pinned-split test through `setup()`). - `npx tsc --noEmit`, ESLint clean. - Live e2e against a local `wrangler dev` API worker: 1v1 (6/6) and 2v2 (8/8) as above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## 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 (UI changes — the ranked modal's 1v1/2v2 cards — were verified with before/after screenshots in the live app during development.) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
422 lines
14 KiB
TypeScript
422 lines
14 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]));
|
|
});
|
|
|
|
// 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 = (
|
|
id: string,
|
|
teamIndex: number | null,
|
|
clan?: string,
|
|
friends: string[] = [],
|
|
): PlayerInfo => {
|
|
return new PlayerInfo(
|
|
`Player ${id}`,
|
|
PlayerType.Human,
|
|
id, // clientID
|
|
id,
|
|
false,
|
|
clan,
|
|
friends,
|
|
teamIndex,
|
|
);
|
|
};
|
|
|
|
it("should honor pinned teamIndex exactly (matchmade 2v2)", () => {
|
|
const players = [
|
|
createPinnedPlayer("1", 0),
|
|
createPinnedPlayer("2", 1),
|
|
createPinnedPlayer("3", 1),
|
|
createPinnedPlayer("4", 0),
|
|
];
|
|
|
|
const result = assignTeams(players, teams);
|
|
|
|
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
|
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
|
|
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
|
|
expect(result.get(players[3])).toEqual(ColoredTeams.Red);
|
|
});
|
|
|
|
it("should let pins override clan grouping", () => {
|
|
// Two clanmates matched onto opposite teams stay split: the matcher's
|
|
// balancing is authoritative over the clan all-on-one-team rule.
|
|
const players = [
|
|
createPinnedPlayer("1", 0, "CLANA"),
|
|
createPinnedPlayer("2", 1, "CLANA"),
|
|
createPinnedPlayer("3", 1),
|
|
createPinnedPlayer("4", 0),
|
|
];
|
|
|
|
const result = assignTeams(players, teams);
|
|
|
|
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
|
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
|
|
});
|
|
|
|
it("should balance unpinned players around pinned ones", () => {
|
|
// Red already holds two pinned players (at capacity for 4 players /
|
|
// 2 teams), so the unpinned player must land on Blue.
|
|
const players = [
|
|
createPinnedPlayer("1", 0),
|
|
createPinnedPlayer("2", 0),
|
|
createPinnedPlayer("3", 1),
|
|
createPinnedPlayer("4", null),
|
|
];
|
|
|
|
const result = assignTeams(players, teams);
|
|
|
|
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
|
|
});
|
|
|
|
it("should treat an out-of-range teamIndex as unpinned", () => {
|
|
const players = [
|
|
createPinnedPlayer("1", 7),
|
|
createPinnedPlayer("2", null),
|
|
createPinnedPlayer("3", null),
|
|
createPinnedPlayer("4", null),
|
|
];
|
|
|
|
const result = assignTeams(players, teams);
|
|
|
|
for (const p of players) {
|
|
expect([ColoredTeams.Red, ColoredTeams.Blue]).toContain(result.get(p));
|
|
}
|
|
});
|
|
|
|
it("should pull an unpinned friend toward a pinned player's team", () => {
|
|
const players = [
|
|
createPinnedPlayer("1", 0),
|
|
createPinnedPlayer("2", null, undefined, ["1"]),
|
|
createPinnedPlayer("3", null),
|
|
createPinnedPlayer("4", null),
|
|
];
|
|
|
|
const result = assignTeams(players, teams);
|
|
|
|
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
|
|
});
|
|
|
|
it("should honor pins even past maxTeamSize (trust the matcher)", () => {
|
|
const players = [
|
|
createPinnedPlayer("1", 0),
|
|
createPinnedPlayer("2", 0),
|
|
createPinnedPlayer("3", 0),
|
|
];
|
|
|
|
const result = assignTeams(players, teams, 1);
|
|
|
|
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
|
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
|
|
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|