From 7a1d152ef726ac5022a325f774b3d685e3dbf0b7 Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 16:49:19 +0300 Subject: [PATCH 1/9] Add gameType to matchmaking request --- src/matchmaking/dto/createMatchmakingInvite.dto.ts | 10 ++++++++++ src/matchmaking/dto/matchmakingInvite.dto.ts | 8 ++++++++ src/matchmaking/dto/matchmakingRoom.dto.ts | 8 ++++++++ src/matchmaking/matchmaking.service.ts | 3 +++ src/matchmaking/type/matchmakingInvite.type.ts | 1 + 5 files changed, 30 insertions(+) diff --git a/src/matchmaking/dto/createMatchmakingInvite.dto.ts b/src/matchmaking/dto/createMatchmakingInvite.dto.ts index cc9831d9c..5464d4a1c 100644 --- a/src/matchmaking/dto/createMatchmakingInvite.dto.ts +++ b/src/matchmaking/dto/createMatchmakingInvite.dto.ts @@ -3,6 +3,7 @@ import { IsBoolean, IsEnum, IsIn, + IsInt, IsMongoId, IsOptional, IsString, @@ -44,6 +45,15 @@ export class CreateMatchmakingInviteDto { @IsEnum(MatchType) matchType: MatchType; + /** + * Client-defined game mode played in the room. + * + * @example 1 + */ + @Type(() => Number) + @IsInt() + gameType: number; + /** * Existing custom lobby or room ID. Required for CUSTOM matches. * diff --git a/src/matchmaking/dto/matchmakingInvite.dto.ts b/src/matchmaking/dto/matchmakingInvite.dto.ts index 0821d81a5..d189b8986 100644 --- a/src/matchmaking/dto/matchmakingInvite.dto.ts +++ b/src/matchmaking/dto/matchmakingInvite.dto.ts @@ -45,6 +45,14 @@ export class MatchmakingInviteDto { @Expose() matchType: MatchType; + /** + * Client-defined game mode played in the room. + * + * @example 1 + */ + @Expose() + gameType: number; + /** * Current invite status. * diff --git a/src/matchmaking/dto/matchmakingRoom.dto.ts b/src/matchmaking/dto/matchmakingRoom.dto.ts index c127171c1..8c88c2ffa 100644 --- a/src/matchmaking/dto/matchmakingRoom.dto.ts +++ b/src/matchmaking/dto/matchmakingRoom.dto.ts @@ -21,6 +21,14 @@ export class MatchmakingRoomDto { @Expose() matchType: MatchType; + /** + * Client-defined game mode played in the room. + * + * @example 1 + */ + @Expose() + gameType: number; + /** * Current room status. * diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index 4f15d57e2..2e29503da 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -110,6 +110,7 @@ export class MatchmakingService { const invite: MatchmakingInvite = this.recalculateInvite({ id: new Types.ObjectId().toString(), matchType: body.matchType, + gameType: body.gameType, status: InviteStatus.OPEN, ownerPlayerId: playerId, clanId: @@ -1784,6 +1785,7 @@ export class MatchmakingService { return { id: invite.id, matchType: invite.matchType, + gameType: invite.gameType, status: invite.status, ownerPlayerId: invite.ownerPlayerId, clanId: invite.clanId, @@ -1810,6 +1812,7 @@ export class MatchmakingService { return { id: invite.id, matchType: invite.matchType, + gameType: invite.gameType, status: invite.status, ownerPlayerId: invite.ownerPlayerId, clanId: invite.clanId, diff --git a/src/matchmaking/type/matchmakingInvite.type.ts b/src/matchmaking/type/matchmakingInvite.type.ts index 52636d939..4de694496 100644 --- a/src/matchmaking/type/matchmakingInvite.type.ts +++ b/src/matchmaking/type/matchmakingInvite.type.ts @@ -5,6 +5,7 @@ import { MatchmakingParticipant } from './matchmakingParticipant.type'; export type MatchmakingInvite = { id: string; matchType: MatchType; + gameType: number; status: InviteStatus; ownerPlayerId: string; clanId?: string; From 62766867478fe2ee6721c8d760d16e73cbd3e53f Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 16:51:31 +0300 Subject: [PATCH 2/9] Update matchmaking tests for gameType in payload --- .../MatchmakingNotifier/notifications.test.ts | 1 + .../matchmaking/MatchmakingService/flow.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts b/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts index ccf7f79e7..34e8806e9 100644 --- a/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts +++ b/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts @@ -20,6 +20,7 @@ describe('MatchmakingNotifier notifications', () => { const room: MatchmakingRoomDto = { id: 'invite-1', matchType: MatchType.RANDOM, + gameType: 1, status: InviteStatus.QUEUED, ownerPlayerId: 'player-1', players: [{ playerId: 'player-1', name: 'Player 1', avatar: null }], diff --git a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts index e44866edf..6c079c9b1 100644 --- a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts +++ b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts @@ -193,21 +193,26 @@ const createActiveBattleStartMatch = ( }); describe('MatchmakingService flow', () => { + const gameType = 1; + it('creates an active RANDOM match after ready room owners start matchmaking', async () => { const { redis, notifier, service } = createService(); const [firstInvite, firstErrors] = await service.createInvite('player-1', { matchType: MatchType.RANDOM, + gameType, }); const [secondInvite, secondErrors] = await service.createInvite( 'player-2', { matchType: MatchType.RANDOM, + gameType, }, ); expect(firstErrors).toBeNull(); expect(secondErrors).toBeNull(); + expect(firstInvite.gameType).toBe(gameType); expect(firstInvite.status).toBe('READY'); expect(secondInvite.status).toBe('READY'); expect(getStoredMatches(redis)).toHaveLength(0); @@ -267,6 +272,7 @@ describe('MatchmakingService flow', () => { const [invite, errors] = await service.createInvite('player-1', { matchType: MatchType.CLAN, + gameType, }); expect(errors).toBeNull(); @@ -316,6 +322,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.RANDOM, + gameType, }); const [startedInvite, startErrors] = await service.startRoom( @@ -340,6 +347,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.CUSTOM, + gameType, roomId, allowBots: false, }); @@ -367,6 +375,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.CUSTOM, + gameType, roomId, }); @@ -391,6 +400,7 @@ describe('MatchmakingService flow', () => { const [invite, errors] = await service.createInvite('player-1', { matchType: MatchType.RANDOM, + gameType, automaticInvite: { type: MatchmakingAutoInviteType.PLAYER, playerId: 'player-2', @@ -422,6 +432,7 @@ describe('MatchmakingService flow', () => { const [invite, errors] = await service.createInvite('player-1', { matchType: MatchType.RANDOM, + gameType, automaticInvite: { type: MatchmakingAutoInviteType.CLAN, }, @@ -461,6 +472,7 @@ describe('MatchmakingService flow', () => { const [invite, errors] = await service.createInvite('player-1', { matchType: MatchType.CUSTOM, + gameType, roomId: '665af23e5e982f0013aa334b', }); @@ -470,6 +482,7 @@ describe('MatchmakingService flow', () => { expect.objectContaining({ id: invite.id, matchType: MatchType.CUSTOM, + gameType, ownerPlayerId: 'player-1', }), ); @@ -494,6 +507,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.RANDOM, + gameType, }); const [sentInvite, inviteErrors] = await service.sendPlayerInvite( 'player-2', @@ -543,6 +557,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.CLAN, + gameType, }); const [sentInvite, inviteErrors] = await service.sendClanInvite('player-1'); @@ -595,6 +610,7 @@ describe('MatchmakingService flow', () => { const [invite, createErrors] = await service.createInvite('player-1', { matchType: MatchType.CUSTOM, + gameType, roomId: '665af23e5e982f0013aa334b', allowBots: false, }); From fce4644831bd8f388ef26ea81788d726f9e4548a Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 16:57:05 +0300 Subject: [PATCH 3/9] Add gameType to related payloads and MQTT messages --- src/matchmaking/dto/matchmakingMatch.dto.ts | 8 ++++++++ src/matchmaking/dto/matchmakingMqttMatch.dto.ts | 8 ++++++++ src/matchmaking/dto/matchmakingRoomInvite.dto.ts | 8 ++++++++ src/matchmaking/matchmaking.service.ts | 6 ++++++ src/matchmaking/type/activeMatch.type.ts | 1 + 5 files changed, 31 insertions(+) diff --git a/src/matchmaking/dto/matchmakingMatch.dto.ts b/src/matchmaking/dto/matchmakingMatch.dto.ts index 6f5778606..18e029202 100644 --- a/src/matchmaking/dto/matchmakingMatch.dto.ts +++ b/src/matchmaking/dto/matchmakingMatch.dto.ts @@ -106,6 +106,14 @@ export class MatchmakingMatchDto { @Expose() matchType: MatchType; + /** + * Client-defined game mode played in the match. + * + * @example 1 + */ + @Expose() + gameType: number; + /** * Current match status. * diff --git a/src/matchmaking/dto/matchmakingMqttMatch.dto.ts b/src/matchmaking/dto/matchmakingMqttMatch.dto.ts index 5fa1d0d2a..8286a6dc1 100644 --- a/src/matchmaking/dto/matchmakingMqttMatch.dto.ts +++ b/src/matchmaking/dto/matchmakingMqttMatch.dto.ts @@ -57,6 +57,14 @@ export class MatchmakingMqttMatchDto { @Expose() matchType: MatchType; + /** + * Client-defined game mode played in the match. + * + * @example 1 + */ + @Expose() + gameType: number; + /** * Current match status. * diff --git a/src/matchmaking/dto/matchmakingRoomInvite.dto.ts b/src/matchmaking/dto/matchmakingRoomInvite.dto.ts index 23a6e7e7e..2963eb943 100644 --- a/src/matchmaking/dto/matchmakingRoomInvite.dto.ts +++ b/src/matchmaking/dto/matchmakingRoomInvite.dto.ts @@ -20,6 +20,14 @@ export class MatchmakingRoomInviteDto { @Expose() matchType: MatchType; + /** + * Client-defined game mode played in the room. + * + * @example 1 + */ + @Expose() + gameType: number; + /** * Current room status when the invite was sent. * diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index 2e29503da..890b15391 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -846,6 +846,7 @@ export class MatchmakingService { const match: ActiveMatch = { id: new Types.ObjectId().toString(), matchType, + gameType: firstInvite.gameType, status: MatchStatus.ACTIVE, teamSize: firstInvite.teamSize, teams: [ @@ -868,6 +869,7 @@ export class MatchmakingService { const match: ActiveMatch = { id: new Types.ObjectId().toString(), matchType: MatchType.CLAN, + gameType: invite.gameType, status: MatchStatus.ACTIVE, teamSize: invite.teamSize, teams: [ @@ -892,6 +894,7 @@ export class MatchmakingService { const match: ActiveMatch = { id: new Types.ObjectId().toString(), matchType: MatchType.CUSTOM, + gameType: invite.gameType, status: MatchStatus.ACTIVE, teamSize: invite.teamSize, teams, @@ -1844,6 +1847,7 @@ export class MatchmakingService { return { id: invite.id, matchType: invite.matchType, + gameType: invite.gameType, status: invite.status, ownerPlayer: this.getMappedMqttPlayer(playerMap, invite.ownerPlayerId), senderPlayer: this.getMappedMqttPlayer(playerMap, senderPlayerId), @@ -1860,6 +1864,7 @@ export class MatchmakingService { return { id: match.id, matchType: match.matchType, + gameType: match.gameType, status: match.status, teamSize: match.teamSize, teams: match.teams.map((team) => this.toTeamDto(team)), @@ -1882,6 +1887,7 @@ export class MatchmakingService { return { id: match.id, matchType: match.matchType, + gameType: match.gameType, status: match.status, teamSize: match.teamSize, teams: match.teams.map((team) => this.toMqttTeamDto(team, playerMap)), diff --git a/src/matchmaking/type/activeMatch.type.ts b/src/matchmaking/type/activeMatch.type.ts index 292ddebfb..395b7dbb7 100644 --- a/src/matchmaking/type/activeMatch.type.ts +++ b/src/matchmaking/type/activeMatch.type.ts @@ -10,6 +10,7 @@ export type MatchResult = { export type ActiveMatch = { id: string; matchType: MatchType; + gameType: number; status: MatchStatus; teamSize: 1 | 2; teams: [MatchmakingTeam, MatchmakingTeam]; From 8f9a04ac25606e838608767d7149b8ca7b3a1925 Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 16:58:10 +0300 Subject: [PATCH 4/9] Update matchmaking tests --- .../MatchmakingNotifier/notifications.test.ts | 2 ++ .../matchmaking/MatchmakingService/flow.test.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts b/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts index 34e8806e9..8560eea9a 100644 --- a/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts +++ b/src/__tests__/matchmaking/MatchmakingNotifier/notifications.test.ts @@ -41,6 +41,7 @@ describe('MatchmakingNotifier notifications', () => { const match: MatchmakingMqttMatchDto = { id: 'match-1', matchType: MatchType.RANDOM, + gameType: 1, status: MatchStatus.ACTIVE, teamSize: 2, teams: [ @@ -61,6 +62,7 @@ describe('MatchmakingNotifier notifications', () => { const roomInvite: MatchmakingRoomInviteDto = { id: 'invite-1', matchType: MatchType.RANDOM, + gameType: 1, status: InviteStatus.OPEN, ownerPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, senderPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, diff --git a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts index 6c079c9b1..da32b78bd 100644 --- a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts +++ b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts @@ -176,6 +176,7 @@ const createActiveBattleStartMatch = ( ): ActiveMatch => ({ id: 'match-battle-start', matchType: MatchType.RANDOM, + gameType: 1, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ @@ -240,6 +241,7 @@ describe('MatchmakingService flow', () => { expect(matches[0]).toMatchObject({ id: startedInvite.matchId, matchType: MatchType.RANDOM, + gameType, status: MatchStatus.ACTIVE, teamSize: 2, }); @@ -256,7 +258,7 @@ describe('MatchmakingService flow', () => { expect(redis.values.has('matchmaking:player-invite:player-2')).toBe(false); expect(notifier.matchFound).toHaveBeenCalledWith( 'player-1', - expect.objectContaining({ id: startedInvite.matchId }), + expect.objectContaining({ id: startedInvite.matchId, gameType }), ); expect(notifier.matchFound).toHaveBeenCalledWith( 'player-2', @@ -414,6 +416,7 @@ describe('MatchmakingService flow', () => { expect.objectContaining({ id: invite.id, matchType: MatchType.RANDOM, + gameType, status: invite.status, ownerPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, senderPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, @@ -523,6 +526,7 @@ describe('MatchmakingService flow', () => { expect.objectContaining({ id: invite.id, matchType: MatchType.RANDOM, + gameType, status: invite.status, ownerPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, senderPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, @@ -1039,6 +1043,7 @@ describe('MatchmakingService flow', () => { const match: ActiveMatch = { id: 'match-1', matchType: MatchType.RANDOM, + gameType, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ @@ -1120,6 +1125,7 @@ describe('MatchmakingService flow', () => { const match: ActiveMatch = { id: 'match-2', matchType: MatchType.CLAN, + gameType, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ @@ -1189,6 +1195,7 @@ describe('MatchmakingService flow', () => { const match: ActiveMatch = { id: 'match-daily-task-error', matchType: MatchType.RANDOM, + gameType, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ @@ -1249,6 +1256,7 @@ describe('MatchmakingService flow', () => { const match: ActiveMatch = { id: 'match-player-error', matchType: MatchType.RANDOM, + gameType, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ @@ -1303,6 +1311,7 @@ describe('MatchmakingService flow', () => { const match: ActiveMatch = { id: 'match-clan-error', matchType: MatchType.CLAN, + gameType, status: MatchStatus.ACTIVE, teamSize: 1, teams: [ From e790d182bfdc0e77784cdf7b3b1b3beb3c940a5a Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 17:03:45 +0300 Subject: [PATCH 5/9] Separate Redis queues with matchType and gameType --- src/matchmaking/matchmaking.service.ts | 46 +++++++++++++++++--------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index 890b15391..e34a3d7ac 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -656,15 +656,16 @@ export class MatchmakingService { /** * Routes READY invites into their mode-specific next step. * - * RANDOM tries to pair any two ready teams, CLAN searches for another clan and - * schedules a timeout fallback, and CUSTOM starts directly from room settings. + * RANDOM tries to pair ready teams playing the same game type, CLAN searches + * for another clan playing the same game type and schedules a timeout + * fallback, and CUSTOM starts directly from room settings. */ private async processReadyInvite(invite: MatchmakingInvite) { if (invite.status !== InviteStatus.READY) return invite; if (invite.matchType === MatchType.RANDOM) { const queuedInvite = await this.enqueueReadyInvite(invite); - const match = await this.tryCreateRandomMatch(); + const match = await this.tryCreateRandomMatch(invite.gameType); if (!match) return queuedInvite; const [processedInvite] = await this.readInvite(invite.id); @@ -703,11 +704,11 @@ export class MatchmakingService { } /** - * Stores a READY invite in the Redis list for its match type if it is not - * already queued. + * Stores a READY invite in the Redis list for its matchmaking mode and game + * type if it is not already queued. */ private async enqueueReadyInvite(invite: MatchmakingInvite) { - const queueKey = this.queueKey(invite.matchType); + const queueKey = this.queueKey(invite.matchType, invite.gameType); const queuedInvite: MatchmakingInvite = { ...invite, status: InviteStatus.QUEUED, @@ -726,10 +727,14 @@ export class MatchmakingService { } /** - * Pairs the first two valid RANDOM invites from the queue into an active match. + * Pairs the first two valid RANDOM invites for one game type into an active + * match. */ - private async tryCreateRandomMatch() { - const queuedInvites = await this.getValidQueuedInvites(MatchType.RANDOM); + private async tryCreateRandomMatch(gameType: number) { + const queuedInvites = await this.getValidQueuedInvites( + MatchType.RANDOM, + gameType, + ); if (queuedInvites.length < 2) return null; const [firstInvite, secondInvite] = queuedInvites; @@ -752,7 +757,10 @@ export class MatchmakingService { * when one is available. */ private async tryCreateClanMatch(invite: MatchmakingInvite) { - const queuedInvites = await this.getValidQueuedInvites(MatchType.CLAN); + const queuedInvites = await this.getValidQueuedInvites( + MatchType.CLAN, + invite.gameType, + ); const opponent = queuedInvites.find( (candidate) => candidate.id !== invite.id && @@ -807,8 +815,8 @@ export class MatchmakingService { /** * Loads queued invite ids, drops stale entries, and returns still-READY invites. */ - private async getValidQueuedInvites(matchType: MatchType) { - const queueKey = this.queueKey(matchType); + private async getValidQueuedInvites(matchType: MatchType, gameType: number) { + const queueKey = this.queueKey(matchType, gameType); const queuedInviteIds = Array.from( new Set(await this.redisService.lrange(queueKey, 0, -1)), ); @@ -822,7 +830,9 @@ export class MatchmakingService { } const isValid = - invite.matchType === matchType && invite.status === InviteStatus.QUEUED; + invite.matchType === matchType && + invite.gameType === gameType && + invite.status === InviteStatus.QUEUED; if (!isValid) { await this.redisService.lrem(queueKey, 0, inviteId); continue; @@ -986,7 +996,11 @@ export class MatchmakingService { } private async removeInviteFromQueue(invite: MatchmakingInvite) { - await this.redisService.lrem(this.queueKey(invite.matchType), 0, invite.id); + await this.redisService.lrem( + this.queueKey(invite.matchType, invite.gameType), + 0, + invite.id, + ); } /** @@ -1753,8 +1767,8 @@ export class MatchmakingService { return `${this.PLAYER_INVITE_KEY_PREFIX}:${playerId}`; } - private queueKey(matchType: MatchType) { - return `${this.QUEUE_KEY_PREFIX}:${matchType}`; + private queueKey(matchType: MatchType, gameType: number) { + return `${this.QUEUE_KEY_PREFIX}:${matchType}:${gameType}`; } private matchKey(matchId: string) { From 007ba569a6dcc77bf11b7aad64e0f641621bfde2 Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 17:04:34 +0300 Subject: [PATCH 6/9] Update matchmaking flow test --- .../MatchmakingService/flow.test.ts | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts index da32b78bd..7caab4f21 100644 --- a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts +++ b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts @@ -1,6 +1,7 @@ import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum'; import { MqttNotificationType } from '../../../common/service/notificator/enum/MqttNotificationType.enum'; import { MatchmakingAutoInviteType } from '../../../matchmaking/dto/createMatchmakingInvite.dto'; +import { InviteStatus } from '../../../matchmaking/enum/inviteStatus.enum'; import { MatchStatus } from '../../../matchmaking/enum/matchStatus.enum'; import { MatchType } from '../../../matchmaking/enum/matchType.enum'; import { TeamSide } from '../../../matchmaking/enum/teamSide.enum'; @@ -253,7 +254,9 @@ describe('MatchmakingService flow', () => { { playerId: 'player-2', isBot: false }, expect.objectContaining({ isBot: true }), ]); - expect(await redis.lrange('matchmaking:queue:RANDOM', 0, -1)).toEqual([]); + expect( + await redis.lrange(`matchmaking:queue:RANDOM:${gameType}`, 0, -1), + ).toEqual([]); expect(redis.values.has('matchmaking:player-invite:player-1')).toBe(false); expect(redis.values.has('matchmaking:player-invite:player-2')).toBe(false); expect(notifier.matchFound).toHaveBeenCalledWith( @@ -267,6 +270,42 @@ describe('MatchmakingService flow', () => { expect(notifier.matchEvent).not.toHaveBeenCalled(); }); + it('keeps RANDOM rooms with different game types in separate queues', async () => { + const { redis, notifier, service } = createService(); + const otherGameType = 2; + + const [firstInvite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + }); + const [secondInvite] = await service.createInvite('player-2', { + matchType: MatchType.RANDOM, + gameType: otherGameType, + }); + + const [firstQueued, firstErrors] = await service.startRoom( + firstInvite.id, + 'player-1', + ); + const [secondQueued, secondErrors] = await service.startRoom( + secondInvite.id, + 'player-2', + ); + + expect(firstErrors).toBeNull(); + expect(secondErrors).toBeNull(); + expect(firstQueued.status).toBe(InviteStatus.QUEUED); + expect(secondQueued.status).toBe(InviteStatus.QUEUED); + expect(getStoredMatches(redis)).toHaveLength(0); + expect( + await redis.lrange(`matchmaking:queue:RANDOM:${gameType}`, 0, -1), + ).toEqual([firstInvite.id]); + expect( + await redis.lrange(`matchmaking:queue:RANDOM:${otherGameType}`, 0, -1), + ).toEqual([secondInvite.id]); + expect(notifier.matchFound).not.toHaveBeenCalled(); + }); + it('creates a CLAN bot opponent when the opponent timeout expires', async () => { const { redis, notifier, queue, service } = createService({ 'player-1': 'clan-1', @@ -311,7 +350,9 @@ describe('MatchmakingService flow', () => { expect.objectContaining({ isBot: true }), expect.objectContaining({ isBot: true }), ]); - expect(await redis.lrange('matchmaking:queue:CLAN', 0, -1)).toEqual([]); + expect( + await redis.lrange(`matchmaking:queue:CLAN:${gameType}`, 0, -1), + ).toEqual([]); expect(notifier.matchFound).toHaveBeenCalledWith( 'player-1', expect.objectContaining({ id: matchedInvite.matchId }), @@ -319,6 +360,37 @@ describe('MatchmakingService flow', () => { expect(notifier.matchEvent).not.toHaveBeenCalled(); }); + it('keeps CLAN rooms with different game types in separate queues', async () => { + const { redis, notifier, service } = createService({ + 'player-1': 'clan-1', + 'player-2': 'clan-2', + }); + const otherGameType = 2; + + const [firstInvite] = await service.createInvite('player-1', { + matchType: MatchType.CLAN, + gameType, + }); + const [secondInvite] = await service.createInvite('player-2', { + matchType: MatchType.CLAN, + gameType: otherGameType, + }); + + const [firstQueued] = await service.startRoom(firstInvite.id, 'player-1'); + const [secondQueued] = await service.startRoom(secondInvite.id, 'player-2'); + + expect(firstQueued.status).toBe(InviteStatus.QUEUED); + expect(secondQueued.status).toBe(InviteStatus.QUEUED); + expect(getStoredMatches(redis)).toHaveLength(0); + expect( + await redis.lrange(`matchmaking:queue:CLAN:${gameType}`, 0, -1), + ).toEqual([firstInvite.id]); + expect( + await redis.lrange(`matchmaking:queue:CLAN:${otherGameType}`, 0, -1), + ).toEqual([secondInvite.id]); + expect(notifier.matchFound).not.toHaveBeenCalled(); + }); + it('rejects room start from a player who does not own the room', async () => { const { service } = createService(); From dab2d3606e5debe250bb8eb9bcc368f080fe9afc Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 17:08:05 +0300 Subject: [PATCH 7/9] Improve matchmaking test coverage --- .../MatchmakingService/flow.test.ts | 34 +++++++++++++++++++ .../dto/createMatchmakingInviteDto.test.ts | 31 +++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/__tests__/matchmaking/dto/createMatchmakingInviteDto.test.ts diff --git a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts index 7caab4f21..7a96a5e01 100644 --- a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts +++ b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts @@ -342,6 +342,7 @@ describe('MatchmakingService flow', () => { expect(matches[0]).toMatchObject({ id: matchedInvite.matchId, matchType: MatchType.CLAN, + gameType, status: MatchStatus.ACTIVE, teamSize: 2, }); @@ -391,6 +392,39 @@ describe('MatchmakingService flow', () => { expect(notifier.matchFound).not.toHaveBeenCalled(); }); + it('copies the room game type to a CUSTOM match', async () => { + const { redis, notifier, service } = createService(); + + const [invite, createErrors] = await service.createInvite('player-1', { + matchType: MatchType.CUSTOM, + gameType, + roomId: '665af23e5e982f0013aa334b', + allowBots: true, + }); + const [matchedInvite, startErrors] = await service.startRoom( + invite.id, + 'player-1', + ); + + expect(createErrors).toBeNull(); + expect(startErrors).toBeNull(); + expect(matchedInvite.status).toBe(InviteStatus.MATCHED); + expect(getStoredMatches(redis)).toEqual([ + expect.objectContaining({ + id: matchedInvite.matchId, + matchType: MatchType.CUSTOM, + gameType, + }), + ]); + expect(notifier.matchFound).toHaveBeenCalledWith( + 'player-1', + expect.objectContaining({ + id: matchedInvite.matchId, + gameType, + }), + ); + }); + it('rejects room start from a player who does not own the room', async () => { const { service } = createService(); diff --git a/src/__tests__/matchmaking/dto/createMatchmakingInviteDto.test.ts b/src/__tests__/matchmaking/dto/createMatchmakingInviteDto.test.ts new file mode 100644 index 000000000..08283d307 --- /dev/null +++ b/src/__tests__/matchmaking/dto/createMatchmakingInviteDto.test.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { validate } from 'class-validator'; +import { CreateMatchmakingInviteDto } from '../../../matchmaking/dto/createMatchmakingInvite.dto'; +import { MatchType } from '../../../matchmaking/enum/matchType.enum'; + +describe('CreateMatchmakingInviteDto', () => { + const createDto = (gameType: unknown) => + Object.assign(new CreateMatchmakingInviteDto(), { + matchType: MatchType.RANDOM, + gameType, + }); + + it('accepts an integer gameType', async () => { + await expect(validate(createDto(1))).resolves.toHaveLength(0); + }); + + it.each([ + ['missing', undefined], + ['string', '1'], + ['decimal', 1.5], + ['null', null], + ])('rejects a %s gameType', async (_case, gameType) => { + const errors = await validate(createDto(gameType)); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('gameType'); + expect(errors[0].constraints?.isInt).toBe( + 'gameType must be an integer number', + ); + }); +}); From 2da23d3389620b91d4ac001eed995b3e961d34d7 Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 17:45:38 +0300 Subject: [PATCH 8/9] Add missing gameType to MQTT contract test --- .../service/notificator/MqttNotification.contract.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts b/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts index 0c3dc3e34..c5c2a69fe 100644 --- a/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts +++ b/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts @@ -69,6 +69,7 @@ describe('MQTT notification contract', () => { const invite = { id: 'invite-1', matchType: MatchType.RANDOM, + gameType: 1, status: InviteStatus.QUEUED, ownerPlayerId: 'player-1', players: [{ playerId: 'player-1', name: 'Player 1', avatar: null }], @@ -81,6 +82,7 @@ describe('MQTT notification contract', () => { const match = { id: 'match-1', matchType: MatchType.RANDOM, + gameType: 1, status: MatchStatus.ACTIVE, teamSize: 2 as const, teams: [ @@ -110,6 +112,7 @@ describe('MQTT notification contract', () => { { id: invite.id, matchType: invite.matchType, + gameType: invite.gameType, status: invite.status, ownerPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, senderPlayer: { playerId: 'player-1', name: 'Player 1', avatar: null }, From ed71a717d5583e905a4d547169d9a84f75fd0a23 Mon Sep 17 00:00:00 2001 From: tickBit Date: Mon, 28 Sep 2026 18:29:58 +0300 Subject: [PATCH 9/9] Prettier/linter changes to get stuck CI going --- .../dailyTasks/formAnInnerConnection.test.ts | 42 ++++++++++++------- src/dailyTasks/dailyTasks.service.ts | 2 +- src/dailyTasks/taskGenerator.service.ts | 10 ++--- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/__tests__/dailyTasks/formAnInnerConnection.test.ts b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts index d3fa36f55..c409ea8f4 100644 --- a/src/__tests__/dailyTasks/formAnInnerConnection.test.ts +++ b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts @@ -56,7 +56,9 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { }, { provide: DailyTaskProgressService, - useValue: { handleProgress: jest.fn().mockResolvedValue([true, null]) }, + useValue: { + handleProgress: jest.fn().mockResolvedValue([true, null]), + }, }, ], }).compile(); @@ -114,8 +116,12 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { type: ServerTaskName.FORM_AN_INNER_CONNECTION, }; - jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); - jest.spyOn(service['basicService'], 'updateOne').mockResolvedValue([true, null]); + jest + .spyOn(service['basicService'], 'readOne') + .mockResolvedValue([mockTask as any, null]); + jest + .spyOn(service['basicService'], 'updateOne') + .mockResolvedValue([true, null]); const [res, err] = await service.handleDailyTaskEvent({ playerId: 'player-1', @@ -141,7 +147,9 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { type: ServerTaskName.FORM_AN_INNER_CONNECTION, }; - jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); + jest + .spyOn(service['basicService'], 'readOne') + .mockResolvedValue([mockTask as any, null]); jest.spyOn(service, 'deleteTask').mockResolvedValue([true as any, null]); const [res, err] = await service.handleDailyTaskEvent({ @@ -166,10 +174,12 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { }); it('returns error when task is not found or owned by another clan', async () => { - jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([ - null, - [{ reason: 'NOT_FOUND', message: 'Task not found' }] as any, - ]); + jest + .spyOn(service['basicService'], 'readOne') + .mockResolvedValue([ + null, + [{ reason: 'NOT_FOUND', message: 'Task not found' }] as any, + ]); const [res, err] = await service.handleDailyTaskEvent({ playerId: 'player-1', @@ -194,11 +204,15 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { type: ServerTaskName.FORM_AN_INNER_CONNECTION, }; - jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); - jest.spyOn(service['basicService'], 'updateOne').mockResolvedValue([ - null, - [{ reason: 'DATABASE_ERROR', message: 'Write failed' }] as any, - ]); + jest + .spyOn(service['basicService'], 'readOne') + .mockResolvedValue([mockTask as any, null]); + jest + .spyOn(service['basicService'], 'updateOne') + .mockResolvedValue([ + null, + [{ reason: 'DATABASE_ERROR', message: 'Write failed' }] as any, + ]); const [res, err] = await service.handleDailyTaskEvent({ playerId: 'player-1', @@ -212,4 +226,4 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { expect(err).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/src/dailyTasks/dailyTasks.service.ts b/src/dailyTasks/dailyTasks.service.ts index fc88d51c7..2e7c2bcaa 100644 --- a/src/dailyTasks/dailyTasks.service.ts +++ b/src/dailyTasks/dailyTasks.service.ts @@ -612,4 +612,4 @@ export class DailyTasksService { async getRewards() { return { prizePool: prizePool }; } -} \ No newline at end of file +} diff --git a/src/dailyTasks/taskGenerator.service.ts b/src/dailyTasks/taskGenerator.service.ts index 74940f64a..39695318e 100644 --- a/src/dailyTasks/taskGenerator.service.ts +++ b/src/dailyTasks/taskGenerator.service.ts @@ -47,11 +47,11 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ }), }, { - type: ServerTaskName.FORM_AN_INNER_CONNECTION, - createAmount: () => 1, - createTitle: () => ({ - fi: 'Avaa klaanin chat ja lähetä viesti. Huomaa, miltä tuntuu aloittaa vuorovaikutus. Opit, että viestintä pelissä rakentaa suhteita ja merkityksiä, ei vain siirrä tietoa.', - }), + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + createAmount: () => 1, + createTitle: () => ({ + fi: 'Avaa klaanin chat ja lähetä viesti. Huomaa, miltä tuntuu aloittaa vuorovaikutus. Opit, että viestintä pelissä rakentaa suhteita ja merkityksiä, ei vain siirrä tietoa.', + }), }, { type: ServerTaskName.PLAY_WITH_EMOTIONS,