diff --git a/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts index fc03fd53e..7bbf58b78 100644 --- a/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts +++ b/src/__tests__/common/service/redis/mocks/RedisServiceInMemory.ts @@ -1,11 +1,12 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import IRedisService from '../../../../../common/service/redis/IRedisService'; /** * In-memory mock of RedisService for use in tests. * Simulates set/get/keys with TTL support. */ @Injectable() -export class RedisServiceInMemory implements OnModuleDestroy { +export class RedisServiceInMemory implements IRedisService, OnModuleDestroy { private readonly store = new Map< string, { value: string; expiresAt?: number } @@ -45,6 +46,19 @@ export class RedisServiceInMemory implements OnModuleDestroy { .map(([key]) => key); } + async getValuesByKeyPattern( + pattern: string, + ): Promise> { + const keys = await this.getKeys(pattern); + const result: Record = {}; + + for (const key of keys) { + result[key] = await this.get(key); + } + + return result; + } + async onModuleDestroy() { this.store.clear(); this.isDestroyed = true; diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts index cae7027b7..6b832f347 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/addPlayerOnline.test.ts @@ -3,19 +3,15 @@ import OnlinePlayersModule from '../modules/onlinePlayers.module'; import PlayerBuilderFactory from '../../player/data/playerBuilderFactory'; import PlayerModule from '../../player/modules/player.module'; import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum'; - -const redisSet = jest.fn(); -jest.mock('ioredis', () => { - return jest.fn().mockImplementation(() => ({ - set: redisSet, - keys: jest.fn(), - on: jest.fn(), - })); -}); +import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus'; +import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module'; +import { RedisService } from '../../../common/service/redis/redis.service'; describe('OnlinePlayersService.addPlayerOnline() test suite', () => { let service: OnlinePlayersService; + let redisService: RedisService; + const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); const player1 = playerBuilder .setUniqueIdentifier('player1') @@ -30,14 +26,28 @@ describe('OnlinePlayersService.addPlayerOnline() test suite', () => { const player1Resp = await playerModel.create(player1); player1._id = player1Resp._id.toString(); + + redisService = (await OnlinePlayersCommonModule.getModule()).get( + RedisService, + ); }); - it('Should be able to add one player to cache and set 1 as its value', async () => { - await service.addPlayerOnline(player1._id); + it('Should be able to add one player to cache', async () => { + const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${player1._id}`; + const expectedPayload = JSON.stringify({ + _id: player1._id, + name: player1.name, + status: OnlinePlayerStatus.BATTLE, + }); + + const redisSet = jest.spyOn(redisService, 'set'); - const expectedKey = `${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify({ id: player1._id, name: player1.name })}`; + await service.addPlayerOnline({ + player_id: player1._id, + status: OnlinePlayerStatus.BATTLE, + }); expect(redisSet).toHaveBeenCalledTimes(1); - expect(redisSet).toHaveBeenCalledWith(expectedKey, '1', 'EX', 300); + expect(redisSet).toHaveBeenCalledWith(expectedKey, expectedPayload, 90); }); }); diff --git a/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts b/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts index cff500b04..e5ff5d1d4 100644 --- a/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts +++ b/src/__tests__/onlinePlayers/OnlinePlayersService/getAllOnlinePlayers.test.ts @@ -3,21 +3,17 @@ import OnlinePlayersModule from '../modules/onlinePlayers.module'; import { ObjectId } from 'mongodb'; import PlayerBuilderFactory from '../../player/data/playerBuilderFactory'; import PlayerModule from '../../player/modules/player.module'; -import { CacheKeys } from '../../../common/service/redis/cacheKeys.enum'; - -const redisKeys = jest.fn(); -jest.mock('ioredis', () => { - return jest.fn().mockImplementation(() => ({ - keys: redisKeys, - on: jest.fn(), - })); -}); +import { OnlinePlayerStatus } from '../../../onlinePlayers/enum/OnlinePlayerStatus'; +import { RedisService } from '../../../common/service/redis/redis.service'; +import OnlinePlayersCommonModule from '../modules/onlinePlayersCommon.module'; describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { let service: OnlinePlayersService; - const _id1 = new ObjectId(); - const _id2 = new ObjectId(); + let redisService: RedisService; + + const _id1 = new ObjectId().toString(); + const _id2 = new ObjectId().toString(); const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); const player1 = playerBuilder @@ -36,18 +32,30 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { beforeEach(async () => { jest.clearAllMocks(); service = await OnlinePlayersModule.getOnlinePlayersService(); + + redisService = (await OnlinePlayersCommonModule.getModule()).get( + RedisService, + ); }); it('Should return player_ids from the cache', async () => { await playerModel.create(player1); await playerModel.create(player2); - const payload1 = { name: player1.name, id: _id1.toString() }; - const payload2 = { name: player2.name, id: _id2.toString() }; + const payload1 = { + name: player1.name, + _id: _id1, + status: OnlinePlayerStatus.UI, + }; + const payload2 = { + name: player2.name, + _id: _id2, + status: OnlinePlayerStatus.BATTLE, + }; - redisKeys.mockReturnValueOnce([ - `${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify(payload1)}`, - `${CacheKeys.ONLINE_PLAYERS}:${JSON.stringify(payload2)}`, - ]); + jest.spyOn(redisService, 'getValuesByKeyPattern').mockResolvedValue({ + [_id1]: JSON.stringify(payload1), + [_id2]: JSON.stringify(payload2), + }); const player_ids = await service.getAllOnlinePlayers(); @@ -55,14 +63,8 @@ describe('OnlinePlayersService.getAllOnlinePlayers() test suite', () => { expect(player_ids).toContainEqual(payload2); }); - it('Should return an empty array if there are no players set', async () => { - const player_ids = await service.getAllOnlinePlayers(); - - expect(player_ids).toHaveLength(0); - }); - - it('Should not return players _ids which TTL was expired', async () => { - redisKeys.mockReturnValueOnce(undefined); + it('Should not return players if there are no players', async () => { + jest.spyOn(redisService, 'getValuesByKeyPattern').mockResolvedValue({}); const player_ids = await service.getAllOnlinePlayers(); diff --git a/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts b/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts index 32f1ba699..8eb40e1e2 100644 --- a/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts +++ b/src/__tests__/onlinePlayers/modules/onlinePlayersCommon.module.ts @@ -7,6 +7,8 @@ import { mongooseOptions, mongoString } from '../../test_utils/const/db'; import { ModelName } from '../../../common/enum/modelName.enum'; 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'; export default class OnlinePlayersCommonModule { private static module: TestingModule; @@ -24,7 +26,10 @@ export default class OnlinePlayersCommonModule { RedisModule, ], providers: [OnlinePlayersService], - }).compile(); + }) + .overrideProvider(RedisService) + .useClass(RedisServiceInMemory) + .compile(); return OnlinePlayersCommonModule.module; } diff --git a/src/common/service/redis/IRedisService.ts b/src/common/service/redis/IRedisService.ts new file mode 100644 index 000000000..262d502dd --- /dev/null +++ b/src/common/service/redis/IRedisService.ts @@ -0,0 +1,11 @@ +export default interface IRedisService { + set(key: string, value: string, ttlS?: number): Promise; + + get(key: string): Promise; + + getKeys(pattern: string): Promise; + + getValuesByKeyPattern( + pattern: string, + ): Promise>; +} diff --git a/src/common/service/redis/redis.service.ts b/src/common/service/redis/redis.service.ts index 9929727cc..9bf1e1f5f 100644 --- a/src/common/service/redis/redis.service.ts +++ b/src/common/service/redis/redis.service.ts @@ -1,13 +1,14 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import Redis from 'ioredis'; import { envVars } from '../envHandler/envVars'; +import IRedisService from './IRedisService'; /** * A service for interacting directly with Redis using ioredis. * Provides basic get/set functionality and pattern-based key scanning. */ @Injectable() -export class RedisService implements OnModuleDestroy { +export class RedisService implements OnModuleDestroy, IRedisService { private readonly client: Redis; constructor() { @@ -53,10 +54,46 @@ export class RedisService implements OnModuleDestroy { * Retrieves all keys matching a given pattern. * * @param pattern - A Redis key pattern (e.g., 'online:*'). - * @returns A promise resolving to an array of matching keys. + * @returns Array of matching keys. */ async getKeys(pattern: string) { - return this.client.keys(pattern); + let cursor = '0'; + const foundKeys: string[] = []; + + do { + const [nextCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 100, + ); + cursor = nextCursor; + foundKeys.push(...keys); + } while (cursor !== '0'); + + return foundKeys; + } + + /** + * Retrieves all values for keys matching a given pattern + * + * @param pattern pattern of keys to find + * + * @example getValuesByKeyPattern("user:*") // Get all values, which keys starts with "user:" + */ + async getValuesByKeyPattern(pattern: string) { + const keys = await this.getKeys(pattern); + if (keys.length === 0) return {}; + + const values = await this.client.mget(...keys); + const result: Record = {}; + + keys.forEach((key, index) => { + result[key] = values[index]; + }); + + return result; } /** diff --git a/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts b/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts new file mode 100644 index 000000000..16ee38f8b --- /dev/null +++ b/src/onlinePlayers/dto/InformPlayerIsOnline.dto.ts @@ -0,0 +1,14 @@ +import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; +import { IsEnum, IsOptional } from 'class-validator'; + +export default class InformPlayerIsOnlineDto { + /** + * What players is doing or where the player is in the game. + * + * @example "BattleWait" + * @default "UI" + */ + @IsOptional() + @IsEnum(OnlinePlayerStatus) + status?: OnlinePlayerStatus; +} diff --git a/src/onlinePlayers/dto/onlinePlayer.dto.ts b/src/onlinePlayers/dto/onlinePlayer.dto.ts index c068eaec9..983bab67e 100644 --- a/src/onlinePlayers/dto/onlinePlayer.dto.ts +++ b/src/onlinePlayers/dto/onlinePlayer.dto.ts @@ -1,4 +1,5 @@ import { Expose } from 'class-transformer'; +import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; export default class OnlinePlayerDto { /** @@ -7,7 +8,7 @@ export default class OnlinePlayerDto { * @example "68189c8ce6eda712552911b9" */ @Expose() - id: string; + _id: string; /** * name of the player @@ -16,4 +17,12 @@ export default class OnlinePlayerDto { */ @Expose() name: string; + + /** + * What players is doing or where the player is in the game. + * + * @example "UI" + */ + @Expose() + status: OnlinePlayerStatus; } diff --git a/src/onlinePlayers/enum/OnlinePlayerStatus.ts b/src/onlinePlayers/enum/OnlinePlayerStatus.ts new file mode 100644 index 000000000..1597cc2a5 --- /dev/null +++ b/src/onlinePlayers/enum/OnlinePlayerStatus.ts @@ -0,0 +1,19 @@ +/** + * Represents what players is doing or where the player is in the game. + */ +export enum OnlinePlayerStatus { + /** + * Player is in game UI + */ + UI = 'UI', + + /** + * Player is waiting in battle queue, to join the battle + */ + BATTLE_WAIT = 'BattleWait', + + /** + * Player is playing the battle + */ + BATTLE = 'Battle', +} diff --git a/src/onlinePlayers/onlinePlayers.controller.ts b/src/onlinePlayers/onlinePlayers.controller.ts index f99ea7243..f278d5865 100644 --- a/src/onlinePlayers/onlinePlayers.controller.ts +++ b/src/onlinePlayers/onlinePlayers.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post } from '@nestjs/common'; import { OnlinePlayersService } from './onlinePlayers.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; import OnlinePlayerDto from './dto/onlinePlayer.dto'; +import InformPlayerIsOnlineDto from './dto/InformPlayerIsOnline.dto'; @Controller('online-players') export class OnlinePlayersController { @@ -13,20 +14,23 @@ export class OnlinePlayersController { /** * Inform the API if player is still online * - * @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 5 min. + * @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 1.5 min. * - * So it is recommended to make requests to this endpoint every 4-5 min to properly track the players being online, although not often than that. + * So it is recommended to make requests to this endpoint every 1 min to properly track the players being online, although not often than that. */ @ApiResponseDescription({ success: { status: 204, }, - errors: [401], + errors: [400, 401], }) @Post('ping') @UniformResponse() - async ping(@LoggedUser() user: User) { - return this.onlinePlayersService.addPlayerOnline(user.player_id); + async ping(@Body() body: InformPlayerIsOnlineDto, @LoggedUser() user: User) { + return this.onlinePlayersService.addPlayerOnline({ + player_id: user.player_id, + status: body.status, + }); } /** @@ -45,7 +49,7 @@ export class OnlinePlayersController { errors: [401], }) @Get() - @UniformResponse() + @UniformResponse(null, OnlinePlayerDto) async getAllOnlinePlayers() { return this.onlinePlayersService.getAllOnlinePlayers(); } diff --git a/src/onlinePlayers/onlinePlayers.service.ts b/src/onlinePlayers/onlinePlayers.service.ts index 26d867369..fbd22b0c1 100644 --- a/src/onlinePlayers/onlinePlayers.service.ts +++ b/src/onlinePlayers/onlinePlayers.service.ts @@ -3,11 +3,18 @@ import { CacheKeys } from '../common/service/redis/cacheKeys.enum'; import { PlayerService } from '../player/player.service'; import { IServiceReturn } from '../common/service/basicService/IService'; import { RedisService } from '../common/service/redis/redis.service'; +import { OnlinePlayerStatus } from './enum/OnlinePlayerStatus'; +import AddOnlinePlayer from './payload/AddOnlinePlayer'; +import OnlinePlayer from './payload/OnlinePlayer'; @Injectable() export class OnlinePlayersService { private readonly ONLINE_PLAYERS_KEY = CacheKeys.ONLINE_PLAYERS; - private readonly PLAYER_TTL = 300; // Time-to-live in seconds (5 minutes) + /** + * Time-to-live in seconds (1.5 minutes) + * @private + */ + private readonly PLAYER_TTL_S = 90; constructor( private readonly redisService: RedisService, @@ -17,43 +24,46 @@ export class OnlinePlayersService { /** * Adds a player to the online players list by storing their status in the cache. * - * @param playerId - The unique identifier of the player to be marked as online. - * @returns A promise that resolves when the player's online status is successfully stored. + * @param playerInfo - Information about player to be added + * @returns Nothing or ServiceError if problems to find the player */ - async addPlayerOnline(playerId: string): Promise> { - const [player, error] = await this.playerService.getPlayerById(playerId); - if (error) return [null, error]; + async addPlayerOnline( + playerInfo: AddOnlinePlayer, + ): Promise> { + const { player_id, status } = playerInfo; + + const [player, errors] = await this.playerService.getPlayerById(player_id); + if (errors) return [null, errors]; - const payload = { - id: playerId, + const payload: OnlinePlayer = { + _id: player_id, name: player.name, + status: status ?? OnlinePlayerStatus.UI, }; await this.redisService.set( - `${this.ONLINE_PLAYERS_KEY}:${JSON.stringify(payload)}`, - '1', - this.PLAYER_TTL, + `${this.ONLINE_PLAYERS_KEY}:${player_id}`, + JSON.stringify(payload), + this.PLAYER_TTL_S, ); } /** - * Gets all the online players and returns data as JSON object. + * Gets all the online players array. * * This method fetches all keys from the cache that match the pattern * for online players. * - * @returns Array of player name and id as JSON objects. + * @returns Array of OnlinePlayers or empty array if nothing found */ - async getAllOnlinePlayers(): Promise<{ id: string; name: string }[]> { - const players = await this.redisService.getKeys( + async getAllOnlinePlayers(): Promise { + const players = await this.redisService.getValuesByKeyPattern( `${this.ONLINE_PLAYERS_KEY}:*`, ); if (!players) return []; - return players.map((player) => { - const playerData = player.replace(`${this.ONLINE_PLAYERS_KEY}:`, ''); - return JSON.parse(playerData); - }); + const onlinePlayersStr = Object.values(players); + return onlinePlayersStr.map((playerStr) => JSON.parse(playerStr)); } } diff --git a/src/onlinePlayers/payload/AddOnlinePlayer.ts b/src/onlinePlayers/payload/AddOnlinePlayer.ts new file mode 100644 index 000000000..e8853d887 --- /dev/null +++ b/src/onlinePlayers/payload/AddOnlinePlayer.ts @@ -0,0 +1,15 @@ +import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; + +export default class AddOnlinePlayer { + /** + * Player _id to be added + */ + player_id: string; + + /** + * Player status to set + * + * @default "UI" + */ + status?: OnlinePlayerStatus; +} diff --git a/src/onlinePlayers/payload/OnlinePlayer.ts b/src/onlinePlayers/payload/OnlinePlayer.ts new file mode 100644 index 000000000..9ec0029e7 --- /dev/null +++ b/src/onlinePlayers/payload/OnlinePlayer.ts @@ -0,0 +1,18 @@ +import { OnlinePlayerStatus } from '../enum/OnlinePlayerStatus'; + +export default class OnlinePlayer { + /** + * Player _id + */ + _id: string; + + /** + * Player's name + */ + name: string; + + /** + * Player status + */ + status: OnlinePlayerStatus; +}