diff --git a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts index 36f93f18a..011b1e555 100644 --- a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts +++ b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts @@ -2,16 +2,19 @@ import { BattleResultDto } from '../../../../gameData/dto/battleResult.dto'; import { RequestType } from '../../../../gameData/enum/requestType.enum'; export class BattleResultDtoBuilder { - private readonly base: BattleResultDto = { + private readonly base: any = { + matchId: 'default-match', type: RequestType.RESULT, team1: [], team2: [], duration: 0, + result: 0, winnerTeam: 0, }; - build(): BattleResultDto { - return { ...this.base } as BattleResultDto; + setMatchId(id: string): this { + this.base.matchId = id; + return this; } setType(type: RequestType): this { @@ -29,13 +32,18 @@ export class BattleResultDtoBuilder { return this; } - setDuration(duration: number): this { - this.base.duration = duration; + setDuration(d: number): this { + this.base.duration = d; return this; } - setWinnerTeam(winnerTeam: number): this { - this.base.winnerTeam = winnerTeam; + setWinnerTeam(team: number): this { + this.base.result = team; + this.base.winnerTeam = team; return this; } + + build(): BattleResultDto { + return { ...this.base } as BattleResultDto; + } } diff --git a/src/__tests__/gameData/data/gameData/GameBuilder.ts b/src/__tests__/gameData/data/gameData/GameBuilder.ts index 155f8bfb3..60df8fab0 100644 --- a/src/__tests__/gameData/data/gameData/GameBuilder.ts +++ b/src/__tests__/gameData/data/gameData/GameBuilder.ts @@ -1,5 +1,7 @@ import { Game } from '../../../../gameData/game.schema'; import { ObjectId } from 'mongodb'; +import { BattleStatus } from '../../../../gameData/enum/battleStatus.enum'; +import { GameType } from '../../../../gameData/enum/gameType.enum'; export class GameBuilder { private readonly base: Game = { @@ -10,7 +12,11 @@ export class GameBuilder { winner: 1, startedAt: new Date(), endedAt: new Date(), - _id: undefined, + _id: undefined as unknown as string, + gameType: GameType.CASUAL, + status: BattleStatus.OPEN, + receivedResults: [], + finalWinner: 0, }; build(): Game { @@ -21,39 +27,42 @@ export class GameBuilder { this.base.team1 = team1; return this; } - setTeam2(team2: string[]): this { this.base.team2 = team2; return this; } - setTeam1Clan(team1Clan: string): this { this.base.team1Clan = team1Clan; return this; } - setTeam2Clan(team2Clan: string): this { this.base.team2Clan = team2Clan; return this; } - setWinner(winner: 1 | 2): this { this.base.winner = winner; return this; } - setStartedAt(date: Date): this { this.base.startedAt = date; return this; } - setEndedAt(date: Date): this { this.base.endedAt = date; return this; } - setId(id: string): this { this.base._id = id; return this; } + + setStatus(status: BattleStatus): this { + this.base.status = status; + return this; + } + + setGameType(gameType: GameType): this { + this.base.gameType = gameType; + return this; + } } diff --git a/src/gameData/dto/battleResult.dto.ts b/src/gameData/dto/battleResult.dto.ts index be133492d..b59ee9131 100644 --- a/src/gameData/dto/battleResult.dto.ts +++ b/src/gameData/dto/battleResult.dto.ts @@ -2,8 +2,10 @@ import { IsArray, IsEnum, IsInt, + IsString, IsMongoId, IsPositive, + IsNotEmpty, Max, Min, } from 'class-validator'; @@ -18,6 +20,15 @@ export class BattleResultDto { @IsEnum(RequestType) type: RequestType.RESULT; + /** + * The unique identifier for the battle match. + * This is used to look up the existing game record in the database. + * * @example "665af23e5e982f0013aa9999" + */ + @IsString() + @IsNotEmpty() + matchId: string; + /** * IDs of players in team 1 * @@ -53,5 +64,5 @@ export class BattleResultDto { @IsInt() @Min(1) @Max(2) - winnerTeam: number; + result: number; } diff --git a/src/gameData/dto/startBattle.dto.ts b/src/gameData/dto/startBattle.dto.ts new file mode 100644 index 000000000..38804dd44 --- /dev/null +++ b/src/gameData/dto/startBattle.dto.ts @@ -0,0 +1,41 @@ +import { + IsEnum, + IsArray, + IsMongoId, + IsOptional, + IsString, +} from 'class-validator'; +import { GameType } from '../enum/gameType.enum'; + +export class StartBattleDto { + /** + * Type of the game session + * @example "matchmaking" + */ + @IsEnum(GameType) + gameType: GameType; + + /** + * List of player IDs for Team 1 + * @example ["60f7c2d9a2d3c7b7e56d01df"] + */ + @IsArray() + @IsMongoId({ each: true }) + team1: string[]; + + /** + * List of player IDs for Team 2 + * @example ["60f7c2d9a2d3c7b7e56d01df"] + */ + @IsArray() + @IsMongoId({ each: true }) + team2: string[]; + + /** + * Optional custom match ID. If these are not provided, the server generates one. + * @example "match_12345" + */ + @IsOptional() + @IsString() + matchId?: string; +} diff --git a/src/gameData/dto/submitResult.dto.ts b/src/gameData/dto/submitResult.dto.ts new file mode 100644 index 000000000..ada70cf8d --- /dev/null +++ b/src/gameData/dto/submitResult.dto.ts @@ -0,0 +1,36 @@ +import { + IsString, + IsInt, + IsNotEmpty, + Min, + Max, + IsPositive, +} from 'class-validator'; + +export class SubmitResultDto { + /** + * The unique identifier for the battle match. + * @example "665af23e5e982f0013aa9999" + */ + @IsString() + @IsNotEmpty() + matchId: string; + + /** + * Duration of the battle in seconds. + * @example 120 + */ + @IsInt() + @IsPositive() + duration: number; + + /** + * The result of the battle. + * 1 represents a win for Team 1, 2 represents a win for Team 2. + * @example 1 + */ + @IsInt() + @Min(1) + @Max(2) + result: number; +} diff --git a/src/gameData/enum/battleStatus.enum.ts b/src/gameData/enum/battleStatus.enum.ts new file mode 100644 index 000000000..1cba634a1 --- /dev/null +++ b/src/gameData/enum/battleStatus.enum.ts @@ -0,0 +1,18 @@ +/** + * Defines all the possible states of a battle. + */ +export enum BattleStatus { + /** Match is registered and awaiting results from the players. */ + OPEN = 'OPEN', + + /** * Conflicting results detected or 2+ results received. + * Indicates the conflict resolution timer is active. + */ + PROCESSING = 'PROCESSING', + + /** Results have been validated and rewards have been given. */ + COMPLETED = 'COMPLETED', + + /** No results were received within the 30 minute period. */ + TIMED_OUT = 'TIMED_OUT', +} diff --git a/src/gameData/enum/gameType.enum.ts b/src/gameData/enum/gameType.enum.ts new file mode 100644 index 000000000..7b75330ac --- /dev/null +++ b/src/gameData/enum/gameType.enum.ts @@ -0,0 +1,5 @@ +export enum GameType { + MATCHMAKING = 'matchmaking', + CASUAL = 'casual', + CUSTOM = 'custom', +} diff --git a/src/gameData/game.schema.ts b/src/gameData/game.schema.ts index 214deb24c..1638879d4 100644 --- a/src/gameData/game.schema.ts +++ b/src/gameData/game.schema.ts @@ -2,6 +2,7 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; import { ModelName } from '../common/enum/modelName.enum'; import { ExtractField } from '../common/decorator/response/ExtractField'; +import { BattleStatus } from './enum/battleStatus.enum'; export type GameDocument = HydratedDocument; @@ -23,32 +24,79 @@ export class Game { @Prop({ type: MongooseSchema.Types.ObjectId, - required: true, + required: false, // not required anymore due to a business logic change ref: ModelName.CLAN, }) - team1Clan: string; + team1Clan?: string; @Prop({ type: MongooseSchema.Types.ObjectId, - required: true, + required: false, // not required anymore due to a business logic change ref: ModelName.CLAN, }) - team2Clan: string; + team2Clan?: string; + + @Prop({ type: String, required: true, default: 'REGULAR' }) + gameType: string; + + @Prop({ type: String, enum: BattleStatus, default: BattleStatus.OPEN }) + status: BattleStatus; + + @Prop({ + type: [ + { + playerId: { + type: MongooseSchema.Types.ObjectId, + ref: ModelName.PLAYER, + }, + winnerTeam: Number, + duration: Number, + receivedAt: { type: Date, default: Date.now }, + }, + ], + default: [], + }) + receivedResults: { + playerId: string; + winnerTeam: number; + duration: number; + receivedAt?: Date; + }[]; + + @Prop({ type: Number }) + finalWinner: number; - @Prop({ type: Number, enum: [1, 2], required: true }) - winner: number; + @Prop({ type: Number, enum: [1, 2] }) + winner?: number; - @Prop({ type: Date, required: true }) - startedAt: Date; + @Prop({ type: Date }) + startedAt?: Date; - @Prop({ type: Date, required: true }) - endedAt: Date; + @Prop({ type: Date }) + endedAt?: Date; @ExtractField() _id: string; } export const GameSchema = SchemaFactory.createForClass(Game); + +// Making sure players can't be on both teams at the same time +GameSchema.pre('validate', function (next) { + const t1 = (this.team1 || []).map((id) => id.toString()); + const t2 = (this.team2 || []).map((id) => id.toString()); + + const intersection = t1.filter((id) => t2.includes(id)); + if (intersection.length > 0) { + return next( + new Error( + `Player ${intersection[0]} cannot be on both teams at the same time`, + ), + ); + } + next(); +}); + GameSchema.set('collection', ModelName.GAME); GameSchema.virtual(ModelName.PLAYER + '1', { ref: ModelName.PLAYER, diff --git a/src/gameData/gameData.controller.ts b/src/gameData/gameData.controller.ts index 817036049..97b118042 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -1,4 +1,9 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { + Body, + Controller, + Post, + Put +} from '@nestjs/common'; import { GameDataService } from './gameData.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; @@ -11,6 +16,8 @@ import { RequestTypeDto } from './dto/requestType.dto'; import { BattleResultDto } from './dto/battleResult.dto'; import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; import { BattleResponseDto } from './dto/battleResponse.dto'; +import { StartBattleDto } from './dto/startBattle.dto'; +import { SubmitResultDto } from './dto/submitResult.dto'; @Controller('gameData') export class GameDataController { @@ -67,4 +74,53 @@ export class GameDataController { return new APIError({ reason: APIErrorReason.BAD_REQUEST }); } } + + /** + * Initialize a new battle record + * * @remarks This endpoint is used to register the start of a battle. + * It creates a record in the database with the initial participants and returns the unique match ID. + * * This match ID must be stored by the client and used in the `PUT battle/result` call. + */ + @ApiResponseDescription({ + success: { + status: 201, + }, + errors: [400, 401], + }) + @Post('battle/start') + async startBattle(@Body() startBattleDto: StartBattleDto) { + return this.service.registerBattle(startBattleDto); + } + + /** + * Submit player battle result + * * @remarks Endpoint for players to report the outcome of a specific match. + * * The logic will compare the result with other players in the same matchId. + * If all results match, the battle is finalized and rewards are calculated. + * * Notice that if results conflict, the battle status will move to "PROCESSING" + * for further verification. + * * @param user - The authenticated player submitting the result. + * @param dto - Contains the matchId, winning team, and match duration. + */ + @ApiResponseDescription({ + success: { + dto: BattleResponseDto, + }, + errors: [400, 401, 403, 404], + }) + @Put('battle/result') + async submitResult( + @LoggedUser() user: User, + @Body() SubmitResultDto: SubmitResultDto, + ) { + const legacyDto = new BattleResultDto(); + legacyDto.matchId = SubmitResultDto.matchId; + legacyDto.result = SubmitResultDto.result; + legacyDto.duration = SubmitResultDto.duration; + + return this.service.handleBattleResult( + legacyDto as BattleResultDto, + user.player_id, + ); + } } diff --git a/src/gameData/gameData.service.ts b/src/gameData/gameData.service.ts index 4625a9709..748e69aea 100644 --- a/src/gameData/gameData.service.ts +++ b/src/gameData/gameData.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Game } from './game.schema'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import BasicService from '../common/service/basicService/BasicService'; import { CreateGameDto } from './dto/createGame.dto'; import { PlayerService } from '../player/player.service'; @@ -10,6 +10,9 @@ import { JwtService } from '@nestjs/jwt'; import { ClanService } from '../clan/clan.service'; import { ModelName } from '../common/enum/modelName.enum'; import { BattleResultDto } from './dto/battleResult.dto'; +import { StartBattleDto } from './dto/startBattle.dto'; +import { BattleStatus } from './enum/battleStatus.enum'; +import { GameDocument } from './game.schema'; import { User } from '../auth/user'; import { GameDto } from './dto/game.dto'; import { BattleResponseDto } from './dto/battleResponse.dto'; @@ -55,7 +58,7 @@ export class GameDataService { const currentTime = new Date(); const winningTeam = - battleResult.winnerTeam === 1 ? battleResult.team1 : battleResult.team2; + battleResult.result === 1 ? battleResult.team1 : battleResult.team2; const playerInWinningTeam = winningTeam.includes(user.player_id); if (!playerInWinningTeam) { @@ -131,7 +134,7 @@ export class GameDataService { team2: battleResult.team2, team1Clan: team1Id, team2Clan: team2Id, - winner: battleResult.winnerTeam, + winner: battleResult.result, startedAt: new Date(currentTime.getTime() - battleResult.duration * 1000), endedAt: currentTime, }; @@ -154,7 +157,7 @@ export class GameDataService { user: User, ): Promise<[BattleResponseDto, ServiceError[]]> { const [clan, errors] = await this.clanService.readOneById( - battleResult.winnerTeam === 1 ? team2ClanId : team1ClanId, + battleResult.result === 1 ? team2ClanId : team1ClanId, { includeRefs: [ModelName.SOULHOME] }, ); if (errors) { @@ -308,4 +311,111 @@ export class GameDataService { return await this.createOne(newGame); } } + + /** + * Initializes a new battle record in the database. + * Sets the initial status to OPEN. + * * @param dto - The data required to start a battle, including the matchId and teams. + * @returns A promise resolving to the created Battle document. + */ + async registerBattle(dto: StartBattleDto): Promise { + const matchId = dto.matchId || new Types.ObjectId().toHexString(); + const newBattle = new this.model({ + _id: matchId, + gameType: 'BATTLE', + ...dto, + status: BattleStatus.OPEN, + receivedResults: [], + }); + return newBattle.save(); + } + + /** + * Processes a result claim from a player. + * If results from both teams match, the battle is marked COMPLETED. + * If results don't match, the battle enters PROCESSING and triggers a timeout-based resolution. + * * @param dto - The result containing matchId, winning team, and duration. + * @param playerId - The ID of the player submitting the result. + * @returns A promise to the updated Battle document. + * @throws Error if the matchId is not found in the database. + */ + async handleBattleResult(dto: BattleResultDto, playerId: string) { + const battle = await this.model.findById(dto.matchId); + if (!battle) throw new Error('Match not found'); + + battle.receivedResults.push({ + playerId, + winnerTeam: dto.result, + duration: dto.duration, + }); + + if (battle.receivedResults.length >= 2) { + const results = battle.receivedResults.map((r) => r.winnerTeam); + const allMatch = results.every((val) => val === results[0]); + + if (allMatch) { + battle.status = BattleStatus.COMPLETED; + battle.finalWinner = results[0]; + await battle.save(); + return await this.generateRaidTokens(battle); + } else { + battle.status = BattleStatus.PROCESSING; + this.startFinalCallTimer(battle._id.toString()); + } + } else { + battle.status = BattleStatus.OPEN; + } + + return await battle.save(); + } + + /** + * Distributes rewards (Raid Tokens) to the members of the winning team. + * This method is triggered only when a final winner has been determined. + * * @param battle - The validated Battle document with a set finalWinner. + * @returns A promise resolving to the saved Battle document after reward distribution. + * @private + */ + private async generateRaidTokens(battle: GameDocument) { + const winners = + (battle.finalWinner === 1 ? battle.team1 : battle.team2) || []; + + return await battle.save(); + } + + /** + * Triggers the "Final Call" timer for conflicting battle results. + * Waits for 2 minutes before forcibly resolving the conflict. + * * @param matchId - The unique identifier for the match in conflict. + * @private + */ + private startFinalCallTimer(matchId: string) { + setTimeout(() => { + this.resolveConflict(matchId); + }, 120000); + } + + /** + * Forcibly resolves a battle conflict after the "Final Call" period. + * Uses a majority vote based on received results and defaults to Team 1 if tied. + * * @param matchId - The unique identifier of the battle to resolve. + * @returns A promise that resolves once the conflict is settled and rewards are issued. + * @private + */ + private async resolveConflict(matchId: string) { + const battle = await this.model.findOne({ matchId }); + if (!battle || battle.status === BattleStatus.COMPLETED) return; + + const results = battle.receivedResults; + const team1Votes = results.filter((r) => r.winnerTeam === 1).length; + const team2Votes = results.filter((r) => r.winnerTeam === 2).length; + + const finalWinner = team2Votes > team1Votes ? 2 : 1; + + battle.status = BattleStatus.COMPLETED; + battle.finalWinner = finalWinner; + await battle.save(); + + await this.generateRaidTokens(battle); + } }