From 920951c9bad3f9337277df302447c41f6845f705 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 24 Jul 2026 11:01:18 -0400 Subject: [PATCH 1/2] bug: fix matchmaking size --- .../matchmaking-lobby.service.spec.ts | 155 +++++++++++++++ src/matchmaking/matchmaking-lobby.service.ts | 27 +-- src/matchmaking/utilities/partySize.spec.ts | 181 ++++++++++++++++++ src/matchmaking/utilities/partySize.ts | 44 +++++ 4 files changed, 384 insertions(+), 23 deletions(-) create mode 100644 src/matchmaking/matchmaking-lobby.service.spec.ts create mode 100644 src/matchmaking/utilities/partySize.spec.ts create mode 100644 src/matchmaking/utilities/partySize.ts diff --git a/src/matchmaking/matchmaking-lobby.service.spec.ts b/src/matchmaking/matchmaking-lobby.service.spec.ts new file mode 100644 index 000000000..8ba035f99 --- /dev/null +++ b/src/matchmaking/matchmaking-lobby.service.spec.ts @@ -0,0 +1,155 @@ +import { Logger } from "@nestjs/common"; +import Redis from "ioredis"; +import { e_match_types_enum } from "generated"; + +import { MatchmakingLobbyService } from "./matchmaking-lobby.service"; +import { HasuraService } from "../hasura/hasura.service"; +import { MatchmakeService } from "./matchmake.service"; +import { PlayerLobby } from "./types/PlayerLobby"; +import { JoinQueueError } from "./utilities/joinQueueError"; +import { User } from "../auth/types/User"; + +describe("MatchmakingLobbyService.verifyLobby", () => { + let service: MatchmakingLobbyService; + let mockHasura: jest.Mocked; + + const captainSteamId = "steam-id-1"; + + const buildLobby = (playerCount: number): PlayerLobby => ({ + id: "lobby-1", + players: Array.from({ length: playerCount }, (_, index) => ({ + captain: index === 0, + name: `player-${index + 1}`, + steam_id: index === 0 ? captainSteamId : `steam-id-${index + 1}`, + is_banned: false, + matchmaking_cooldown: false, + })), + }); + + const captain = { steam_id: captainSteamId } as User; + + beforeEach(() => { + // verifyPlayer runs per lobby member once the size check passes — every + // player comes back clean so only the party-size rule can fail. + mockHasura = { + query: jest.fn().mockImplementation(({ players_by_pk }) => ({ + players_by_pk: { + name: "player", + steam_id: players_by_pk.__args.steam_id, + is_banned: false, + matchmaking_cooldown: false, + current_lobby_id: "lobby-1", + is_in_another_match: false, + }, + })), + } as any; + + const mockRedisManager = { + getConnection: jest.fn().mockReturnValue({} as Redis), + } as any; + + service = new MatchmakingLobbyService( + new Logger("Test"), + mockHasura, + mockRedisManager, + {} as MatchmakeService, + ); + }); + + it("rejects a player who is not the lobby captain", async () => { + await expect( + service.verifyLobby( + buildLobby(2), + { steam_id: "steam-id-2" } as User, + "Competitive", + ), + ).rejects.toThrow("you are not the captain of this lobby"); + }); + + describe("Competitive (10 players)", () => { + const type: e_match_types_enum = "Competitive"; + + it.each([1, 2, 3, 4, 5, 10])("accepts a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).resolves.toBe(true); + }); + + it.each([6, 7, 8, 9, 11, 12])("rejects a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).rejects.toBeInstanceOf(JoinQueueError); + }); + + it("explains the requirement when the party is between the two valid sizes", async () => { + await expect( + service.verifyLobby(buildLobby(7), captain, type), + ).rejects.toThrow( + "To join a Competitive match, your lobby must have 5 or fewer players, or exactly 10 players. You have 7.", + ); + }); + + it("rejects a party larger than the match itself", async () => { + await expect( + service.verifyLobby(buildLobby(11), captain, type), + ).rejects.toThrow("You have 11."); + }); + }); + + describe("Wingman (4 players)", () => { + const type: e_match_types_enum = "Wingman"; + + it.each([1, 2, 4])("accepts a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).resolves.toBe(true); + }); + + it.each([3, 5, 6, 10])("rejects a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).rejects.toBeInstanceOf(JoinQueueError); + }); + }); + + describe("Duel (2 players)", () => { + const type: e_match_types_enum = "Duel"; + + it.each([1, 2])("accepts a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).resolves.toBe(true); + }); + + it.each([3, 4, 5, 10])("rejects a party of %i", async (size) => { + await expect( + service.verifyLobby(buildLobby(size), captain, type), + ).rejects.toBeInstanceOf(JoinQueueError); + }); + }); + + it("does not run per-player verification when the party size is invalid", async () => { + await expect( + service.verifyLobby(buildLobby(7), captain, "Competitive"), + ).rejects.toThrow(JoinQueueError); + + expect(mockHasura.query).not.toHaveBeenCalled(); + }); + + it("still rejects a valid-sized party when a member is banned", async () => { + mockHasura.query.mockResolvedValueOnce({ + players_by_pk: { + name: "banned-player", + steam_id: captainSteamId, + is_banned: true, + matchmaking_cooldown: false, + current_lobby_id: "lobby-1", + is_in_another_match: false, + }, + } as any); + + await expect( + service.verifyLobby(buildLobby(5), captain, "Competitive"), + ).rejects.toThrow("banned-player is banned"); + }); +}); diff --git a/src/matchmaking/matchmaking-lobby.service.ts b/src/matchmaking/matchmaking-lobby.service.ts index 0caa28ac9..d38767747 100644 --- a/src/matchmaking/matchmaking-lobby.service.ts +++ b/src/matchmaking/matchmaking-lobby.service.ts @@ -15,6 +15,7 @@ import { getMatchmakingLobbyDetailsCacheKey, } from "./utilities/cacheKeys"; import { JoinQueueError } from "./utilities/joinQueueError"; +import { getPartySizeError } from "./utilities/partySize"; @Injectable() export class MatchmakingLobbyService { @@ -117,30 +118,10 @@ export class MatchmakingLobbyService { throw new JoinQueueError(`you are not the captain of this lobby`); } - const totalPlayers = lobby.players.length; + const partySizeError = getPartySizeError(type, lobby.players.length); - switch (type) { - case "Competitive": - if (totalPlayers > 5 && totalPlayers !== 10) { - throw new JoinQueueError( - `To join a Competitive match, with a lobby greater than 5 players, you must have 10 players in your lobby`, - ); - } - break; - case "Wingman": - if (totalPlayers > 2 && totalPlayers !== 4) { - throw new JoinQueueError( - `To join a Wingman match, with a lobby greater than 2 players, you must have 4 players in your lobby`, - ); - } - break; - case "Duel": - if (totalPlayers > 1 && totalPlayers !== 2) { - throw new JoinQueueError( - `To join a Duel match, with a lobby greater than 1 player you must have 2 players in your lobby`, - ); - } - break; + if (partySizeError) { + throw new JoinQueueError(partySizeError); } for (const player of lobby.players) { diff --git a/src/matchmaking/utilities/partySize.spec.ts b/src/matchmaking/utilities/partySize.spec.ts new file mode 100644 index 000000000..5038f9c40 --- /dev/null +++ b/src/matchmaking/utilities/partySize.spec.ts @@ -0,0 +1,181 @@ +import { e_match_types_enum } from "generated"; +import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; +import { canPartyQueue, getPartySizeError } from "./partySize"; + +/** + * The rule: a party may queue when it fits in one lineup (<= half the match) or + * fills the whole match exactly. Everything else is unplaceable. + */ +describe("canPartyQueue", () => { + describe("Duel (2 players, 1v1)", () => { + const type: e_match_types_enum = "Duel"; + + it("allows a solo player (fills one lineup)", () => { + expect(canPartyQueue(type, 1)).toBe(true); + }); + + it("allows a full party of 2 (both lineups, split in-house)", () => { + expect(canPartyQueue(type, 2)).toBe(true); + }); + + it.each([3, 4, 5, 10, 11])("rejects a party of %i", (size) => { + expect(canPartyQueue(type, size)).toBe(false); + }); + }); + + describe("Wingman (4 players, 2v2)", () => { + const type: e_match_types_enum = "Wingman"; + + it.each([1, 2])("allows a party of %i (fits one lineup)", (size) => { + expect(canPartyQueue(type, size)).toBe(true); + }); + + it("allows a full party of 4", () => { + expect(canPartyQueue(type, 4)).toBe(true); + }); + + it("rejects a party of 3 — too big for one lineup, too small for two", () => { + expect(canPartyQueue(type, 3)).toBe(false); + }); + + it.each([5, 6, 10, 11])("rejects a party of %i", (size) => { + expect(canPartyQueue(type, size)).toBe(false); + }); + }); + + describe("Competitive (10 players, 5v5)", () => { + const type: e_match_types_enum = "Competitive"; + + it.each([1, 2, 3, 4, 5])( + "allows a party of %i (fits one lineup)", + (size) => { + expect(canPartyQueue(type, size)).toBe(true); + }, + ); + + it("allows a full party of 10", () => { + expect(canPartyQueue(type, 10)).toBe(true); + }); + + it.each([6, 7, 8, 9])( + "rejects a party of %i — between one lineup and a full match", + (size) => { + expect(canPartyQueue(type, size)).toBe(false); + }, + ); + + it.each([11, 12, 15, 20])( + "rejects a party of %i — larger than the match itself", + (size) => { + expect(canPartyQueue(type, size)).toBe(false); + }, + ); + }); + + describe("Premier / Faceit (10 players)", () => { + const tenPlayerTypes: e_match_types_enum[] = ["Premier", "Faceit"]; + + it.each(tenPlayerTypes)( + "%s follows the same 1-5 or exactly 10 rule", + (type) => { + expect(canPartyQueue(type, 5)).toBe(true); + expect(canPartyQueue(type, 6)).toBe(false); + expect(canPartyQueue(type, 10)).toBe(true); + expect(canPartyQueue(type, 11)).toBe(false); + }, + ); + }); + + describe("the party sizes from the queue matrix", () => { + const matchmakingTypes: e_match_types_enum[] = [ + "Duel", + "Wingman", + "Competitive", + ]; + + const queueableTypes = (size: number) => + matchmakingTypes.filter((type) => canPartyQueue(type, size)); + + it("1 — every mode", () => { + expect(queueableTypes(1)).toEqual(["Duel", "Wingman", "Competitive"]); + }); + + it("2 — every mode (full Duel, half a Wingman)", () => { + expect(queueableTypes(2)).toEqual(["Duel", "Wingman", "Competitive"]); + }); + + it("3 — Competitive only", () => { + expect(queueableTypes(3)).toEqual(["Competitive"]); + }); + + it("4 — Wingman and Competitive", () => { + expect(queueableTypes(4)).toEqual(["Wingman", "Competitive"]); + }); + + it("5 — Competitive only", () => { + expect(queueableTypes(5)).toEqual(["Competitive"]); + }); + + it.each([6, 7, 8, 9])("%i — nothing is queueable", (size) => { + expect(queueableTypes(size)).toEqual([]); + }); + + it("10 — Competitive only", () => { + expect(queueableTypes(10)).toEqual(["Competitive"]); + }); + + it.each([11, 12, 20])( + "%i — nothing is queueable, over every match size", + (size) => { + expect(queueableTypes(size)).toEqual([]); + }, + ); + }); + + describe("degenerate sizes", () => { + it.each([0, -1])("rejects a party of %i", (size) => { + expect(canPartyQueue("Competitive", size)).toBe(false); + }); + }); + + it("stays in sync with ExpectedPlayers for every match type", () => { + for (const [type, expected] of Object.entries(ExpectedPlayers)) { + const matchType = type as e_match_types_enum; + + expect(canPartyQueue(matchType, expected / 2)).toBe(true); + expect(canPartyQueue(matchType, expected / 2 + 1)).toBe( + expected / 2 + 1 === expected, + ); + expect(canPartyQueue(matchType, expected)).toBe(true); + expect(canPartyQueue(matchType, expected + 1)).toBe(false); + } + }); +}); + +describe("getPartySizeError", () => { + it("returns nothing for a queueable party", () => { + expect(getPartySizeError("Competitive", 5)).toBeUndefined(); + expect(getPartySizeError("Wingman", 4)).toBeUndefined(); + }); + + it("names the type, both valid sizes, and the actual party size", () => { + const error = getPartySizeError("Competitive", 7); + + expect(error).toContain("Competitive"); + expect(error).toContain("5 or fewer"); + expect(error).toContain("exactly 10"); + expect(error).toContain("You have 7"); + }); + + it("reports Wingman thresholds", () => { + expect(getPartySizeError("Wingman", 3)).toContain( + "2 or fewer players, or exactly 4 players", + ); + }); + + it("reports Duel thresholds", () => { + expect(getPartySizeError("Duel", 3)).toContain( + "1 or fewer players, or exactly 2 players", + ); + }); +}); diff --git a/src/matchmaking/utilities/partySize.ts b/src/matchmaking/utilities/partySize.ts new file mode 100644 index 000000000..de78b3701 --- /dev/null +++ b/src/matchmaking/utilities/partySize.ts @@ -0,0 +1,44 @@ +import { e_match_types_enum } from "generated"; +import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; + +/** + * A party may queue a match type when it either fits inside a single lineup + * (half the match, matchmaking fills the other half) or fills the entire match + * on its own (both lineups, split in-house). Anything between those two — or + * anything above the full match size — cannot be placed. + * + * Duel (2): 1 or 2 · Wingman (4): 1-2 or 4 · Competitive (10): 1-5 or 10 + */ +export function canPartyQueue( + type: e_match_types_enum, + partySize: number, +): boolean { + const expected = ExpectedPlayers[type]; + + if (!expected) { + return true; + } + + if (partySize < 1) { + return false; + } + + return partySize <= expected / 2 || partySize === expected; +} + +/** + * Returns the reason a party cannot queue the given type, or undefined when it + * can. Used for the JoinQueueError message surfaced to every lobby member. + */ +export function getPartySizeError( + type: e_match_types_enum, + partySize: number, +): string | undefined { + if (canPartyQueue(type, partySize)) { + return; + } + + const expected = ExpectedPlayers[type]; + + return `To join a ${type} match, your lobby must have ${expected / 2} or fewer players, or exactly ${expected} players. You have ${partySize}.`; +} From ccb00983a61b09c45acb6899c5901f4d90442a7d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 24 Jul 2026 11:04:36 -0400 Subject: [PATCH 2/2] wip --- .../matchmaking-lobby.service.spec.ts | 191 +++++++++++++++--- src/matchmaking/matchmaking-lobby.service.ts | 35 +++- src/matchmaking/utilities/partySize.spec.ts | 181 ----------------- src/matchmaking/utilities/partySize.ts | 44 ---- 4 files changed, 192 insertions(+), 259 deletions(-) delete mode 100644 src/matchmaking/utilities/partySize.spec.ts delete mode 100644 src/matchmaking/utilities/partySize.ts diff --git a/src/matchmaking/matchmaking-lobby.service.spec.ts b/src/matchmaking/matchmaking-lobby.service.spec.ts index 8ba035f99..9e0c1367e 100644 --- a/src/matchmaking/matchmaking-lobby.service.spec.ts +++ b/src/matchmaking/matchmaking-lobby.service.spec.ts @@ -8,12 +8,25 @@ import { MatchmakeService } from "./matchmake.service"; import { PlayerLobby } from "./types/PlayerLobby"; import { JoinQueueError } from "./utilities/joinQueueError"; import { User } from "../auth/types/User"; +import { ExpectedPlayers } from "../discord-bot/enums/ExpectedPlayers"; +/** + * The party-size rule: a lobby may queue when it fits in one lineup (<= half + * the match) or fills the whole match exactly. Everything else is unplaceable. + */ describe("MatchmakingLobbyService.verifyLobby", () => { let service: MatchmakingLobbyService; let mockHasura: jest.Mocked; const captainSteamId = "steam-id-1"; + const captain = { steam_id: captainSteamId } as User; + + // Premier and Faceit are demo-import only — they are never matchmade. + const matchmakingTypes: e_match_types_enum[] = [ + "Duel", + "Wingman", + "Competitive", + ]; const buildLobby = (playerCount: number): PlayerLobby => ({ id: "lobby-1", @@ -26,7 +39,16 @@ describe("MatchmakingLobbyService.verifyLobby", () => { })), }); - const captain = { steam_id: captainSteamId } as User; + const canQueue = async (type: e_match_types_enum, partySize: number) => { + try { + return await service.verifyLobby(buildLobby(partySize), captain, type); + } catch (error) { + if (error instanceof JoinQueueError) { + return false; + } + throw error; + } + }; beforeEach(() => { // verifyPlayer runs per lobby member once the size check passes — every @@ -66,22 +88,87 @@ describe("MatchmakingLobbyService.verifyLobby", () => { ).rejects.toThrow("you are not the captain of this lobby"); }); - describe("Competitive (10 players)", () => { - const type: e_match_types_enum = "Competitive"; + describe("Duel (2 players, 1v1)", () => { + const type: e_match_types_enum = "Duel"; + + it("accepts a solo player — fills one lineup", async () => { + await expect(canQueue(type, 1)).resolves.toBe(true); + }); + + it("accepts a full party of 2 — both lineups, split in-house", async () => { + await expect(canQueue(type, 2)).resolves.toBe(true); + }); + + it.each([3, 4, 5, 10, 11])("rejects a party of %i", async (size) => { + await expect(canQueue(type, size)).resolves.toBe(false); + }); - it.each([1, 2, 3, 4, 5, 10])("accepts a party of %i", async (size) => { + it("explains the requirement", async () => { await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).resolves.toBe(true); + service.verifyLobby(buildLobby(3), captain, type), + ).rejects.toThrow( + "To join a Duel match, your lobby must have 1 or fewer players, or exactly 2 players. You have 3.", + ); }); + }); + + describe("Wingman (4 players, 2v2)", () => { + const type: e_match_types_enum = "Wingman"; - it.each([6, 7, 8, 9, 11, 12])("rejects a party of %i", async (size) => { + it.each([1, 2])("accepts a party of %i — fits one lineup", async (size) => { + await expect(canQueue(type, size)).resolves.toBe(true); + }); + + it("accepts a full party of 4", async () => { + await expect(canQueue(type, 4)).resolves.toBe(true); + }); + + it("rejects a party of 3 — too big for one lineup, too small for two", async () => { + await expect(canQueue(type, 3)).resolves.toBe(false); + }); + + it.each([5, 6, 10, 11])("rejects a party of %i", async (size) => { + await expect(canQueue(type, size)).resolves.toBe(false); + }); + + it("explains the requirement", async () => { await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).rejects.toBeInstanceOf(JoinQueueError); + service.verifyLobby(buildLobby(3), captain, type), + ).rejects.toThrow( + "To join a Wingman match, your lobby must have 2 or fewer players, or exactly 4 players. You have 3.", + ); + }); + }); + + describe("Competitive (10 players, 5v5)", () => { + const type: e_match_types_enum = "Competitive"; + + it.each([1, 2, 3, 4, 5])( + "accepts a party of %i — fits one lineup", + async (size) => { + await expect(canQueue(type, size)).resolves.toBe(true); + }, + ); + + it("accepts a full party of 10", async () => { + await expect(canQueue(type, 10)).resolves.toBe(true); }); - it("explains the requirement when the party is between the two valid sizes", async () => { + it.each([6, 7, 8, 9])( + "rejects a party of %i — between one lineup and a full match", + async (size) => { + await expect(canQueue(type, size)).resolves.toBe(false); + }, + ); + + it.each([11, 12, 15, 20])( + "rejects a party of %i — larger than the match itself", + async (size) => { + await expect(canQueue(type, size)).resolves.toBe(false); + }, + ); + + it("explains the requirement", async () => { await expect( service.verifyLobby(buildLobby(7), captain, type), ).rejects.toThrow( @@ -89,43 +176,83 @@ describe("MatchmakingLobbyService.verifyLobby", () => { ); }); - it("rejects a party larger than the match itself", async () => { + it("reports the party size when it is over the full match size", async () => { await expect( service.verifyLobby(buildLobby(11), captain, type), ).rejects.toThrow("You have 11."); }); }); - describe("Wingman (4 players)", () => { - const type: e_match_types_enum = "Wingman"; + describe("the party sizes from the queue matrix", () => { + const queueableTypes = async (size: number) => { + const queueable: e_match_types_enum[] = []; + for (const type of matchmakingTypes) { + if (await canQueue(type, size)) { + queueable.push(type); + } + } + return queueable; + }; - it.each([1, 2, 4])("accepts a party of %i", async (size) => { - await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).resolves.toBe(true); + it("1 — every mode", async () => { + await expect(queueableTypes(1)).resolves.toEqual([ + "Duel", + "Wingman", + "Competitive", + ]); }); - it.each([3, 5, 6, 10])("rejects a party of %i", async (size) => { - await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).rejects.toBeInstanceOf(JoinQueueError); + it("2 — every mode (full Duel, half a Wingman)", async () => { + await expect(queueableTypes(2)).resolves.toEqual([ + "Duel", + "Wingman", + "Competitive", + ]); }); - }); - describe("Duel (2 players)", () => { - const type: e_match_types_enum = "Duel"; + it("3 — Competitive only", async () => { + await expect(queueableTypes(3)).resolves.toEqual(["Competitive"]); + }); - it.each([1, 2])("accepts a party of %i", async (size) => { - await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).resolves.toBe(true); + it("4 — Wingman and Competitive", async () => { + await expect(queueableTypes(4)).resolves.toEqual([ + "Wingman", + "Competitive", + ]); }); - it.each([3, 4, 5, 10])("rejects a party of %i", async (size) => { - await expect( - service.verifyLobby(buildLobby(size), captain, type), - ).rejects.toBeInstanceOf(JoinQueueError); + it("5 — Competitive only", async () => { + await expect(queueableTypes(5)).resolves.toEqual(["Competitive"]); + }); + + it.each([6, 7, 8, 9])("%i — nothing is queueable", async (size) => { + await expect(queueableTypes(size)).resolves.toEqual([]); + }); + + it("10 — Competitive only", async () => { + await expect(queueableTypes(10)).resolves.toEqual(["Competitive"]); }); + + it.each([11, 12, 20])( + "%i — nothing is queueable, over every match size", + async (size) => { + await expect(queueableTypes(size)).resolves.toEqual([]); + }, + ); + }); + + it("stays in sync with ExpectedPlayers for every matchmaking type", async () => { + for (const type of matchmakingTypes) { + const expected = ExpectedPlayers[type]; + const half = expected / 2; + + await expect(canQueue(type, half)).resolves.toBe(true); + await expect(canQueue(type, half + 1)).resolves.toBe( + half + 1 === expected, + ); + await expect(canQueue(type, expected)).resolves.toBe(true); + await expect(canQueue(type, expected + 1)).resolves.toBe(false); + } }); it("does not run per-player verification when the party size is invalid", async () => { diff --git a/src/matchmaking/matchmaking-lobby.service.ts b/src/matchmaking/matchmaking-lobby.service.ts index d38767747..8bfd23b7a 100644 --- a/src/matchmaking/matchmaking-lobby.service.ts +++ b/src/matchmaking/matchmaking-lobby.service.ts @@ -15,7 +15,7 @@ import { getMatchmakingLobbyDetailsCacheKey, } from "./utilities/cacheKeys"; import { JoinQueueError } from "./utilities/joinQueueError"; -import { getPartySizeError } from "./utilities/partySize"; +import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; @Injectable() export class MatchmakingLobbyService { @@ -118,7 +118,7 @@ export class MatchmakingLobbyService { throw new JoinQueueError(`you are not the captain of this lobby`); } - const partySizeError = getPartySizeError(type, lobby.players.length); + const partySizeError = this.getPartySizeError(type, lobby.players.length); if (partySizeError) { throw new JoinQueueError(partySizeError); @@ -405,6 +405,37 @@ export class MatchmakingLobbyService { return players_by_pk.current_lobby_id || steamId; } + /** + * A party may queue a match type when it either fits inside a single lineup + * (half the match, matchmaking fills the other half) or fills the entire + * match on its own (both lineups, split in-house). Anything between those two + * — or anything above the full match size — cannot be placed. + * + * Duel (2): 1 or 2 · Wingman (4): 1-2 or 4 · Competitive (10): 1-5 or 10 + */ + private canPartyQueue(type: e_match_types_enum, partySize: number): boolean { + const expected = ExpectedPlayers[type]; + + if (!expected) { + return true; + } + + return partySize <= expected / 2 || partySize === expected; + } + + private getPartySizeError( + type: e_match_types_enum, + partySize: number, + ): string | undefined { + if (this.canPartyQueue(type, partySize)) { + return; + } + + const expected = ExpectedPlayers[type]; + + return `To join a ${type} match, your lobby must have ${expected / 2} or fewer players, or exactly ${expected} players. You have ${partySize}.`; + } + private async verifyPlayer( lobbyId: string, steamId: string, diff --git a/src/matchmaking/utilities/partySize.spec.ts b/src/matchmaking/utilities/partySize.spec.ts deleted file mode 100644 index 5038f9c40..000000000 --- a/src/matchmaking/utilities/partySize.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { e_match_types_enum } from "generated"; -import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; -import { canPartyQueue, getPartySizeError } from "./partySize"; - -/** - * The rule: a party may queue when it fits in one lineup (<= half the match) or - * fills the whole match exactly. Everything else is unplaceable. - */ -describe("canPartyQueue", () => { - describe("Duel (2 players, 1v1)", () => { - const type: e_match_types_enum = "Duel"; - - it("allows a solo player (fills one lineup)", () => { - expect(canPartyQueue(type, 1)).toBe(true); - }); - - it("allows a full party of 2 (both lineups, split in-house)", () => { - expect(canPartyQueue(type, 2)).toBe(true); - }); - - it.each([3, 4, 5, 10, 11])("rejects a party of %i", (size) => { - expect(canPartyQueue(type, size)).toBe(false); - }); - }); - - describe("Wingman (4 players, 2v2)", () => { - const type: e_match_types_enum = "Wingman"; - - it.each([1, 2])("allows a party of %i (fits one lineup)", (size) => { - expect(canPartyQueue(type, size)).toBe(true); - }); - - it("allows a full party of 4", () => { - expect(canPartyQueue(type, 4)).toBe(true); - }); - - it("rejects a party of 3 — too big for one lineup, too small for two", () => { - expect(canPartyQueue(type, 3)).toBe(false); - }); - - it.each([5, 6, 10, 11])("rejects a party of %i", (size) => { - expect(canPartyQueue(type, size)).toBe(false); - }); - }); - - describe("Competitive (10 players, 5v5)", () => { - const type: e_match_types_enum = "Competitive"; - - it.each([1, 2, 3, 4, 5])( - "allows a party of %i (fits one lineup)", - (size) => { - expect(canPartyQueue(type, size)).toBe(true); - }, - ); - - it("allows a full party of 10", () => { - expect(canPartyQueue(type, 10)).toBe(true); - }); - - it.each([6, 7, 8, 9])( - "rejects a party of %i — between one lineup and a full match", - (size) => { - expect(canPartyQueue(type, size)).toBe(false); - }, - ); - - it.each([11, 12, 15, 20])( - "rejects a party of %i — larger than the match itself", - (size) => { - expect(canPartyQueue(type, size)).toBe(false); - }, - ); - }); - - describe("Premier / Faceit (10 players)", () => { - const tenPlayerTypes: e_match_types_enum[] = ["Premier", "Faceit"]; - - it.each(tenPlayerTypes)( - "%s follows the same 1-5 or exactly 10 rule", - (type) => { - expect(canPartyQueue(type, 5)).toBe(true); - expect(canPartyQueue(type, 6)).toBe(false); - expect(canPartyQueue(type, 10)).toBe(true); - expect(canPartyQueue(type, 11)).toBe(false); - }, - ); - }); - - describe("the party sizes from the queue matrix", () => { - const matchmakingTypes: e_match_types_enum[] = [ - "Duel", - "Wingman", - "Competitive", - ]; - - const queueableTypes = (size: number) => - matchmakingTypes.filter((type) => canPartyQueue(type, size)); - - it("1 — every mode", () => { - expect(queueableTypes(1)).toEqual(["Duel", "Wingman", "Competitive"]); - }); - - it("2 — every mode (full Duel, half a Wingman)", () => { - expect(queueableTypes(2)).toEqual(["Duel", "Wingman", "Competitive"]); - }); - - it("3 — Competitive only", () => { - expect(queueableTypes(3)).toEqual(["Competitive"]); - }); - - it("4 — Wingman and Competitive", () => { - expect(queueableTypes(4)).toEqual(["Wingman", "Competitive"]); - }); - - it("5 — Competitive only", () => { - expect(queueableTypes(5)).toEqual(["Competitive"]); - }); - - it.each([6, 7, 8, 9])("%i — nothing is queueable", (size) => { - expect(queueableTypes(size)).toEqual([]); - }); - - it("10 — Competitive only", () => { - expect(queueableTypes(10)).toEqual(["Competitive"]); - }); - - it.each([11, 12, 20])( - "%i — nothing is queueable, over every match size", - (size) => { - expect(queueableTypes(size)).toEqual([]); - }, - ); - }); - - describe("degenerate sizes", () => { - it.each([0, -1])("rejects a party of %i", (size) => { - expect(canPartyQueue("Competitive", size)).toBe(false); - }); - }); - - it("stays in sync with ExpectedPlayers for every match type", () => { - for (const [type, expected] of Object.entries(ExpectedPlayers)) { - const matchType = type as e_match_types_enum; - - expect(canPartyQueue(matchType, expected / 2)).toBe(true); - expect(canPartyQueue(matchType, expected / 2 + 1)).toBe( - expected / 2 + 1 === expected, - ); - expect(canPartyQueue(matchType, expected)).toBe(true); - expect(canPartyQueue(matchType, expected + 1)).toBe(false); - } - }); -}); - -describe("getPartySizeError", () => { - it("returns nothing for a queueable party", () => { - expect(getPartySizeError("Competitive", 5)).toBeUndefined(); - expect(getPartySizeError("Wingman", 4)).toBeUndefined(); - }); - - it("names the type, both valid sizes, and the actual party size", () => { - const error = getPartySizeError("Competitive", 7); - - expect(error).toContain("Competitive"); - expect(error).toContain("5 or fewer"); - expect(error).toContain("exactly 10"); - expect(error).toContain("You have 7"); - }); - - it("reports Wingman thresholds", () => { - expect(getPartySizeError("Wingman", 3)).toContain( - "2 or fewer players, or exactly 4 players", - ); - }); - - it("reports Duel thresholds", () => { - expect(getPartySizeError("Duel", 3)).toContain( - "1 or fewer players, or exactly 2 players", - ); - }); -}); diff --git a/src/matchmaking/utilities/partySize.ts b/src/matchmaking/utilities/partySize.ts deleted file mode 100644 index de78b3701..000000000 --- a/src/matchmaking/utilities/partySize.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { e_match_types_enum } from "generated"; -import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; - -/** - * A party may queue a match type when it either fits inside a single lineup - * (half the match, matchmaking fills the other half) or fills the entire match - * on its own (both lineups, split in-house). Anything between those two — or - * anything above the full match size — cannot be placed. - * - * Duel (2): 1 or 2 · Wingman (4): 1-2 or 4 · Competitive (10): 1-5 or 10 - */ -export function canPartyQueue( - type: e_match_types_enum, - partySize: number, -): boolean { - const expected = ExpectedPlayers[type]; - - if (!expected) { - return true; - } - - if (partySize < 1) { - return false; - } - - return partySize <= expected / 2 || partySize === expected; -} - -/** - * Returns the reason a party cannot queue the given type, or undefined when it - * can. Used for the JoinQueueError message surfaced to every lobby member. - */ -export function getPartySizeError( - type: e_match_types_enum, - partySize: number, -): string | undefined { - if (canPartyQueue(type, partySize)) { - return; - } - - const expected = ExpectedPlayers[type]; - - return `To join a ${type} match, your lobby must have ${expected / 2} or fewer players, or exactly ${expected} players. You have ${partySize}.`; -}