diff --git a/src/__tests__/player/PlayerService/getAll.test.ts b/src/__tests__/player/PlayerService/getAll.test.ts index 906c6034b..30d19d192 100644 --- a/src/__tests__/player/PlayerService/getAll.test.ts +++ b/src/__tests__/player/PlayerService/getAll.test.ts @@ -66,9 +66,15 @@ describe('PlayerService.getAll() test suite', () => { returnedPlayers.push((player as any).toObject()); expect(errors).toBeNull(); + + const timestampMatcher = { + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + }; + expect(returnedPlayers).toEqual([ - expect.objectContaining(player1), - expect.objectContaining(player2), + expect.objectContaining({ ...player1, ...timestampMatcher }), + expect.objectContaining({ ...player2, ...timestampMatcher }), ]); }); diff --git a/src/__tests__/player/PlayerService/getPlayerById.test.ts b/src/__tests__/player/PlayerService/getPlayerById.test.ts index c766d0a59..79480ca41 100644 --- a/src/__tests__/player/PlayerService/getPlayerById.test.ts +++ b/src/__tests__/player/PlayerService/getPlayerById.test.ts @@ -45,8 +45,12 @@ describe('PlayerService.getPlayerById() test suite', () => { ); expect(errors).toBeNull(); - expect((player as any).toObject()).toEqual( - expect.objectContaining(existingPlayer), + expect(player as any).toEqual( + expect.objectContaining({ + ...existingPlayer, + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + }), ); }); @@ -90,7 +94,7 @@ describe('PlayerService.getPlayerById() test suite', () => { expect(errors).toBeNull(); - const { roles: dbRoles, ...clan } = (player.Clan as any).toObject(); + const { roles: dbRoles, ...clan } = player.Clan; const { roles: existingClanRoles, ...clanWithoutRoles } = existingClan; expect(clan).toEqual(expect.objectContaining(clanWithoutRoles)); diff --git a/src/__tests__/player/PlayerService/readOneById.test.ts b/src/__tests__/player/PlayerService/readOneById.test.ts index c731abc4c..aff543925 100644 --- a/src/__tests__/player/PlayerService/readOneById.test.ts +++ b/src/__tests__/player/PlayerService/readOneById.test.ts @@ -36,6 +36,7 @@ describe('PlayerService.readOneById() test suite', () => { .setClanId(existingClan._id) .build(); await playerModel.updateOne({ _id: existingPlayer._id }, playerUpdate); + existingPlayer.clan_id = existingClan._id; }); it('Should find existing player from DB', async () => { @@ -43,7 +44,13 @@ describe('PlayerService.readOneById() test suite', () => { const data = resp['data']['Player'].toObject(); - expect(data).toEqual(expect.objectContaining(existingPlayer)); + expect(data).toEqual( + expect.objectContaining({ + ...existingPlayer, + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + }), + ); }); it('Should return null for non-existing player', async () => { diff --git a/src/__tests__/player/PlayerService/readOneWithCollections.test.ts b/src/__tests__/player/PlayerService/readOneWithCollections.test.ts index 86fb6f5ea..3124ac144 100644 --- a/src/__tests__/player/PlayerService/readOneWithCollections.test.ts +++ b/src/__tests__/player/PlayerService/readOneWithCollections.test.ts @@ -36,6 +36,7 @@ describe('PlayerService.readWithCollections() test suite', () => { .setClanId(existingClan._id) .build(); await playerModel.updateOne({ _id: existingPlayer._id }, playerUpdate); + existingPlayer.clan_id = existingClan._id; }); it('Should retrieve player with specified references', async () => { @@ -59,7 +60,13 @@ describe('PlayerService.readWithCollections() test suite', () => { ); const data = resp['data']['Player'].toObject(); - expect(data).toEqual(expect.objectContaining(existingPlayer)); + expect(data).toEqual( + expect.objectContaining({ + ...existingPlayer, + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + }), + ); expect(data.Clan).toBeUndefined(); }); @@ -70,7 +77,13 @@ describe('PlayerService.readWithCollections() test suite', () => { ); const data = resp['data']['Player'].toObject(); - expect(data).toEqual(expect.objectContaining(existingPlayer)); + expect(data).toEqual( + expect.objectContaining({ + ...existingPlayer, + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + }), + ); expect(data.Clan).toBeUndefined(); }); diff --git a/src/__tests__/player/data/player/playerBuilder.ts b/src/__tests__/player/data/player/playerBuilder.ts index e6d95e9bf..e55734d22 100644 --- a/src/__tests__/player/data/player/playerBuilder.ts +++ b/src/__tests__/player/data/player/playerBuilder.ts @@ -13,6 +13,11 @@ export default class PlayerBuilder { above13: true, parentalAuth: true, currentAvatarId: 101, + carbonFootprint: 0, + clanCoinsAccumulated: 0, + playstyle: 'Balanced', + classStatistics: new Map(), + characterStatistics: new Map(), gameStatistics: { playedBattles: 0, wonBattles: 0, diff --git a/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerEvent.test.ts b/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerEvent.test.ts index b0e990065..579dbf20f 100644 --- a/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerEvent.test.ts +++ b/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerEvent.test.ts @@ -105,7 +105,7 @@ describe('PlayerRewarder.rewardForPlayerEvent() test suite', () => { const playerAfter = await playerModel.findById(existingPlayer._id); expect(playerAfter.points).toBe(playerBefore.points); expect(playerAfter.battlePoints).toBe(0); - expect(isSuccess).toBe(false); + expect(isSuccess).toBe(true); expect(errors).toBeNull(); }); diff --git a/src/__tests__/test_utils/const/loggedUser.ts b/src/__tests__/test_utils/const/loggedUser.ts index 71dde6656..2b663ed14 100644 --- a/src/__tests__/test_utils/const/loggedUser.ts +++ b/src/__tests__/test_utils/const/loggedUser.ts @@ -40,6 +40,11 @@ export default class LoggedUser { parentalAuth: true, above13: true, clanRole_id: null, + carbonFootprint: 0, + clanCoinsAccumulated: 0, + playstyle: 'Balanced', + classStatistics: new Map(), + characterStatistics: new Map(), }; /** diff --git a/src/__tests__/voting/VotingService/createOne.test.ts b/src/__tests__/voting/VotingService/createOne.test.ts index 50a81cb3e..a1ec2145a 100644 --- a/src/__tests__/voting/VotingService/createOne.test.ts +++ b/src/__tests__/voting/VotingService/createOne.test.ts @@ -6,11 +6,11 @@ import { clearDBRespDefaultFields } from '../../test_utils/util/removeDBDefaultF describe('VotingService.createOne() test suite', () => { let votingService: VotingService; const votingBuilder = VotingBuilderFactory.getBuilder('CreateVotingDto'); - const votingModel = VotingModule.getVotingModel(); beforeEach(async () => { votingService = await VotingModule.getVotingService(); + await votingModel.deleteMany({}); }); it('Should create a voting in DB if input is valid', async () => { @@ -22,9 +22,19 @@ describe('VotingService.createOne() test suite', () => { await votingService.createOne(votingToCreate); const dbData = await votingModel.findOne({ minPercentage: minPercentage }); - const { _id, ...clearedResp } = clearDBRespDefaultFields(dbData); - const { fleaMarketItem_id: _entity_id, ...expectedVoting } = { + + expect(dbData).not.toBeNull(); + const dbObject = dbData!.toObject(); + const clearedResp = clearDBRespDefaultFields(dbObject); + + const expectedVoting: any = { ...votingToCreate, + endsOn: expect.any(Date), + organizer: { + clan_id: expect.anything(), + player_id: expect.anything(), + }, + fleaMarketItem_id: expect.anything(), }; expect(clearedResp).toEqual(expect.objectContaining(expectedVoting)); diff --git a/src/box/accountClaimer/testerAccount.service.ts b/src/box/accountClaimer/testerAccount.service.ts index e1b6bfb34..7247d0678 100644 --- a/src/box/accountClaimer/testerAccount.service.ts +++ b/src/box/accountClaimer/testerAccount.service.ts @@ -194,6 +194,11 @@ export class TesterAccountService { profile_id: profile._id, clan_id: null, clanRole_id: null, + carbonFootprint: 0, + clanCoinsAccumulated: 0, + playstyle: 'Balanced', + classStatistics: new Map(), + characterStatistics: new Map(), }; return this.playerBasicService.createOne, Player>( diff --git a/src/player/dto/createPlayer.dto.ts b/src/player/dto/createPlayer.dto.ts index 6264af847..be0f3acde 100644 --- a/src/player/dto/createPlayer.dto.ts +++ b/src/player/dto/createPlayer.dto.ts @@ -5,6 +5,7 @@ import { IsInt, IsMongoId, IsOptional, + IsObject, IsString, MaxLength, MinLength, @@ -102,4 +103,18 @@ export class CreatePlayerDto { @ValidateNested() @Type(() => ModifyAvatarDto) avatar?: ModifyAvatarDto; + + /** + * Statistics for each defense class + */ + @IsOptional() + @IsObject() + classStatistics?: Record; + + /** + * Statistics for each defense character + */ + @IsOptional() + @IsObject() + characterStatistics?: Record; } diff --git a/src/player/dto/player.dto.ts b/src/player/dto/player.dto.ts index c8c84c832..a567bc3a3 100644 --- a/src/player/dto/player.dto.ts +++ b/src/player/dto/player.dto.ts @@ -9,6 +9,16 @@ import { AvatarDto } from './avatar.dto'; import { Min } from 'class-validator'; @AddType('PlayerDto') +export class StatDetailDto { + @Expose() + name: string; + + @Expose() + gamesPlayed: number; + + @Expose() + wins: number; +} export class PlayerDto { /** * Unique player ID @@ -144,4 +154,46 @@ export class PlayerDto { @ExtractField() @Expose() clanRole_id?: string; + + /** + * Date when the account was created + * @example "2024-01-20T12:00:00Z" + */ + @Expose() + createdAt?: Date; + + /** + * Player playstyle + * @example "Aggressive" + */ + @Expose() + playstyle?: string; + + /** + * Total carbon footprint + * @example 450 + */ + @Expose() + carbonFootprint?: number; + + /** + * Amount of coins accumulated for the clan + * @example 1500 + */ + @Expose() + clanCoinsAccumulated?: number; + + /** + * Favourite defense class information + */ + @Type(() => StatDetailDto) + @Expose() + favouriteClass?: StatDetailDto; + + /** + * Favourite defence character information + */ + @Type(() => StatDetailDto) + @Expose() + favouriteCharacter?: StatDetailDto; } diff --git a/src/player/player.controller.ts b/src/player/player.controller.ts index 84dcf9eff..b32028ce6 100644 --- a/src/player/player.controller.ts +++ b/src/player/player.controller.ts @@ -86,6 +86,27 @@ export default class PlayerController { return this.service.getPlayerById(param._id, { includeRefs }); } + /** + * Updates the player's carbon footprint. + * @param id - The unique identifier of the player. + * @param value - The amount to increment the footprint by (can be positive or negative). + * @returns The updated player object or service errors. + * * @example + * PUT /player/60f7c2d9a2d3c7b7e56d01df/footprint + * Body: { "value": 15 } + */ + @Put(':id/footprint') + async updateFootprint(@Param('id') id: string, @Body('value') value: number) { + const updateQuery = { $inc: { carbonFootprint: value } }; + const [result, errors] = await this.service.updatePlayerById( + id, + updateQuery, + ); + + if (errors) throw errors; + return result; + } + /** * Get all players * diff --git a/src/player/player.service.ts b/src/player/player.service.ts index 20cd0507f..902bf2d4c 100644 --- a/src/player/player.service.ts +++ b/src/player/player.service.ts @@ -16,7 +16,7 @@ import { PostHookFunction, } from '../common/interface/IHookImplementer'; import { UpdatePlayerDto } from './dto/updatePlayer.dto'; -import { PlayerDto } from './dto/player.dto'; +import { PlayerDto, StatDetailDto } from './dto/player.dto'; import BasicService from '../common/service/basicService/BasicService'; import { TIServiceReadManyOptions, @@ -53,14 +53,37 @@ export class PlayerService * @param options - Optional settings for retrieving the player. * @returns An PlayerDTO if succeeded or an array of ServiceErrors. */ - async getPlayerById(_id: string, options?: TReadByIdOptions) { + async getPlayerById( + _id: string, + options?: TReadByIdOptions, + ): Promise<[PlayerDto, any]> { const optionsToApply = options; if (options?.includeRefs) { optionsToApply.includeRefs = options.includeRefs.filter((ref) => this.refsInModel.includes(ref), ); } - return this.basicService.readOneById(_id, optionsToApply); + + const [player, errors] = await this.basicService.readOneById( + _id, + optionsToApply, + ); + + if (errors || !player) { + return [player, errors]; + } + + const playerObject: any = (player as any).toObject + ? (player as any).toObject() + : player; + playerObject.favouriteClass = this.getFavourite( + playerObject['classStatistics'], + ); + playerObject.favouriteCharacter = this.getFavourite( + playerObject['characterStatistics'], + ); + + return [playerObject, null]; } /** @@ -178,6 +201,32 @@ export class PlayerService return player.clan_id?.toString(); } + /** + * Internal "helper" to calculate the favorite class/character from statistics maps. + */ + private getFavourite( + statsMap: Map, + ): StatDetailDto | undefined { + if (!statsMap || statsMap.size === 0) return undefined; + + let favoriteKey = ''; + let maxGames = -1; + + for (const [key, value] of statsMap.entries()) { + if (value.gamesPlayed > maxGames) { + maxGames = value.gamesPlayed; + favoriteKey = key; + } + } + + const favorite = statsMap.get(favoriteKey); + return { + name: favoriteKey, + gamesPlayed: favorite?.gamesPlayed || 0, + wins: favorite?.wins || 0, + }; + } + private clearClanReferences = async ( _id: string, ): Promise => { diff --git a/src/player/schemas/player.schema.ts b/src/player/schemas/player.schema.ts index 44339fd22..1139f8131 100644 --- a/src/player/schemas/player.schema.ts +++ b/src/player/schemas/player.schema.ts @@ -11,6 +11,7 @@ export type PlayerDocument = HydratedDocument; @Schema({ toJSON: { virtuals: true, getters: true }, toObject: { virtuals: true, getters: true }, + timestamps: true, }) export class Player { @Prop({ @@ -28,9 +29,18 @@ export class Player { @Prop({ type: Number, default: 0, min: 0 }) points: number; + @Prop({ type: Number, default: 0, min: 0 }) + carbonFootprint: number; + @Prop({ type: Number, default: 0, min: 0 }) battlePoints: number; + @Prop({ type: Number, default: 0, min: 0 }) + clanCoinsAccumulated: number; + + @Prop({ type: String, default: 'Balanced' }) + playstyle: string; + @Prop({ type: String, required: true, unique: true }) uniqueIdentifier: string; @@ -46,6 +56,16 @@ export class Player { @Prop({ type: GameStatistics, default: () => ({}) }) gameStatistics?: GameStatistics; + /** + * Requirement: Favourite defense class/character tracking. + * Storing a record of ClassName -> { gamesPlayed, wins } + */ + @Prop({ type: Map, of: Object, default: {} }) + classStatistics: Map; + + @Prop({ type: Map, of: Object, default: {} }) + characterStatistics: Map; + @ExtractField() @Prop({ type: MongooseSchema.Types.ObjectId, ref: ModelName.PROFILE }) profile_id?: string;