diff --git a/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts index 7bbf58b78..b7c0f4574 100644 --- a/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts +++ b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts @@ -7,11 +7,17 @@ import IRedisService from '../../../../../common/service/redis/IRedisService'; */ @Injectable() export class RedisServiceInMemory implements IRedisService, OnModuleDestroy { + private static instance: RedisServiceInMemory; private readonly store = new Map< string, { value: string; expiresAt?: number } >(); + constructor() { + if (RedisServiceInMemory.instance) return RedisServiceInMemory.instance; + RedisServiceInMemory.instance = this; + } + private isDestroyed = false; async set(key: string, value: string, ttlS?: number): Promise { diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts index 6b832f347..b932d9e8f 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts @@ -6,6 +6,9 @@ import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum'; import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus'; import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module'; import { RedisService } from '../../../common/service/redis/redis.service'; +import OnlinePlayersBuilderFactory from '../data/onlinePlayersBuilderFactory'; +import { OnlinePlayerBuilder } from '../data/onlinePlayers/OnlinePlayerBuilder'; +import { BattleWaitStatus } from '../../../onlinePlayers/payload/additionalTypes/BattleWaitStatus'; describe('OnlinePlayersService.addPlayerOnline() test suite', () => { let service: OnlinePlayersService; @@ -18,6 +21,12 @@ describe('OnlinePlayersService.addPlayerOnline() test suite', () => { .setName('player1') .build(); + const addPlayerBuilder = + OnlinePlayersBuilderFactory.getBuilder('AddOnlinePlayer'); + const onlinePlayerBuilder = OnlinePlayersBuilderFactory.getBuilder( + 'OnlinePlayer', + ) as OnlinePlayerBuilder; + const playerModel = PlayerModule.getPlayerModel(); beforeEach(async () => { @@ -33,21 +42,50 @@ describe('OnlinePlayersService.addPlayerOnline() test suite', () => { }); it('Should be able to add one player to cache', async () => { + const playerToAdd = addPlayerBuilder + .setPlayerId(player1._id) + .setStatus(OnlinePlayerStatus.BATTLE) + .build(); + const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${player1._id}`; - const expectedPayload = JSON.stringify({ - _id: player1._id, - name: player1.name, - status: OnlinePlayerStatus.BATTLE, - }); + const expectedPayload = JSON.stringify( + onlinePlayerBuilder + .setId(playerToAdd.player_id) + .setName(player1.name) + .setStatus(playerToAdd.status) + .build(), + ); + + const redisSet = jest.spyOn(redisService, 'set'); + + await service.addPlayerOnline(playerToAdd); + + expect(redisSet).toHaveBeenCalledTimes(1); + expect(redisSet).toHaveBeenCalledWith(expectedKey, expectedPayload, 90); + }); + + it(`Should set queue number if player has status ${OnlinePlayerStatus.BATTLE_WAIT}`, async () => { + const playerToAdd = addPlayerBuilder + .setPlayerId(player1._id) + .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 redisSet = jest.spyOn(redisService, 'set'); await service.addPlayerOnline({ player_id: player1._id, - status: OnlinePlayerStatus.BATTLE, + status: OnlinePlayerStatus.BATTLE_WAIT, }); - expect(redisSet).toHaveBeenCalledTimes(1); expect(redisSet).toHaveBeenCalledWith(expectedKey, expectedPayload, 90); }); }); diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts new file mode 100644 index 000000000..21c8e6876 --- /dev/null +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayerById.test.ts @@ -0,0 +1,59 @@ +import { OnlinePlayersService } from '../../../onlinePlayers/onlinePlayers.service'; +import { RedisService } from '../../../common/service/redis/redis.service'; +import PlayerBuilderFactory from '../../player/data/playerBuilderFactory'; +import PlayerModule from '../../player/modules/player.module'; +import OnlinePlayersModule from '../modules/onlinePlayers.module'; +import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module'; +import OnlinePlayer from '../../../onlinePlayers/payload/OnlinePlayer'; +import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus'; + +describe('OnlinePlayersService.getOnlinePlayerById() test suite', () => { + let service: OnlinePlayersService; + + let redisService: RedisService; + + const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); + const player1 = playerBuilder + .setUniqueIdentifier('player1') + .setName('player1') + .build(); + + const playerModel = PlayerModule.getPlayerModel(); + + beforeEach(async () => { + jest.clearAllMocks(); + service = await OnlinePlayersModule.getOnlinePlayersService(); + + const player1Resp = await playerModel.create(player1); + player1._id = player1Resp._id.toString(); + + redisService = (await OnlinePlayersCommonModule.getModule()).get( + RedisService, + ); + }); + + it('Should return player if it exists', async () => { + const existingPlayer: OnlinePlayer = { + _id: player1._id, + name: player1.name, + status: OnlinePlayerStatus.UI, + }; + jest + .spyOn(redisService, 'get') + .mockResolvedValue(JSON.stringify(existingPlayer)); + + const [player, errors] = await service.getOnlinePlayerById(player1._id); + + expect(errors).toBeNull(); + expect(player).toEqual(existingPlayer); + }); + + it('Should return ServiceError NOT_FOUND if player does not exists', async () => { + jest.spyOn(redisService, 'get').mockResolvedValue(null); + + const [player, errors] = await service.getOnlinePlayerById(player1._id); + + expect(player).toBeNull(); + expect(errors).toContainSE_NOT_FOUND(); + }); +}); diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayers.test.ts similarity index 91% rename from src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts rename to src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayers.test.ts index 8e5ecc38e..e029ffa64 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/getOnlinePlayers.test.ts @@ -7,7 +7,7 @@ import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStat import { RedisService } from '../../../common/service/redis/redis.service'; import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module'; -describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { +describe('OnlinePlayersService.getOnlinePlayers() test suite', () => { let service: OnlinePlayersService; let redisService: RedisService; @@ -57,7 +57,7 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { [_id2]: JSON.stringify(payload2), }); - const player_ids = await service.getAllOnlinePlayers(); + const player_ids = await service.getOnlinePlayers(); expect(player_ids).toContainEqual(payload1); expect(player_ids).toContainEqual(payload2); @@ -82,7 +82,7 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { [_id2]: JSON.stringify(payload2), }); - const player_ids = await service.getAllOnlinePlayers({ + const player_ids = await service.getOnlinePlayers({ filter: { status: [OnlinePlayerStatus.UI] }, }); @@ -93,7 +93,7 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { it('Should not return players if there are no players', async () => { jest.spyOn(redisService, 'getValuesByKeyPattern').mockResolvedValue({}); - const player_ids = await service.getAllOnlinePlayers(); + const player_ids = await service.getOnlinePlayers(); expect(player_ids).not.toContain(_id1); }); diff --git a/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/getPlayerQueueNumber.test.ts b/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/getPlayerQueueNumber.test.ts new file mode 100644 index 000000000..d823e09b6 --- /dev/null +++ b/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/getPlayerQueueNumber.test.ts @@ -0,0 +1,99 @@ +import BattleQueueModule from '../../modules/battleQueue.module'; +import PlayerModule from '../../../player/modules/player.module'; +import PlayerBuilderFactory from '../../../player/data/playerBuilderFactory'; +import { BattleQueueService } from '../../../../onlinePlayers/battleQueue/battleQueue.service'; +import { OnlinePlayerStatus } from '../../../../onlinePlayers/enum/OnlinePlayerStatus'; +import OnlinePlayersBuilderFactory from '../../data/onlinePlayersBuilderFactory'; +import { OnlinePlayerBuilder } from '../../data/onlinePlayers/OnlinePlayerBuilder'; +import { BattleWaitStatus } from '../../../../onlinePlayers/payload/additionalTypes/BattleWaitStatus'; + +describe('BattleQueueService.getPlayerQueueNumber() test suite', () => { + let service: BattleQueueService; + + const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); + const player1 = playerBuilder + .setName('player1') + .setUniqueIdentifier('player1') + .build(); + const player2 = playerBuilder + .setName('player2') + .setUniqueIdentifier('player2') + .build(); + const player3 = playerBuilder + .setName('player3') + .setUniqueIdentifier('player3') + .build(); + const playerModel = PlayerModule.getPlayerModel(); + + const onlinePlayerBuilder = OnlinePlayersBuilderFactory.getBuilder( + 'OnlinePlayer', + ) as OnlinePlayerBuilder; + + beforeEach(async () => { + service = await BattleQueueModule.getBattleQueueService(); + + const createdPlayer1 = await playerModel.create(player1); + player1._id = createdPlayer1._id.toString(); + const createdPlayer2 = await playerModel.create(player2); + player2._id = createdPlayer2._id.toString(); + const createdPlayer3 = await playerModel.create(player3); + player3._id = createdPlayer3._id.toString(); + }); + + it('Should return increased by 1 order number for each player', async () => { + const onlinePlayer1 = onlinePlayerBuilder + .setId(player1._id) + .setName(player1.name) + .build(); + const onlinePlayer2 = onlinePlayerBuilder + .setId(player2._id) + .setName(player2.name) + .build(); + const onlinePlayer3 = onlinePlayerBuilder + .setId(player3._id) + .setName(player3.name) + .build(); + + const [number1, errors1] = + await service.getPlayerQueueNumber(onlinePlayer1); + const [number2, errors2] = + await service.getPlayerQueueNumber(onlinePlayer2); + const [number3, errors3] = + await service.getPlayerQueueNumber(onlinePlayer3); + + expect(errors1).toBeNull(); + expect(number1).toBe(0); + + expect(errors2).toBeNull(); + expect(number2).toBe(1); + + expect(errors3).toBeNull(); + expect(number3).toBe(2); + }); + + it('Should return the same number the player has if the player is already in queue', async () => { + const queueNumber = 0; + const onlinePlayer1 = onlinePlayerBuilder + .setId(player1._id) + .setName(player1.name) + .setStatus(OnlinePlayerStatus.BATTLE_WAIT) + .setAdditional({ queueNumber }) + .build(); + + const [number1, errors1] = + await service.getPlayerQueueNumber(onlinePlayer1); + const [number2, errors2] = + await service.getPlayerQueueNumber(onlinePlayer1); + const [number3, errors3] = + await service.getPlayerQueueNumber(onlinePlayer1); + + expect(errors1).toBeNull(); + expect(number1).toBe(queueNumber); + + expect(errors2).toBeNull(); + expect(number2).toBe(queueNumber); + + expect(errors3).toBeNull(); + expect(number3).toBe(queueNumber); + }); +}); diff --git a/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/sortPlayersByQueueNumber.test.ts b/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/sortPlayersByQueueNumber.test.ts new file mode 100644 index 000000000..85bd05e5b --- /dev/null +++ b/src/__tests__/onlinePlayers/battleQueue/BattleQueueService/sortPlayersByQueueNumber.test.ts @@ -0,0 +1,74 @@ +import { BattleQueueService } from 'src/onlinePlayers/battleQueue/battleQueue.service'; +import BattleQueueModule from '../../modules/battleQueue.module'; +import OnlinePlayersBuilderFactory from '../../data/onlinePlayersBuilderFactory'; +import { OnlinePlayerBuilder } from '../../data/onlinePlayers/OnlinePlayerBuilder'; +import { BattleWaitStatus } from '../../../../onlinePlayers/payload/additionalTypes/BattleWaitStatus'; + +describe('BattleQueueService.sortPlayersByQueueNumber', () => { + let service: BattleQueueService; + let playerBuilder: OnlinePlayerBuilder; + + beforeEach(async () => { + service = await BattleQueueModule.getBattleQueueService(); + playerBuilder = OnlinePlayersBuilderFactory.getBuilder('OnlinePlayer'); + }); + + it('should sort players in ascending queueNumber order', () => { + const players = [ + playerBuilder.setAdditional({ queueNumber: 10 }).build(), + playerBuilder.setAdditional({ queueNumber: 5 }).build(), + playerBuilder.setAdditional({ queueNumber: 7 }).build(), + ]; + + const [sorted, errors] = service.sortPlayersByQueueNumber(players); + const sortedNumbers = sorted.map((p) => p.additional.queueNumber); + + expect(errors).toBeNull(); + expect(sortedNumbers).toEqual([5, 7, 10]); + }); + + it('should sort players considering wrap-around queue numbers', () => { + const players = [ + playerBuilder.setAdditional({ queueNumber: 1 }).build(), + playerBuilder.setAdditional({ queueNumber: 9999 }).build(), + playerBuilder.setAdditional({ queueNumber: 5 }).build(), + playerBuilder.setAdditional({ queueNumber: 9998 }).build(), + playerBuilder.setAdditional({ queueNumber: 0 }).build(), + ]; + + const [sorted, errors] = service.sortPlayersByQueueNumber(players); + const sortedNumbers = sorted.map((p) => p.additional.queueNumber); + + expect(errors).toBeNull(); + expect(sortedNumbers).toEqual([9998, 9999, 0, 1, 5]); + }); + + it('should still sort correctly with gaps and wrap', () => { + const players = [ + playerBuilder.setAdditional({ queueNumber: 9998 }).build(), + playerBuilder.setAdditional({ queueNumber: 3 }).build(), + playerBuilder.setAdditional({ queueNumber: 9995 }).build(), + playerBuilder.setAdditional({ queueNumber: 9 }).build(), + playerBuilder.setAdditional({ queueNumber: 1000 }).build(), + ]; + + const [sorted, errors] = service.sortPlayersByQueueNumber(players); + const sortedNumbers = sorted.map((p) => p.additional.queueNumber); + + expect(errors).toBeNull(); + expect(sortedNumbers).toEqual([9995, 9998, 3, 9, 1000]); + }); + + it('Should return ServiceError NOT_FOUND if empty array is provided', () => { + const [sorted, errors] = service.sortPlayersByQueueNumber([]); + expect(sorted).toBeNull(); + expect(errors).toContainSE_NOT_FOUND(); + }); + + it('Should return ServiceError NOT_FOUND if no players have the order number', () => { + const players1 = [playerBuilder.build(), playerBuilder.build()]; + const [sorted, errors] = service.sortPlayersByQueueNumber(players1); + expect(sorted).toBeNull(); + expect(errors).toContainSE_NOT_FOUND(); + }); +}); diff --git a/src/__tests__/onlinePlayers/data/onlinePlayers/AddOnlinePlayerBuilder.ts b/src/__tests__/onlinePlayers/data/onlinePlayers/AddOnlinePlayerBuilder.ts new file mode 100644 index 000000000..d131f86fd --- /dev/null +++ b/src/__tests__/onlinePlayers/data/onlinePlayers/AddOnlinePlayerBuilder.ts @@ -0,0 +1,23 @@ +import AddOnlinePlayer from '../../../../onlinePlayers/payload/AddOnlinePlayer'; +import { OnlinePlayerStatus } from '../../../../onlinePlayers/enum/OnlinePlayerStatus'; + +export class AddOnlinePlayerBuilder { + private readonly base: Partial = { + player_id: undefined, + status: OnlinePlayerStatus.UI, + }; + + build(): AddOnlinePlayer { + return { ...this.base } as AddOnlinePlayer; + } + + setPlayerId(player_id: string): this { + this.base.player_id = player_id; + return this; + } + + setStatus(status: OnlinePlayerStatus): this { + this.base.status = status; + return this; + } +} diff --git a/src/__tests__/onlinePlayers/data/onlinePlayers/OnlinePlayerBuilder.ts b/src/__tests__/onlinePlayers/data/onlinePlayers/OnlinePlayerBuilder.ts new file mode 100644 index 000000000..470049f84 --- /dev/null +++ b/src/__tests__/onlinePlayers/data/onlinePlayers/OnlinePlayerBuilder.ts @@ -0,0 +1,35 @@ +import OnlinePlayer from '../../../../onlinePlayers/payload/OnlinePlayer'; +import { OnlinePlayerStatus } from '../../../../onlinePlayers/enum/OnlinePlayerStatus'; + +export class OnlinePlayerBuilder { + private readonly base: Partial> = { + _id: undefined, + name: 'player1', + status: OnlinePlayerStatus.UI, + additional: undefined, + }; + + build(): OnlinePlayer { + return { ...this.base } as OnlinePlayer; + } + + setId(id: string): this { + this.base._id = id; + return this; + } + + setName(name: string): this { + this.base.name = name; + return this; + } + + setStatus(status: OnlinePlayerStatus): this { + this.base.status = status; + return this; + } + + setAdditional(additional: Additional): this { + this.base.additional = additional; + return this; + } +} diff --git a/src/__tests__/onlinePlayers/data/onlinePlayersBuilderFactory.ts b/src/__tests__/onlinePlayers/data/onlinePlayersBuilderFactory.ts new file mode 100644 index 000000000..efd2d36ac --- /dev/null +++ b/src/__tests__/onlinePlayers/data/onlinePlayersBuilderFactory.ts @@ -0,0 +1,22 @@ +import { OnlinePlayerBuilder } from './onlinePlayers/OnlinePlayerBuilder'; +import { AddOnlinePlayerBuilder } from './onlinePlayers/AddOnlinePlayerBuilder'; + +type BuilderName = 'OnlinePlayer' | 'AddOnlinePlayer'; + +type BuilderMap = { + OnlinePlayer: OnlinePlayerBuilder; + AddOnlinePlayer: AddOnlinePlayerBuilder; +}; + +export default class OnlinePlayersBuilderFactory { + static getBuilder(builderName: T): BuilderMap[T] { + switch (builderName) { + case 'OnlinePlayer': + return new OnlinePlayerBuilder() as BuilderMap[T]; + case 'AddOnlinePlayer': + return new AddOnlinePlayerBuilder() as BuilderMap[T]; + default: + throw new Error(`Unknown builder name: ${builderName}`); + } + } +} diff --git a/src/__tests__/onlinePlayers/modules/battleQueue.module.ts b/src/__tests__/onlinePlayers/modules/battleQueue.module.ts new file mode 100644 index 000000000..1ba52f73d --- /dev/null +++ b/src/__tests__/onlinePlayers/modules/battleQueue.module.ts @@ -0,0 +1,11 @@ +import { BattleQueueService } from '../../../onlinePlayers/battleQueue/battleQueue.service'; +import OnlinePlayersCommonModule from './onlinePlayersCommon.module'; + +export default class BattleQueueModule { + private constructor() {} + + static async getBattleQueueService() { + const module = await OnlinePlayersCommonModule.getModule(); + return module.resolve(BattleQueueService); + } +} diff --git a/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts b/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts index 8eb40e1e2..d13cd3645 100644 --- a/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts +++ b/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts @@ -9,6 +9,7 @@ import { PlayerSchema } from '../../../player/schemas/player.schema'; import { RedisModule } from '../../../common/service/redis/redis.module'; import { RedisServiceInMemory } from '../../common/service/redis/mocks/RedisServiceInMemory'; import { RedisService } from '../../../common/service/redis/redis.service'; +import { BattleQueueService } from '../../../onlinePlayers/battleQueue/battleQueue.service'; export default class OnlinePlayersCommonModule { private static module: TestingModule; @@ -25,7 +26,7 @@ export default class OnlinePlayersCommonModule { RequestHelperModule, RedisModule, ], - providers: [OnlinePlayersService], + providers: [OnlinePlayersService, BattleQueueService], }) .overrideProvider(RedisService) .useClass(RedisServiceInMemory) diff --git a/src/common/service/redis/cacheKeys.enum.ts b/src/common/service/redis/cacheKeys.enum.ts index e1a985818..517faa19d 100644 --- a/src/common/service/redis/cacheKeys.enum.ts +++ b/src/common/service/redis/cacheKeys.enum.ts @@ -5,12 +5,23 @@ * and avoid hardcoding string literals throughout the codebase. * Each key corresponds to a specific cache entry in Redis. * - * - `CLAN_LEADERBOARD`: Key for caching clan leaderboard data. - * - `PLAYER_LEADERBOARD`: Key for caching player leaderboard data. - * - `ONLINE_PLAYERS`: Key for caching the list of online players. */ export enum CacheKeys { + /** + * Key for caching clan leaderboard data. + */ CLAN_LEADERBOARD = 'clanLeaderboard', + /** + * Key for caching player leaderboard data. + */ PLAYER_LEADERBOARD = 'playerLeaderboard', + + /** + * Key for caching the list of online players. + */ ONLINE_PLAYERS = 'online_players', + /** + * Next queue number for a battle queue + */ + NEXT_QUEUE_NUMBER = 'next_queue_number', } diff --git a/src/leaderboard/leaderboard.controller.ts b/src/leaderboard/leaderboard.controller.ts index 5ea9831a0..b4938fd48 100644 --- a/src/leaderboard/leaderboard.controller.ts +++ b/src/leaderboard/leaderboard.controller.ts @@ -13,6 +13,7 @@ import { PlayerService } from '../player/player.service'; import { LeaderboardPlayerDto } from './dto/leaderboardPlayer.dto'; import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; import ClanPositionDto from './dto/clanPosition.dto'; +import SwaggerTags from '../common/swagger/tags/SwaggerTags.decorator'; @Controller('leaderboard') export class LeaderboardController { @@ -26,7 +27,7 @@ export class LeaderboardController { * * @remarks Leaderboard of players. Top Players are defined by the amount of points that he/she has. * - * Notice that the leaderboards data is updated once every 12 hours. + * Notice that the leaderboards data is updated once every 3h hours. */ @ApiResponseDescription({ success: { @@ -37,6 +38,7 @@ export class LeaderboardController { errors: [400, 404], hasAuth: false, }) + @SwaggerTags('Release on 01.06.2025', 'Leaderboard') @Get('player') @NoAuth() @UniformResponse(ModelName.PLAYER, LeaderboardPlayerDto) @@ -50,7 +52,7 @@ export class LeaderboardController { * * @remarks Leaderboard of clans. Top Clans are defined by the amount of points that each Clan has. * - * Notice that the leaderboards data is updated once every 12 hours. + * Notice that the leaderboards data is updated once every 3h hours. */ @ApiResponseDescription({ success: { @@ -61,6 +63,7 @@ export class LeaderboardController { errors: [400, 404], hasAuth: false, }) + @SwaggerTags('Release on 01.06.2025', 'Leaderboard') @Get('clan') @NoAuth() @UniformResponse(ModelName.CLAN, ClanDto) diff --git a/src/onlinePlayers/battleQueue/battleQueue.controller.ts b/src/onlinePlayers/battleQueue/battleQueue.controller.ts new file mode 100644 index 000000000..19d339ffc --- /dev/null +++ b/src/onlinePlayers/battleQueue/battleQueue.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get } from '@nestjs/common'; +import { BattleQueueService } from './battleQueue.service'; +import ApiResponseDescription from 'src/common/swagger/response/ApiResponseDescription'; +import OnlinePlayerDto from '../dto/onlinePlayer.dto'; +import { UniformResponse } from '../../common/decorator/response/UniformResponse'; +import SwaggerTags from '../../common/swagger/tags/SwaggerTags.decorator'; +import { OnlinePlayersService } from '../onlinePlayers.service'; +import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; + +@Controller('/online-players/battleQueue') +export class BattleQueueController { + constructor( + private readonly service: BattleQueueService, + private readonly onlinePlayersService: OnlinePlayersService, + ) {} + + /** + * Get battle queue + * + * @remarks Returns a list of online players waiting to join the battle in a queue order, + * where the first player is the next to play + */ + @ApiResponseDescription({ + success: { + dto: OnlinePlayerDto, + returnsArray: true, + hasPagination: false, + }, + errors: [401, 404], + }) + @SwaggerTags('Release on 01.06.2025', 'OnlinePlayers') + @Get() + @UniformResponse(null, OnlinePlayerDto) + async getBattleQueue() { + const queuePlayers = await this.onlinePlayersService.getOnlinePlayers({ + filter: { status: [OnlinePlayerStatus.BATTLE_WAIT] }, + }); + return this.service.sortPlayersByQueueNumber(queuePlayers); + } +} diff --git a/src/onlinePlayers/battleQueue/battleQueue.service.ts b/src/onlinePlayers/battleQueue/battleQueue.service.ts new file mode 100644 index 000000000..6bccc0c25 --- /dev/null +++ b/src/onlinePlayers/battleQueue/battleQueue.service.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@nestjs/common'; +import { IServiceReturn } from '../../common/service/basicService/IService'; +import OnlinePlayer from '../payload/OnlinePlayer'; +import { BattleWaitStatus } from '../payload/additionalTypes/BattleWaitStatus'; +import ServiceError from '../../common/service/basicService/ServiceError'; +import { SEReason } from '../../common/service/basicService/SEReason'; +import { RedisService } from '../../common/service/redis/redis.service'; +import { CacheKeys } from '../../common/service/redis/cacheKeys.enum'; + +@Injectable() +export class BattleQueueService { + public constructor(private readonly redisService: RedisService) {} + + /** + * Max number the queue number can become + * @private + */ + private readonly queueNumberMax = 9999; + + /** + * Gets a queue number for a player. + * + * Each time a player gets a new queue number, the queue number is increased by one until it becomes its max value. + * Then the number will be set to its max value, it will be reset and will be equal to zero again. + * Notice that if the player already in the queue, the number will not be updated + * + * @param player player for which the number is requested + * + * @returns queue number for an online player + */ + async getPlayerQueueNumber( + player: OnlinePlayer | null, + ): Promise> { + if (player?.additional?.queueNumber != null) { + const existingQueueNumber = (player as OnlinePlayer) + .additional.queueNumber; + return [existingQueueNumber, null]; + } + + const playerQueueNumber = await this.getNextQueueNumber(); + await this.increaseQueueNumber(); + + return [playerQueueNumber, null]; + } + + /** + * Sorts online players based on their queueNumber considering wrap-around after queue number is reset + * + * Notice that all players that does not have a queue number, will be excluded from queue and not retuned. + * + * @param players players with queueNumber + * + * @returns players in order number or ServiceError NOT_FOUND if: + * - Empty array is provided + * - No players have a queue number + */ + sortPlayersByQueueNumber( + players: OnlinePlayer[], + ): IServiceReturn[]> { + const [validPlayers, filterErrors] = + this.filterInvalidPlayersInQueue(players); + + if (filterErrors) return [null, filterErrors]; + + const queueNumbers = validPlayers.map((p) => p.additional.queueNumber); + + const queueMax = this.queueNumberMax + 1; + let oldestQN = queueNumbers[0]; + + for (const currentNumber of queueNumbers) { + const diffRefToCurr = (currentNumber - oldestQN + queueMax) % queueMax; + const diffCurrToRef = (oldestQN - currentNumber + queueMax) % queueMax; + + if (diffCurrToRef < diffRefToCurr) oldestQN = currentNumber; + } + + const sortedPlayers = validPlayers + .slice() + .sort((playerLeft, playerRight) => { + const aQN = playerLeft.additional.queueNumber; + const bQN = playerRight.additional.queueNumber; + + const normA = (aQN - oldestQN + queueMax) % queueMax; + const normB = (bQN - oldestQN + queueMax) % queueMax; + + return normA - normB; + }); + + return [sortedPlayers, null]; + } + + /** + * Gets next queue number from Redis. + * + * Notice that if number does not exist = not set, it will be set to zero. + * + * Notice that the method does not change the value in Redis, it only returns the current value + * + * @private + * @returns next queue number from Redis + */ + private async getNextQueueNumber() { + const queueNumberStr = await this.redisService.get( + CacheKeys.NEXT_QUEUE_NUMBER, + ); + if (queueNumberStr) return Number.parseInt(queueNumberStr); + + await this.redisService.set(CacheKeys.NEXT_QUEUE_NUMBER, '0'); + return 0; + } + + /** + * Increases next queue number in Redis. + * + * Notice that if the number is more than max queue number it will be reset to zero + * + * @private + */ + private async increaseQueueNumber() { + let queueNumber = await this.getNextQueueNumber(); + + queueNumber++; + if (queueNumber > this.queueNumberMax) queueNumber = 0; + + await this.redisService.set(CacheKeys.NEXT_QUEUE_NUMBER, `${queueNumber}`); + } + + /** + * Filters players that does not have an order number + * + * @param players players to filter + * @private + * @returns only players that have the order number or ServiceError NOT_FOUND if there are no valid players + */ + private filterInvalidPlayersInQueue( + players: OnlinePlayer[], + ): IServiceReturn[]> { + if (players.length === 0) + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_FOUND, + message: 'No online players in battle queue', + }), + ], + ]; + + const validPlayers = players.filter( + (player) => player.additional?.queueNumber != null, + ); + if (validPlayers.length === 0) + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_FOUND, + message: 'No online players with queue number found', + }), + ], + ]; + return [validPlayers, null]; + } +} diff --git a/src/onlinePlayers/dto/onlinePlayer.dto.ts b/src/onlinePlayers/dto/onlinePlayer.dto.ts index 983bab67e..f56313651 100644 --- a/src/onlinePlayers/dto/onlinePlayer.dto.ts +++ b/src/onlinePlayers/dto/onlinePlayer.dto.ts @@ -1,7 +1,7 @@ import { Expose } from 'class-transformer'; import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; -export default class OnlinePlayerDto { +export default class OnlinePlayerDto { /** * _id of the player * @@ -25,4 +25,12 @@ export default class OnlinePlayerDto { */ @Expose() status: OnlinePlayerStatus; + + /** + * Any additional information online player has + * + * @example { queueNumber: 239 } + */ + @Expose() + additional?: Additional; } diff --git a/src/onlinePlayers/onlinePlayers.controller.ts b/src/onlinePlayers/onlinePlayers.controller.ts index 9d46c0316..dca2a0499 100644 --- a/src/onlinePlayers/onlinePlayers.controller.ts +++ b/src/onlinePlayers/onlinePlayers.controller.ts @@ -7,6 +7,7 @@ import ApiResponseDescription from '../common/swagger/response/ApiResponseDescri import OnlinePlayerDto from './dto/onlinePlayer.dto'; import InformPlayerIsOnlineDto from './dto/InformPlayerIsOnline.dto'; import OnlinePlayerSearchQueryDto from './dto/OnlinePlayerSearchQuery.dto'; +import SwaggerTags from '../common/swagger/tags/SwaggerTags.decorator'; @Controller('online-players') export class OnlinePlayersController { @@ -25,6 +26,7 @@ export class OnlinePlayersController { }, errors: [400, 401], }) + @SwaggerTags('Release on 01.06.2025', 'OnlinePlayers') @Post('ping') @UniformResponse() async ping(@Body() body: InformPlayerIsOnlineDto, @LoggedUser() user: User) { @@ -49,11 +51,12 @@ export class OnlinePlayersController { }, errors: [401], }) + @SwaggerTags('Release on 01.06.2025', 'OnlinePlayers') @Get() @UniformResponse(null, OnlinePlayerDto) async getAllOnlinePlayers(@Query() query: OnlinePlayerSearchQueryDto) { const filter = { status: query.search }; - return this.onlinePlayersService.getAllOnlinePlayers({ + return this.onlinePlayersService.getOnlinePlayers({ filter, }); } diff --git a/src/onlinePlayers/onlinePlayers.module.ts b/src/onlinePlayers/onlinePlayers.module.ts index 0342ca223..10781868c 100644 --- a/src/onlinePlayers/onlinePlayers.module.ts +++ b/src/onlinePlayers/onlinePlayers.module.ts @@ -3,10 +3,13 @@ import { OnlinePlayersService } from './onlinePlayers.service'; import { OnlinePlayersController } from './onlinePlayers.controller'; import { PlayerModule } from '../player/player.module'; import { RedisModule } from '../common/service/redis/redis.module'; +import { BattleQueueService } from './battleQueue/battleQueue.service'; +import { BattleQueueController } from './battleQueue/battleQueue.controller'; @Module({ imports: [PlayerModule, RedisModule], - providers: [OnlinePlayersService], - controllers: [OnlinePlayersController], + providers: [OnlinePlayersService, BattleQueueService], + controllers: [OnlinePlayersController, BattleQueueController], + exports: [OnlinePlayersService], }) export class OnlinePlayersModule {} diff --git a/src/onlinePlayers/onlinePlayers.service.ts b/src/onlinePlayers/onlinePlayers.service.ts index bd3063b0f..431483d8d 100644 --- a/src/onlinePlayers/onlinePlayers.service.ts +++ b/src/onlinePlayers/onlinePlayers.service.ts @@ -6,6 +6,10 @@ import { RedisService } from '../common/service/redis/redis.service'; import { OnlinePlayerStatus } from './enum/OnlinePlayerStatus'; import AddOnlinePlayer from './payload/AddOnlinePlayer'; import OnlinePlayer from './payload/OnlinePlayer'; +import ServiceError from '../common/service/basicService/ServiceError'; +import { SEReason } from '../common/service/basicService/SEReason'; +import { BattleWaitStatus } from './payload/additionalTypes/BattleWaitStatus'; +import { BattleQueueService } from './battleQueue/battleQueue.service'; @Injectable() export class OnlinePlayersService { @@ -19,6 +23,7 @@ export class OnlinePlayersService { constructor( private readonly redisService: RedisService, private readonly playerService: PlayerService, + private readonly battleQueueService: BattleQueueService, ) {} /** @@ -41,6 +46,13 @@ export class OnlinePlayersService { status: status ?? OnlinePlayerStatus.UI, }; + if (status === OnlinePlayerStatus.BATTLE_WAIT) { + const [onlinePlayer] = await this.getOnlinePlayerById(player_id); + const [queueNumber] = + await this.battleQueueService.getPlayerQueueNumber(onlinePlayer); + (payload as OnlinePlayer).additional = { queueNumber }; + } + await this.redisService.set( `${this.ONLINE_PLAYERS_KEY}:${player_id}`, JSON.stringify(payload), @@ -56,7 +68,7 @@ export class OnlinePlayersService { * * @returns Array of OnlinePlayers or empty array if nothing found */ - async getAllOnlinePlayers(options?: { + async getOnlinePlayers(options?: { filter?: { status?: OnlinePlayerStatus[] }; }): Promise { const players = await this.redisService.getValuesByKeyPattern( @@ -77,4 +89,32 @@ export class OnlinePlayersService { return onlinePlayers; } + + /** + * Gets online player by its _id. + * + * @param player_id player _id to be found + * + * @returns found online player or ServiceError NOT_FOUND if player is not found + */ + async getOnlinePlayerById( + player_id: string, + ): Promise> { + const player = await this.redisService.get( + `${this.ONLINE_PLAYERS_KEY}:${player_id}`, + ); + if (!player) + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_FOUND, + field: 'player_id', + value: player_id, + message: 'Player with this _id is not found in online players', + }), + ], + ]; + return [JSON.parse(player), null]; + } } diff --git a/src/onlinePlayers/payload/OnlinePlayer.ts b/src/onlinePlayers/payload/OnlinePlayer.ts index 9ec0029e7..5591a7816 100644 --- a/src/onlinePlayers/payload/OnlinePlayer.ts +++ b/src/onlinePlayers/payload/OnlinePlayer.ts @@ -1,6 +1,6 @@ import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; -export default class OnlinePlayer { +export default class OnlinePlayer { /** * Player _id */ @@ -15,4 +15,9 @@ export default class OnlinePlayer { * Player status */ status: OnlinePlayerStatus; + + /** + * Any additional information online player has + */ + additional?: Additional; } diff --git a/src/onlinePlayers/payload/additionalTypes/BattleWaitStatus.ts b/src/onlinePlayers/payload/additionalTypes/BattleWaitStatus.ts new file mode 100644 index 000000000..4decfcefb --- /dev/null +++ b/src/onlinePlayers/payload/additionalTypes/BattleWaitStatus.ts @@ -0,0 +1,9 @@ +/** + * Additional information for a player with status BattleWait + */ +export type BattleWaitStatus = { + /** + * Player number in the queue + */ + queueNumber: number; +};