From 26ebba59ac9c0caf3a726ec98ac76678d5062a59 Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:15:24 +0300 Subject: [PATCH 1/8] Add rooms/leave endpoint to matchmaking API --- src/matchmaking/matchmaking.controller.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/matchmaking/matchmaking.controller.ts b/src/matchmaking/matchmaking.controller.ts index 8334cabc4..66ec346d0 100644 --- a/src/matchmaking/matchmaking.controller.ts +++ b/src/matchmaking/matchmaking.controller.ts @@ -91,6 +91,15 @@ export class MatchmakingController { return this.matchmakingService.joinInvite(roomId, user.player_id, body); } + /** + * Removes the authenticated player from their active matchmaking room. + */ + @Post('rooms/leave') + @UniformResponse() + async leaveRoom(@LoggedUser() user: User) { + return this.matchmakingService.leaveRoom(user.player_id); + } + /** * Starts matchmaking for a ready room owned by the authenticated player. */ From 418bc89cb1e42630eacb1571670e25e8b178bc75 Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:21:15 +0300 Subject: [PATCH 2/8] Add active matchmaking room validation for leave endpoint --- src/matchmaking/matchmaking.service.ts | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index e34a3d7ac..bf439e0e0 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -234,6 +234,39 @@ export class MatchmakingService { return [this.toInviteDto(updatedInvite), null]; } + /** + * Resolves and validates the authenticated player's active matchmaking room. + * The room state is updated by the leave-room transition implemented + * separately from this lookup and validation step. + */ + async leaveRoom(playerId: string): Promise> { + const [invite, inviteErrors] = + await this.getActiveInviteForPlayer(playerId); + if (inviteErrors) return [null, inviteErrors]; + + if (!invite.players.includes(playerId)) { + await this.redisService.delete(this.playerInviteKey(playerId)); + + return [null, this.playerHasNoActiveInviteError(playerId)]; + } + + if (invite.status === InviteStatus.MATCHED) { + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_ALLOWED, + field: 'status', + value: invite.status, + message: 'A matched room can no longer be left.', + }), + ], + ]; + } + + return [null, null]; + } + /** * Starts matchmaking for a ready room. Only the room owner may move a room * from READY into matchmaking. @@ -1614,6 +1647,43 @@ export class MatchmakingService { ]; } + /** + * Loads a player's active room through the reverse Redis index. A dangling + * index is removed before returning the same not-found error as a missing + * index. + */ + private async getActiveInviteForPlayer( + playerId: string, + ): Promise> { + const activeInviteId = await this.redisService.get( + this.playerInviteKey(playerId), + ); + + if (!activeInviteId) { + return [null, this.playerHasNoActiveInviteError(playerId)]; + } + + const [invite, inviteErrors] = await this.readInvite(activeInviteId); + if (inviteErrors) { + await this.redisService.delete(this.playerInviteKey(playerId)); + + return [null, this.playerHasNoActiveInviteError(playerId)]; + } + + return [invite, null]; + } + + private playerHasNoActiveInviteError(playerId: string) { + return [ + new ServiceError({ + reason: SEReason.NOT_FOUND, + field: 'playerId', + value: playerId, + message: 'Player does not have an active matchmaking room.', + }), + ]; + } + /** * Recomputes bot fillers and OPEN/READY status after create or join changes. */ From d918522b7388246a9424dd8cae43762dd5e599cc Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:33:55 +0300 Subject: [PATCH 3/8] Room update logic related to leaving room --- src/matchmaking/matchmaking.service.ts | 29 +++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index bf439e0e0..71400d514 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -235,9 +235,8 @@ export class MatchmakingService { } /** - * Resolves and validates the authenticated player's active matchmaking room. - * The room state is updated by the leave-room transition implemented - * separately from this lookup and validation step. + * Removes the authenticated player from their active matchmaking room and + * updates the room for the remaining players. */ async leaveRoom(playerId: string): Promise> { const [invite, inviteErrors] = @@ -264,6 +263,30 @@ export class MatchmakingService { ]; } + const remainingPlayerIds = invite.players.filter( + (invitePlayerId) => invitePlayerId !== playerId, + ); + if (remainingPlayerIds.length === 0) return [null, null]; + + if (invite.status === InviteStatus.QUEUED) { + await this.removeInviteFromQueue(invite); + } + + const updatedAt = new Date().toISOString(); + const updatedInvite = this.recalculateInvite({ + ...invite, + ownerPlayerId: + invite.ownerPlayerId === playerId + ? remainingPlayerIds[0] + : invite.ownerPlayerId, + players: remainingPlayerIds, + updatedAt, + }); + + await this.redisService.delete(this.playerInviteKey(playerId)); + await this.saveInvite(updatedInvite); + await this.notifyInvitePlayers(updatedInvite); + return [null, null]; } From fb207bfa78d1285c83de7fa4e792ba828dfeb205 Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:39:18 +0300 Subject: [PATCH 4/8] Leave room logic, when the last player leaves a matchmaking room --- src/matchmaking/matchmaking.service.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index 71400d514..7a3adfcd6 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -266,7 +266,18 @@ export class MatchmakingService { const remainingPlayerIds = invite.players.filter( (invitePlayerId) => invitePlayerId !== playerId, ); - if (remainingPlayerIds.length === 0) return [null, null]; + if (remainingPlayerIds.length === 0) { + if (invite.status === InviteStatus.QUEUED) { + await this.removeInviteFromQueue(invite); + } + + await Promise.all([ + this.redisService.delete(this.playerInviteKey(playerId)), + this.redisService.delete(this.inviteKey(invite.id)), + ]); + + return [null, null]; + } if (invite.status === InviteStatus.QUEUED) { await this.removeInviteFromQueue(invite); From d481a1d4a8f15c0967e92cbeaa59c3cdf9616fa0 Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:44:12 +0300 Subject: [PATCH 5/8] Remove overlapping implementation related to fetcing active room --- src/matchmaking/matchmaking.service.ts | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/matchmaking/matchmaking.service.ts b/src/matchmaking/matchmaking.service.ts index 7a3adfcd6..7a98a9665 100644 --- a/src/matchmaking/matchmaking.service.ts +++ b/src/matchmaking/matchmaking.service.ts @@ -1499,25 +1499,8 @@ export class MatchmakingService { private async getOwnedActiveInvite( senderPlayerId: string, ): Promise> { - const activeInviteId = await this.redisService.get( - this.playerInviteKey(senderPlayerId), - ); - - if (!activeInviteId) { - return [ - null, - [ - new ServiceError({ - reason: SEReason.NOT_FOUND, - field: 'playerId', - value: senderPlayerId, - message: 'Player does not have an active matchmaking room.', - }), - ], - ]; - } - - const [invite, inviteErrors] = await this.readInvite(activeInviteId); + const [invite, inviteErrors] = + await this.getActiveInviteForPlayer(senderPlayerId); if (inviteErrors) return [null, inviteErrors]; if (invite.ownerPlayerId !== senderPlayerId) { From 8647610b3d31da4dffbf15c9d3e16622fc404dfb Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:47:52 +0300 Subject: [PATCH 6/8] Add matchmaking leave room tests --- .../MatchmakingService/flow.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts index 7a96a5e01..ca51591ac 100644 --- a/src/__tests__/matchmaking/MatchmakingService/flow.test.ts +++ b/src/__tests__/matchmaking/MatchmakingService/flow.test.ts @@ -425,6 +425,224 @@ describe('MatchmakingService flow', () => { ); }); + it('removes a player from a room and notifies only the remaining players', async () => { + const { redis, notifier, service } = createService(); + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + allowBots: false, + }); + await service.joinInvite(invite.id, 'player-2', {} as any); + notifier.inviteUpdated.mockClear(); + + const [result, leaveErrors] = await service.leaveRoom('player-2'); + const [updatedInvite, readErrors] = await service.getInvite(invite.id); + + expect(result).toBeNull(); + expect(leaveErrors).toBeNull(); + expect(readErrors).toBeNull(); + expect(updatedInvite).toMatchObject({ + ownerPlayerId: 'player-1', + players: ['player-1'], + bots: [], + status: InviteStatus.OPEN, + }); + expect(redis.values.has('matchmaking:player-invite:player-2')).toBe(false); + expect(redis.values.has('matchmaking:player-invite:player-1')).toBe(true); + expect(notifier.inviteUpdated).toHaveBeenCalledTimes(1); + expect(notifier.inviteUpdated).toHaveBeenCalledWith( + 'player-1', + expect.objectContaining({ + id: invite.id, + ownerPlayerId: 'player-1', + status: InviteStatus.OPEN, + }), + ); + expect(notifier.inviteUpdated).not.toHaveBeenCalledWith( + 'player-2', + expect.anything(), + ); + }); + + it('transfers room ownership when the owner leaves', async () => { + const { notifier, service } = createService(); + const roomId = '665af23e5e982f0013aa334b'; + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.CUSTOM, + gameType, + roomId, + allowBots: false, + }); + await service.joinInvite(invite.id, 'player-2', { roomId }); + notifier.inviteUpdated.mockClear(); + + const [, leaveErrors] = await service.leaveRoom('player-1'); + const [updatedInvite] = await service.getInvite(invite.id); + + expect(leaveErrors).toBeNull(); + expect(updatedInvite).toMatchObject({ + ownerPlayerId: 'player-2', + players: ['player-2'], + }); + expect(notifier.inviteUpdated).toHaveBeenCalledTimes(1); + expect(notifier.inviteUpdated).toHaveBeenCalledWith( + 'player-2', + expect.objectContaining({ ownerPlayerId: 'player-2' }), + ); + }); + + it('deletes the room when its last player leaves', async () => { + const { redis, notifier, service } = createService(); + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + }); + notifier.inviteUpdated.mockClear(); + + const [, leaveErrors] = await service.leaveRoom('player-1'); + const [deletedInvite, readErrors] = await service.getInvite(invite.id); + + expect(leaveErrors).toBeNull(); + expect(deletedInvite).toBeNull(); + expect(readErrors).toHaveLength(1); + expect(readErrors[0].reason).toBe(SEReason.NOT_FOUND); + expect(redis.values.has(`matchmaking:invite:${invite.id}`)).toBe(false); + expect(redis.values.has('matchmaking:player-invite:player-1')).toBe(false); + expect(notifier.inviteUpdated).not.toHaveBeenCalled(); + }); + + it('removes a queued room from its queue and recalculates its status', async () => { + const { redis, service } = createService(); + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + allowBots: false, + }); + await service.joinInvite(invite.id, 'player-2', {} as any); + const [queuedInvite] = await service.startRoom(invite.id, 'player-1'); + + expect(queuedInvite.status).toBe(InviteStatus.QUEUED); + + const [, leaveErrors] = await service.leaveRoom('player-2'); + const [updatedInvite] = await service.getInvite(invite.id); + + expect(leaveErrors).toBeNull(); + expect(updatedInvite.status).toBe(InviteStatus.OPEN); + expect(updatedInvite.players).toEqual(['player-1']); + expect( + await redis.lrange(`matchmaking:queue:RANDOM:${gameType}`, 0, -1), + ).toEqual([]); + }); + + it('recalculates bot fillers after a player leaves', async () => { + const { service } = createService(); + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + allowBots: true, + }); + const [joinedInvite] = await service.joinInvite( + invite.id, + 'player-2', + {} as any, + ); + + expect(joinedInvite.bots).toHaveLength(0); + + await service.leaveRoom('player-2'); + const [updatedInvite] = await service.getInvite(invite.id); + + expect(updatedInvite.players).toEqual(['player-1']); + expect(updatedInvite.bots).toHaveLength(1); + expect(updatedInvite.status).toBe(InviteStatus.READY); + }); + + it('returns NOT_FOUND when the player has no active room or leaves twice', async () => { + const { service } = createService(); + + const [, initialErrors] = await service.leaveRoom('player-1'); + + expect(initialErrors).toHaveLength(1); + expect(initialErrors[0]).toMatchObject({ + reason: SEReason.NOT_FOUND, + field: 'playerId', + value: 'player-1', + }); + + await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + }); + await service.leaveRoom('player-1'); + const [, repeatedErrors] = await service.leaveRoom('player-1'); + + expect(repeatedErrors).toHaveLength(1); + expect(repeatedErrors[0].reason).toBe(SEReason.NOT_FOUND); + }); + + it('cleans up a dangling player room index', async () => { + const { redis, service } = createService(); + const playerInviteKey = 'matchmaking:player-invite:player-1'; + await redis.set(playerInviteKey, 'missing-invite'); + + const [, leaveErrors] = await service.leaveRoom('player-1'); + + expect(leaveErrors).toHaveLength(1); + expect(leaveErrors[0].reason).toBe(SEReason.NOT_FOUND); + expect(redis.values.has(playerInviteKey)).toBe(false); + }); + + it('rejects an inconsistent player room index and removes it', async () => { + const { redis, service } = createService(); + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.RANDOM, + gameType, + }); + const playerInviteKey = 'matchmaking:player-invite:player-2'; + await redis.set(playerInviteKey, invite.id); + + const [, leaveErrors] = await service.leaveRoom('player-2'); + const [unchangedInvite] = await service.getInvite(invite.id); + + expect(leaveErrors).toHaveLength(1); + expect(leaveErrors[0].reason).toBe(SEReason.NOT_FOUND); + expect(redis.values.has(playerInviteKey)).toBe(false); + expect(unchangedInvite.players).toEqual(['player-1']); + }); + + it('rejects leaving a room after it has been matched', async () => { + const { redis, service } = createService(); + const roomId = '665af23e5e982f0013aa334b'; + + const [invite] = await service.createInvite('player-1', { + matchType: MatchType.CUSTOM, + gameType, + roomId, + allowBots: true, + }); + const [matchedInvite] = await service.startRoom(invite.id, 'player-1'); + await redis.set('matchmaking:player-invite:player-1', invite.id); + + const [, leaveErrors] = await service.leaveRoom('player-1'); + const [unchangedInvite] = await service.getInvite(invite.id); + + expect(matchedInvite.status).toBe(InviteStatus.MATCHED); + expect(leaveErrors).toHaveLength(1); + expect(leaveErrors[0]).toMatchObject({ + reason: SEReason.NOT_ALLOWED, + field: 'status', + value: InviteStatus.MATCHED, + }); + expect(unchangedInvite.players).toEqual(['player-1']); + expect(unchangedInvite.status).toBe(InviteStatus.MATCHED); + }); + it('rejects room start from a player who does not own the room', async () => { const { service } = createService(); From f9a2c2dededd40d2c4932817a49152d935d43f7a Mon Sep 17 00:00:00 2001 From: tickBit Date: Tue, 29 Sep 2026 10:55:48 +0300 Subject: [PATCH 7/8] Update matchmaking API doc --- doc/matchmaking-api.md | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/doc/matchmaking-api.md b/doc/matchmaking-api.md index c3cfbe1df..a57bdb7cb 100644 --- a/doc/matchmaking-api.md +++ b/doc/matchmaking-api.md @@ -10,12 +10,14 @@ explicit notifications sent to players so they can join an existing room. 1. A player creates a room with `POST /matchmaking/rooms`. 2. Other players may join the room with `POST /matchmaking/rooms/{roomId}/join`. -3. When the room has a playable composition, the owner starts matchmaking with +3. Before a match is created, any room member may leave with + `POST /matchmaking/rooms/leave`. +4. When the room has a playable composition, the owner starts matchmaking with `POST /matchmaking/rooms/{roomId}/start`. -4. When matchmaking creates an active match, clients join the Photon Room. -5. Each real player confirms Photon readiness with +5. When matchmaking creates an active match, clients join the Photon Room. +6. Each real player confirms Photon readiness with `POST /matchmaking/matches/{matchId}/start`. -6. The match is finished with `POST /matchmaking/matches/{matchId}/finish`. +7. The match is finished with `POST /matchmaking/matches/{matchId}/finish`. Bots can fill missing player slots when `allowBots` is enabled. Bots are not expected to call HTTP endpoints. @@ -131,6 +133,37 @@ Adds the authenticated player to an existing room. Returns the updated `MatchmakingInviteDto`. +## Leave Room + +```http +POST /matchmaking/rooms/leave +``` + +Removes the authenticated player from their current matchmaking room. The +request does not include a room ID or a request body because a player can belong +to only one active matchmaking room. + +If other players remain in the room: + +- the departing player is removed from the room; +- ownership is transferred to the first remaining player when the owner leaves; +- bot fillers and the room's `OPEN` or `READY` status are recalculated; +- a queued room is removed from matchmaking before its updated state is saved; +- the remaining players receive a `ROOM_UPDATED` MQTT notification. + +If the departing player is the last player, the room and its matchmaking queue +entry are deleted. No MQTT notification is sent because no room members remain. + +The endpoint cannot be used after the room has reached `MATCHED` status. + +### Response + +Returns `204 No Content` when the player leaves successfully. + +Returns `404 NOT_FOUND` when the authenticated player does not have an active +matchmaking room. A stale player-to-room lookup is removed when detected and +produces the same response. + ## Start Matchmaking For Room ```http From 8a3a00f8d72f62324f33458fcb52de36e4c0440d Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 30 Sep 2026 10:13:29 +0300 Subject: [PATCH 8/8] Return explicitly 204 no content, after leaving instead of Nest's default 201 --- src/matchmaking/matchmaking.controller.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/matchmaking/matchmaking.controller.ts b/src/matchmaking/matchmaking.controller.ts index 66ec346d0..ad52d0615 100644 --- a/src/matchmaking/matchmaking.controller.ts +++ b/src/matchmaking/matchmaking.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, +} from '@nestjs/common'; import { User } from '../auth/user'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; @@ -95,6 +104,7 @@ export class MatchmakingController { * Removes the authenticated player from their active matchmaking room. */ @Post('rooms/leave') + @HttpCode(HttpStatus.NO_CONTENT) @UniformResponse() async leaveRoom(@LoggedUser() user: User) { return this.matchmakingService.leaveRoom(user.player_id);