Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 282 additions & 0 deletions src/matchmaking/matchmaking-lobby.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
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";
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<HasuraService>;

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",
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 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
// 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("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("explains the requirement", async () => {
await expect(
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([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(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.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(
"To join a Competitive match, your lobby must have 5 or fewer players, or exactly 10 players. You have 7.",
);
});

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("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("1 — every mode", async () => {
await expect(queueableTypes(1)).resolves.toEqual([
"Duel",
"Wingman",
"Competitive",
]);
});

it("2 — every mode (full Duel, half a Wingman)", async () => {
await expect(queueableTypes(2)).resolves.toEqual([
"Duel",
"Wingman",
"Competitive",
]);
});

it("3 — Competitive only", async () => {
await expect(queueableTypes(3)).resolves.toEqual(["Competitive"]);
});

it("4 — Wingman and Competitive", async () => {
await expect(queueableTypes(4)).resolves.toEqual([
"Wingman",
"Competitive",
]);
});

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 () => {
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");
});
});
58 changes: 35 additions & 23 deletions src/matchmaking/matchmaking-lobby.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getMatchmakingLobbyDetailsCacheKey,
} from "./utilities/cacheKeys";
import { JoinQueueError } from "./utilities/joinQueueError";
import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers";

@Injectable()
export class MatchmakingLobbyService {
Expand Down Expand Up @@ -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 = this.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) {
Expand Down Expand Up @@ -424,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,
Expand Down
Loading