diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts index b932d9e8f..fbaa1c46d 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts @@ -70,22 +70,29 @@ describe('OnlinePlayersService.addPlayerOnline() test suite', () => { .setStatus(OnlinePlayerStatus.BATTLE_WAIT) .build(); const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${player1._id}`; - const expectedPayload = JSON.stringify( - onlinePlayerBuilder - .setId(playerToAdd.player_id) - .setName(player1.name) - .setStatus(playerToAdd.status) - .setAdditional({ queueNumber: 0 }) - .build(), - ); + const builtPlayer = onlinePlayerBuilder + .setId(playerToAdd.player_id) + .setName(player1.name) + .setStatus(playerToAdd.status) + .setAdditional({ queueNumber: 0 }) + .build(); + + (builtPlayer as any).client_version = '0.0.0'; + + const expectedPayload = JSON.stringify(builtPlayer); const redisSet = jest.spyOn(redisService, 'set'); await service.addPlayerOnline({ player_id: player1._id, status: OnlinePlayerStatus.BATTLE_WAIT, + client_version: '0.0.0', }); - expect(redisSet).toHaveBeenCalledWith(expectedKey, expectedPayload, 90); + const actualPayloadSent = redisSet.mock.calls[2][1]; + + expect(JSON.parse(actualPayloadSent)).toEqual(JSON.parse(expectedPayload)); + + expect(redisSet).toHaveBeenCalledWith(expectedKey, expect.any(String), 90); }); }); diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts index 21c8e6876..4c597e001 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts @@ -37,6 +37,7 @@ describe('OnlinePlayersService.getOnlinePlayerById() test suite', () => { _id: player1._id, name: player1.name, status: OnlinePlayerStatus.UI, + client_version: '0.0.0', }; jest .spyOn(redisService, 'get') diff --git a/src/common/service/basicService/SEReason.ts b/src/common/service/basicService/SEReason.ts index 6b9f3c335..11c45c05e 100644 --- a/src/common/service/basicService/SEReason.ts +++ b/src/common/service/basicService/SEReason.ts @@ -68,6 +68,11 @@ export enum SEReason { */ VALIDATION = 'VALIDATION', + /** + * Provided game versions are incompatible with each other + */ + VERSION_MISMATCH = 'VERSION_MISMATCH', + /** * The error is unexpected */ diff --git a/src/onlinePlayers/battleQueue/battleQueue.controller.ts b/src/onlinePlayers/battleQueue/battleQueue.controller.ts index e876591ae..37de80c17 100644 --- a/src/onlinePlayers/battleQueue/battleQueue.controller.ts +++ b/src/onlinePlayers/battleQueue/battleQueue.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { BattleQueueService } from './battleQueue.service'; import OnlinePlayerDto from '../dto/onlinePlayer.dto'; import { UniformResponse } from '../../common/decorator/response/UniformResponse'; @@ -29,9 +29,12 @@ export class BattleQueueController { }) @Get() @UniformResponse(null, OnlinePlayerDto) - async getBattleQueue() { + async getBattleQueue(@Query('version') clientVersion: string) { const queuePlayers = await this.onlinePlayersService.getOnlinePlayers({ - filter: { status: [OnlinePlayerStatus.BATTLE_WAIT] }, + filter: { + status: [OnlinePlayerStatus.BATTLE_WAIT], + client_version: clientVersion, + }, }); return this.service.sortPlayersByQueueNumber(queuePlayers); } diff --git a/src/onlinePlayers/battleQueue/battleQueue.service.ts b/src/onlinePlayers/battleQueue/battleQueue.service.ts index 6bccc0c25..d1fe70b12 100644 --- a/src/onlinePlayers/battleQueue/battleQueue.service.ts +++ b/src/onlinePlayers/battleQueue/battleQueue.service.ts @@ -62,6 +62,24 @@ export class BattleQueueService { if (filterErrors) return [null, filterErrors]; + const firstVersion = validPlayers[0].client_version; + const hasVersionMismatch = validPlayers.some( + (p) => p.client_version !== firstVersion, + ); + + if (hasVersionMismatch) { + return [ + null, + [ + new ServiceError({ + reason: SEReason.VERSION_MISMATCH, + message: + 'Version mismatch: Players in queue have different client versions', + }), + ], + ]; + } + const queueNumbers = validPlayers.map((p) => p.additional.queueNumber); const queueMax = this.queueNumberMax + 1; diff --git a/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts b/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts index 16ee38f8b..b5f9af4e0 100644 --- a/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts +++ b/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts @@ -1,5 +1,5 @@ import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum, IsOptional, IsString, IsNotEmpty } from 'class-validator'; export default class InformPlayerIsOnlineDto { /** @@ -11,4 +11,13 @@ export default class InformPlayerIsOnlineDto { @IsOptional() @IsEnum(OnlinePlayerStatus) status?: OnlinePlayerStatus; + + /** + * The version of the game client. + * Required to ensure version-compatible matchmaking. + * @example "0.6.2" + */ + @IsString() + @IsNotEmpty() + client_version: string; } diff --git a/src/onlinePlayers/onlinePlayers.controller.ts b/src/onlinePlayers/onlinePlayers.controller.ts index 0fb8d3e71..ec4e0090c 100644 --- a/src/onlinePlayers/onlinePlayers.controller.ts +++ b/src/onlinePlayers/onlinePlayers.controller.ts @@ -31,6 +31,7 @@ export class OnlinePlayersController { return this.onlinePlayersService.addPlayerOnline({ player_id: user.player_id, status: body.status, + client_version: body.client_version, }); } diff --git a/src/onlinePlayers/onlinePlayers.service.ts b/src/onlinePlayers/onlinePlayers.service.ts index 3ff36ead6..ba8669049 100644 --- a/src/onlinePlayers/onlinePlayers.service.ts +++ b/src/onlinePlayers/onlinePlayers.service.ts @@ -35,7 +35,7 @@ export class OnlinePlayersService { async addPlayerOnline( playerInfo: AddOnlinePlayer, ): Promise> { - const { player_id, status } = playerInfo; + const { player_id, status, client_version } = playerInfo; const [player, errors] = await this.playerService.getPlayerById(player_id); if (errors) return [null, errors]; @@ -44,6 +44,7 @@ export class OnlinePlayersService { _id: player_id, name: player.name, status: status ?? OnlinePlayerStatus.UI, + client_version: client_version, }; if (status === OnlinePlayerStatus.BATTLE_WAIT) { @@ -69,7 +70,10 @@ export class OnlinePlayersService { * @returns Array of OnlinePlayers or empty array if nothing found */ async getOnlinePlayers(options?: { - filter?: { status?: OnlinePlayerStatus[] }; + filter?: { + status?: OnlinePlayerStatus[]; + client_version?: string; + }; }): Promise { const players = await this.redisService.getValuesByKeyPattern( `${this.ONLINE_PLAYERS_KEY}:*`, @@ -82,10 +86,16 @@ export class OnlinePlayersService { ) as OnlinePlayer[]; //TODO: Remove it after there are no versions anymore that uses old implementation of saving online players - const filteredPlayers = onlinePlayers.filter( + let filteredPlayers = onlinePlayers.filter( (player) => typeof player !== 'string' && typeof player !== 'number', ); + if (options?.filter?.client_version) { + filteredPlayers = filteredPlayers.filter( + (p) => p.client_version === options.filter.client_version, + ); + } + if (options?.filter?.status) { return filteredPlayers.filter((p) => options.filter.status.includes(p.status), diff --git a/src/onlinePlayers/payload/AddOnlinePlayer.ts b/src/onlinePlayers/payload/AddOnlinePlayer.ts index e8853d887..4df708fb9 100644 --- a/src/onlinePlayers/payload/AddOnlinePlayer.ts +++ b/src/onlinePlayers/payload/AddOnlinePlayer.ts @@ -1,15 +1,27 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; export default class AddOnlinePlayer { /** * Player _id to be added */ + @IsString() player_id: string; /** - * Player status to set + * Player status to setAddOnlinePlayer * * @default "UI" */ + @IsEnum(OnlinePlayerStatus) + @IsOptional() status?: OnlinePlayerStatus; + + /** + * The version of the game client. + * Used to isolate matchmaking pools and prevent desyncs between incompatible builds. + * @example "1.0.4-beta" + */ + @IsString() + client_version: string; } diff --git a/src/onlinePlayers/payload/OnlinePlayer.ts b/src/onlinePlayers/payload/OnlinePlayer.ts index 5591a7816..527dac118 100644 --- a/src/onlinePlayers/payload/OnlinePlayer.ts +++ b/src/onlinePlayers/payload/OnlinePlayer.ts @@ -1,14 +1,17 @@ import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; +import { Expose } from 'class-transformer'; export default class OnlinePlayer { /** * Player _id */ + @Expose() _id: string; /** * Player's name */ + @Expose() name: string; /** @@ -16,8 +19,17 @@ export default class OnlinePlayer { */ status: OnlinePlayerStatus; + /** + * The version of the game client. + * Used to isolate matchmaking pools and prevent desyncs between incompatible builds. + * @example "1.0.4-beta" + */ + @Expose() + client_version: string; + /** * Any additional information online player has */ + @Expose() additional?: Additional; }