From d4b5b65a8bbc434715cfb01f166baa962ed1b412 Mon Sep 17 00:00:00 2001 From: CapoMK25 Date: Tue, 17 Feb 2026 21:31:21 +0200 Subject: [PATCH 1/5] Implemented tested endpoints --- .../gameData/battle/battle.service.test.ts | 69 +++++++++++ .../data/gameData/BattleResultDtoBuilder.ts | 25 ++-- src/gameData/battle/battle.controller.ts | 25 ++++ src/gameData/battle/battle.module.ts | 15 +++ src/gameData/battle/battle.service.ts | 116 ++++++++++++++++++ src/gameData/battle/dto/battleResult.dto.ts | 25 ++++ src/gameData/battle/dto/startBattle.dto.ts | 35 ++++++ src/gameData/battle/enum/battleStatus.enum.ts | 18 +++ src/gameData/battle/enum/gameType.enum.ts | 5 + src/gameData/battle/schema/battle.schema.ts | 103 ++++++++++++++++ src/gameData/gameData.module.ts | 2 + 11 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/gameData/battle/battle.service.test.ts create mode 100644 src/gameData/battle/battle.controller.ts create mode 100644 src/gameData/battle/battle.module.ts create mode 100644 src/gameData/battle/battle.service.ts create mode 100644 src/gameData/battle/dto/battleResult.dto.ts create mode 100644 src/gameData/battle/dto/startBattle.dto.ts create mode 100644 src/gameData/battle/enum/battleStatus.enum.ts create mode 100644 src/gameData/battle/enum/gameType.enum.ts create mode 100644 src/gameData/battle/schema/battle.schema.ts diff --git a/src/__tests__/gameData/battle/battle.service.test.ts b/src/__tests__/gameData/battle/battle.service.test.ts new file mode 100644 index 000000000..3373505d7 --- /dev/null +++ b/src/__tests__/gameData/battle/battle.service.test.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; +import { BattleService } from '../../../gameData/battle/battle.service'; +import { Battle } from '../../../gameData/battle/schema/battle.schema'; +import { BattleStatus } from '../../../gameData/battle/enum/battleStatus.enum'; +import { BattleResultDtoBuilder } from '../data/gameData/BattleResultDtoBuilder'; + +describe('BattleService', () => { + let service: BattleService; + let model: any; + + const mockBattleModel = { + findOne: jest.fn(), + new: jest.fn().mockResolvedValue({ save: jest.fn() }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BattleService, + { + provide: getModelToken(Battle.name), + useValue: mockBattleModel, + }, + ], + }).compile(); + + service = module.get(BattleService); + model = module.get(getModelToken(Battle.name)); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('should trigger the 2-minute "Final Call" on conflicting results', async () => { + const fakeBattle: any = { + matchId: 'm1', + receivedResults: [], + status: BattleStatus.OPEN, + team1: ['pA'], + team2: ['pB'], + save: jest.fn().mockImplementation(function(this: any) { + return Promise.resolve(this); + }), + }; + + model.findOne.mockReturnValue(Promise.resolve(fakeBattle)); + + const playerAResult = new BattleResultDtoBuilder().setWinnerTeam(1).build(); + const playerBResult = new BattleResultDtoBuilder().setWinnerTeam(2).build(); + + await service.handleBattleResult(playerAResult as any, 'pA'); + await service.handleBattleResult(playerBResult as any, 'pB'); + + expect(fakeBattle.status).toBe(BattleStatus.PROCESSING); + + jest.runOnlyPendingTimers(); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fakeBattle.status).toBe(BattleStatus.COMPLETED); +}, 15000); +}); \ No newline at end of file diff --git a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts index 36f93f18a..2734f438e 100644 --- a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts +++ b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts @@ -2,16 +2,20 @@ 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 +33,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; + } +} \ No newline at end of file diff --git a/src/gameData/battle/battle.controller.ts b/src/gameData/battle/battle.controller.ts new file mode 100644 index 000000000..a575ce55c --- /dev/null +++ b/src/gameData/battle/battle.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Post, UseGuards, Request } from '@nestjs/common'; +import { BattleService } from './battle.service'; +import { StartBattleDto } from './dto/startBattle.dto'; +import { BattleResultDto } from './dto/battleResult.dto'; +import { AuthGuard } from '../../auth/auth.guard'; + +@Controller('gamedata/battle') +@UseGuards(AuthGuard) +export class BattleController { + constructor(private readonly battleService: BattleService) {} + + @Post('start') + async startBattle(@Body() startBattleDto: StartBattleDto) { + return this.battleService.registerBattle(startBattleDto); + } + + @Post('result') + async submitResult( + @Request() req, + @Body() battleResultDto: BattleResultDto + ) { + const playerId = req.user.playerId; + return this.battleService.handleBattleResult(battleResultDto, playerId); + } +} \ No newline at end of file diff --git a/src/gameData/battle/battle.module.ts b/src/gameData/battle/battle.module.ts new file mode 100644 index 000000000..3e0eb002d --- /dev/null +++ b/src/gameData/battle/battle.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { BattleController } from './battle.controller'; +import { BattleService } from './battle.service'; +import { Battle, BattleSchema } from './schema/battle.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Battle.name, schema: BattleSchema }]), + ], + controllers: [BattleController], + providers: [BattleService], + exports: [BattleService], +}) +export class BattleModule {} \ No newline at end of file diff --git a/src/gameData/battle/battle.service.ts b/src/gameData/battle/battle.service.ts new file mode 100644 index 000000000..1f7fe6eb4 --- /dev/null +++ b/src/gameData/battle/battle.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Battle, BattleDocument } from './schema/battle.schema'; +import { StartBattleDto } from './dto/startBattle.dto'; +import { BattleResultDto } from './dto/battleResult.dto'; +import { BattleStatus } from './enum/battleStatus.enum'; + +@Injectable() +export class BattleService { + constructor( + @InjectModel(Battle.name) private battleModel: Model, + ) {} + + /** + * Initializes a new battle record in the database. + * Sets the initial status to OPEN. + * * @param dto - The data required to start a battle, including matchId and teams. + * @returns A promise resolving to the created Battle document. + */ + async registerBattle(dto: StartBattleDto): Promise { + const newBattle = new this.battleModel({ + ...dto, + status: BattleStatus.OPEN, + }); + return newBattle.save(); + } + + /** + * Processes a result claim from a player. + * If results from both teams match, the battle is marked COMPLETED. + * If results conflict, the battle enters PROCESSING and triggers a timeout-based resolution. + * * @param dto - The result payload containing matchId, winning team, and duration. + * @param playerId - The ID of the player submitting the result. + * @returns A promise resolving 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.battleModel.findOne({ matchId: 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.matchId); + } + } else { + battle.status = BattleStatus.OPEN; + } + + 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); + } + + /** + * 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: BattleDocument) { + const winners = (battle.finalWinner === 1 ? battle.team1 : battle.team2) || []; + + return await battle.save(); + } + + /** + * Forcibly resolves a battle conflict after the "Final Call" period. + * Uses a majority vote based on received results; 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.battleModel.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); + } +} \ No newline at end of file diff --git a/src/gameData/battle/dto/battleResult.dto.ts b/src/gameData/battle/dto/battleResult.dto.ts new file mode 100644 index 000000000..2b831ccfa --- /dev/null +++ b/src/gameData/battle/dto/battleResult.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsString, IsNotEmpty, IsNumber, IsPositive, Min, Max } from 'class-validator'; + +export class BattleResultDto { + /** + * The ID of the match generated during /start + */ + @IsNotEmpty() + @IsString() + matchId: string; + + /** + * Duration of the match in seconds + */ + @IsNumber() + @IsPositive() + duration: number; + + /** + * Which team won? (1 for Team 1, 2 for Team 2) + */ + @IsInt() + @Min(1) + @Max(2) + result: number; +} \ No newline at end of file diff --git a/src/gameData/battle/dto/startBattle.dto.ts b/src/gameData/battle/dto/startBattle.dto.ts new file mode 100644 index 000000000..b0b82bdcb --- /dev/null +++ b/src/gameData/battle/dto/startBattle.dto.ts @@ -0,0 +1,35 @@ +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 not provided, server generates one. + * @example "match_12345" + */ + @IsOptional() + @IsString() + matchId?: string; +} \ No newline at end of file diff --git a/src/gameData/battle/enum/battleStatus.enum.ts b/src/gameData/battle/enum/battleStatus.enum.ts new file mode 100644 index 000000000..a2ab92107 --- /dev/null +++ b/src/gameData/battle/enum/battleStatus.enum.ts @@ -0,0 +1,18 @@ +/** + * Defines the possible states of a battle lifecycle. + */ +export enum BattleStatus { + /** Match is registered and awaiting results from 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 distributed. */ + COMPLETED = 'COMPLETED', + + /** No results were received within the 30-minute grace period. */ + TIMED_OUT = 'TIMED_OUT', +} \ No newline at end of file diff --git a/src/gameData/battle/enum/gameType.enum.ts b/src/gameData/battle/enum/gameType.enum.ts new file mode 100644 index 000000000..9db608da0 --- /dev/null +++ b/src/gameData/battle/enum/gameType.enum.ts @@ -0,0 +1,5 @@ +export enum GameType { + MATCHMAKING = 'matchmaking', + CASUAL = 'casual', + CUSTOM = 'custom', +} \ No newline at end of file diff --git a/src/gameData/battle/schema/battle.schema.ts b/src/gameData/battle/schema/battle.schema.ts new file mode 100644 index 000000000..1309fd954 --- /dev/null +++ b/src/gameData/battle/schema/battle.schema.ts @@ -0,0 +1,103 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; +import { ModelName } from '../../../common/enum/modelName.enum'; +import { GameType } from '../enum/gameType.enum'; +import { BattleStatus } from '../enum/battleStatus.enum'; + +/** + * Type definition for a Battle document hydrated with Mongoose methods. + */ +export type BattleDocument = HydratedDocument; + +/** + * Represents a competitive match record in the Altzone system. + * Tracks player participation, submitted results, and final resolution. + */ +@Schema({ timestamps: true }) +export class Battle { + /** * Unique identifier for the match. + * Generated automatically if not provided during initialization. + */ + @Prop({ type: String, required: true, unique: true }) + matchId: string; + + /** The category or mode of the game played (e.g., RANKED, CASUAL). */ + @Prop({ type: String, enum: GameType, required: true }) + gameType: GameType; + + /** The current state of the match in the "Odd Man Out" lifecycle. */ + @Prop({ type: String, enum: BattleStatus, default: BattleStatus.OPEN }) + status: BattleStatus; + + /** * The confirmed winning team (1 or 2). + * Populated only when the status transitions to COMPLETED. + */ + @Prop({ type: Number }) + finalWinner?: number; + + /** * Collection of results reported by individual players. + * Used to detect conflicts and calculate the final winner via majority vote. + */ + @Prop({ + type: [{ + playerId: { type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }, + winnerTeam: Number, // 1 or 2 + duration: Number, + receivedAt: { type: Date, default: Date.now } + }], + default: [] + }) + receivedResults: { + /** Reference to the Player who submitted the result. */ + playerId: string; + /** The team claimed as the winner by this player. */ + winnerTeam: number; + /** The reported duration of the match session. */ + duration: number; + /** Timestamp of when this specific result was received. */ + receivedAt?: Date; + }[]; + + /** List of Player ObjectIds belonging to Team 1. */ + @Prop({ + type: [{ type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }], + required: true, + }) + team1: string[]; + + /** List of Player ObjectIds belonging to Team 2. */ + @Prop({ + type: [{ type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }], + required: true, + }) + team2: string[]; +} + +/** + * Mongoose Schema definition for the Battle class. + */ +export const BattleSchema = SchemaFactory.createForClass(Battle); + +/** Explicitly setting the collection name to 'battles'. */ +BattleSchema.set('collection', 'battles'); + +/** + * Pre-validation middleware to enforce data integrity. + * - Ensures a player is not assigned to both teams simultaneously. + * - Generates a fallback matchId if one is missing. + */ +BattleSchema.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`)); + } + + if (!this.matchId) { + this.matchId = `match_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + next(); +}); \ No newline at end of file diff --git a/src/gameData/gameData.module.ts b/src/gameData/gameData.module.ts index 7ac15e0e0..4b42a94a9 100644 --- a/src/gameData/gameData.module.ts +++ b/src/gameData/gameData.module.ts @@ -8,6 +8,7 @@ import { ClanModule } from '../clan/clan.module'; import { ClanInventoryModule } from '../clanInventory/clanInventory.module'; import { GameEventsHandlerModule } from '../gameEventsHandler/gameEventsHandler.module'; import { EventEmitterCommonModule } from '../common/service/EventEmitterService/EventEmitterCommon.module'; +import { BattleModule } from './battle/battle.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { EventEmitterCommonModule } from '../common/service/EventEmitterService/ ClanInventoryModule, GameEventsHandlerModule, EventEmitterCommonModule, + BattleModule ], providers: [GameDataService], controllers: [GameDataController], From 8055b3f856e7fb02a37962225b20bec955094354 Mon Sep 17 00:00:00 2001 From: CapoMK25 Date: Fri, 6 Mar 2026 20:12:15 +0200 Subject: [PATCH 2/5] addressed all the comments on the PR here --- .../gameData/battle/battle.service.test.ts | 69 ----------- .../gameData/data/gameData/GameBuilder.ts | 55 +++----- src/gameData/battle/battle.controller.ts | 25 ---- src/gameData/battle/battle.module.ts | 15 --- src/gameData/battle/battle.service.ts | 116 ----------------- src/gameData/battle/dto/battleResult.dto.ts | 25 ---- src/gameData/battle/schema/battle.schema.ts | 103 --------------- src/gameData/dto/battleResult.dto.ts | 13 +- .../{battle => }/dto/startBattle.dto.ts | 2 +- src/gameData/dto/submitResult.dto.ts | 29 +++++ .../{battle => }/enum/battleStatus.enum.ts | 8 +- .../{battle => }/enum/gameType.enum.ts | 0 src/gameData/game.schema.ts | 59 +++++++-- src/gameData/gameData.controller.ts | 23 +++- src/gameData/gameData.module.ts | 4 +- src/gameData/gameData.service.ts | 117 +++++++++++++++++- 16 files changed, 251 insertions(+), 412 deletions(-) delete mode 100644 src/__tests__/gameData/battle/battle.service.test.ts delete mode 100644 src/gameData/battle/battle.controller.ts delete mode 100644 src/gameData/battle/battle.module.ts delete mode 100644 src/gameData/battle/battle.service.ts delete mode 100644 src/gameData/battle/dto/battleResult.dto.ts delete mode 100644 src/gameData/battle/schema/battle.schema.ts rename src/gameData/{battle => }/dto/startBattle.dto.ts (88%) create mode 100644 src/gameData/dto/submitResult.dto.ts rename src/gameData/{battle => }/enum/battleStatus.enum.ts (50%) rename src/gameData/{battle => }/enum/gameType.enum.ts (100%) diff --git a/src/__tests__/gameData/battle/battle.service.test.ts b/src/__tests__/gameData/battle/battle.service.test.ts deleted file mode 100644 index 3373505d7..000000000 --- a/src/__tests__/gameData/battle/battle.service.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getModelToken } from '@nestjs/mongoose'; -import { BattleService } from '../../../gameData/battle/battle.service'; -import { Battle } from '../../../gameData/battle/schema/battle.schema'; -import { BattleStatus } from '../../../gameData/battle/enum/battleStatus.enum'; -import { BattleResultDtoBuilder } from '../data/gameData/BattleResultDtoBuilder'; - -describe('BattleService', () => { - let service: BattleService; - let model: any; - - const mockBattleModel = { - findOne: jest.fn(), - new: jest.fn().mockResolvedValue({ save: jest.fn() }), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - BattleService, - { - provide: getModelToken(Battle.name), - useValue: mockBattleModel, - }, - ], - }).compile(); - - service = module.get(BattleService); - model = module.get(getModelToken(Battle.name)); - - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.clearAllMocks(); - }); - - it('should trigger the 2-minute "Final Call" on conflicting results', async () => { - const fakeBattle: any = { - matchId: 'm1', - receivedResults: [], - status: BattleStatus.OPEN, - team1: ['pA'], - team2: ['pB'], - save: jest.fn().mockImplementation(function(this: any) { - return Promise.resolve(this); - }), - }; - - model.findOne.mockReturnValue(Promise.resolve(fakeBattle)); - - const playerAResult = new BattleResultDtoBuilder().setWinnerTeam(1).build(); - const playerBResult = new BattleResultDtoBuilder().setWinnerTeam(2).build(); - - await service.handleBattleResult(playerAResult as any, 'pA'); - await service.handleBattleResult(playerBResult as any, 'pB'); - - expect(fakeBattle.status).toBe(BattleStatus.PROCESSING); - - jest.runOnlyPendingTimers(); - - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - expect(fakeBattle.status).toBe(BattleStatus.COMPLETED); -}, 15000); -}); \ No newline at end of file diff --git a/src/__tests__/gameData/data/gameData/GameBuilder.ts b/src/__tests__/gameData/data/gameData/GameBuilder.ts index 155f8bfb3..eb04c03e8 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,50 +12,33 @@ 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 { return { ...this.base } as Game; } - setTeam1(team1: string[]): this { - 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; - } + setTeam1(team1: string[]): this { 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; } - setEndedAt(date: Date): this { - this.base.endedAt = date; + setStatus(status: BattleStatus): this { + this.base.status = status; return this; } - setId(id: string): this { - this.base._id = id; + setGameType(gameType: GameType): this { + this.base.gameType = gameType; return this; } -} +} \ No newline at end of file diff --git a/src/gameData/battle/battle.controller.ts b/src/gameData/battle/battle.controller.ts deleted file mode 100644 index a575ce55c..000000000 --- a/src/gameData/battle/battle.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Body, Controller, Post, UseGuards, Request } from '@nestjs/common'; -import { BattleService } from './battle.service'; -import { StartBattleDto } from './dto/startBattle.dto'; -import { BattleResultDto } from './dto/battleResult.dto'; -import { AuthGuard } from '../../auth/auth.guard'; - -@Controller('gamedata/battle') -@UseGuards(AuthGuard) -export class BattleController { - constructor(private readonly battleService: BattleService) {} - - @Post('start') - async startBattle(@Body() startBattleDto: StartBattleDto) { - return this.battleService.registerBattle(startBattleDto); - } - - @Post('result') - async submitResult( - @Request() req, - @Body() battleResultDto: BattleResultDto - ) { - const playerId = req.user.playerId; - return this.battleService.handleBattleResult(battleResultDto, playerId); - } -} \ No newline at end of file diff --git a/src/gameData/battle/battle.module.ts b/src/gameData/battle/battle.module.ts deleted file mode 100644 index 3e0eb002d..000000000 --- a/src/gameData/battle/battle.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MongooseModule } from '@nestjs/mongoose'; -import { BattleController } from './battle.controller'; -import { BattleService } from './battle.service'; -import { Battle, BattleSchema } from './schema/battle.schema'; - -@Module({ - imports: [ - MongooseModule.forFeature([{ name: Battle.name, schema: BattleSchema }]), - ], - controllers: [BattleController], - providers: [BattleService], - exports: [BattleService], -}) -export class BattleModule {} \ No newline at end of file diff --git a/src/gameData/battle/battle.service.ts b/src/gameData/battle/battle.service.ts deleted file mode 100644 index 1f7fe6eb4..000000000 --- a/src/gameData/battle/battle.service.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { Battle, BattleDocument } from './schema/battle.schema'; -import { StartBattleDto } from './dto/startBattle.dto'; -import { BattleResultDto } from './dto/battleResult.dto'; -import { BattleStatus } from './enum/battleStatus.enum'; - -@Injectable() -export class BattleService { - constructor( - @InjectModel(Battle.name) private battleModel: Model, - ) {} - - /** - * Initializes a new battle record in the database. - * Sets the initial status to OPEN. - * * @param dto - The data required to start a battle, including matchId and teams. - * @returns A promise resolving to the created Battle document. - */ - async registerBattle(dto: StartBattleDto): Promise { - const newBattle = new this.battleModel({ - ...dto, - status: BattleStatus.OPEN, - }); - return newBattle.save(); - } - - /** - * Processes a result claim from a player. - * If results from both teams match, the battle is marked COMPLETED. - * If results conflict, the battle enters PROCESSING and triggers a timeout-based resolution. - * * @param dto - The result payload containing matchId, winning team, and duration. - * @param playerId - The ID of the player submitting the result. - * @returns A promise resolving 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.battleModel.findOne({ matchId: 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.matchId); - } - } else { - battle.status = BattleStatus.OPEN; - } - - 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); - } - - /** - * 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: BattleDocument) { - const winners = (battle.finalWinner === 1 ? battle.team1 : battle.team2) || []; - - return await battle.save(); - } - - /** - * Forcibly resolves a battle conflict after the "Final Call" period. - * Uses a majority vote based on received results; 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.battleModel.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); - } -} \ No newline at end of file diff --git a/src/gameData/battle/dto/battleResult.dto.ts b/src/gameData/battle/dto/battleResult.dto.ts deleted file mode 100644 index 2b831ccfa..000000000 --- a/src/gameData/battle/dto/battleResult.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IsInt, IsString, IsNotEmpty, IsNumber, IsPositive, Min, Max } from 'class-validator'; - -export class BattleResultDto { - /** - * The ID of the match generated during /start - */ - @IsNotEmpty() - @IsString() - matchId: string; - - /** - * Duration of the match in seconds - */ - @IsNumber() - @IsPositive() - duration: number; - - /** - * Which team won? (1 for Team 1, 2 for Team 2) - */ - @IsInt() - @Min(1) - @Max(2) - result: number; -} \ No newline at end of file diff --git a/src/gameData/battle/schema/battle.schema.ts b/src/gameData/battle/schema/battle.schema.ts deleted file mode 100644 index 1309fd954..000000000 --- a/src/gameData/battle/schema/battle.schema.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; -import { ModelName } from '../../../common/enum/modelName.enum'; -import { GameType } from '../enum/gameType.enum'; -import { BattleStatus } from '../enum/battleStatus.enum'; - -/** - * Type definition for a Battle document hydrated with Mongoose methods. - */ -export type BattleDocument = HydratedDocument; - -/** - * Represents a competitive match record in the Altzone system. - * Tracks player participation, submitted results, and final resolution. - */ -@Schema({ timestamps: true }) -export class Battle { - /** * Unique identifier for the match. - * Generated automatically if not provided during initialization. - */ - @Prop({ type: String, required: true, unique: true }) - matchId: string; - - /** The category or mode of the game played (e.g., RANKED, CASUAL). */ - @Prop({ type: String, enum: GameType, required: true }) - gameType: GameType; - - /** The current state of the match in the "Odd Man Out" lifecycle. */ - @Prop({ type: String, enum: BattleStatus, default: BattleStatus.OPEN }) - status: BattleStatus; - - /** * The confirmed winning team (1 or 2). - * Populated only when the status transitions to COMPLETED. - */ - @Prop({ type: Number }) - finalWinner?: number; - - /** * Collection of results reported by individual players. - * Used to detect conflicts and calculate the final winner via majority vote. - */ - @Prop({ - type: [{ - playerId: { type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }, - winnerTeam: Number, // 1 or 2 - duration: Number, - receivedAt: { type: Date, default: Date.now } - }], - default: [] - }) - receivedResults: { - /** Reference to the Player who submitted the result. */ - playerId: string; - /** The team claimed as the winner by this player. */ - winnerTeam: number; - /** The reported duration of the match session. */ - duration: number; - /** Timestamp of when this specific result was received. */ - receivedAt?: Date; - }[]; - - /** List of Player ObjectIds belonging to Team 1. */ - @Prop({ - type: [{ type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }], - required: true, - }) - team1: string[]; - - /** List of Player ObjectIds belonging to Team 2. */ - @Prop({ - type: [{ type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }], - required: true, - }) - team2: string[]; -} - -/** - * Mongoose Schema definition for the Battle class. - */ -export const BattleSchema = SchemaFactory.createForClass(Battle); - -/** Explicitly setting the collection name to 'battles'. */ -BattleSchema.set('collection', 'battles'); - -/** - * Pre-validation middleware to enforce data integrity. - * - Ensures a player is not assigned to both teams simultaneously. - * - Generates a fallback matchId if one is missing. - */ -BattleSchema.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`)); - } - - if (!this.matchId) { - this.matchId = `match_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - next(); -}); \ No newline at end of file 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/battle/dto/startBattle.dto.ts b/src/gameData/dto/startBattle.dto.ts similarity index 88% rename from src/gameData/battle/dto/startBattle.dto.ts rename to src/gameData/dto/startBattle.dto.ts index b0b82bdcb..233fe3edf 100644 --- a/src/gameData/battle/dto/startBattle.dto.ts +++ b/src/gameData/dto/startBattle.dto.ts @@ -26,7 +26,7 @@ export class StartBattleDto { team2: string[]; /** - * Optional custom match ID. If not provided, server generates one. + * Optional custom match ID. If these are not provided, the server generates one. * @example "match_12345" */ @IsOptional() diff --git a/src/gameData/dto/submitResult.dto.ts b/src/gameData/dto/submitResult.dto.ts new file mode 100644 index 000000000..d94573be3 --- /dev/null +++ b/src/gameData/dto/submitResult.dto.ts @@ -0,0 +1,29 @@ +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; +} \ No newline at end of file diff --git a/src/gameData/battle/enum/battleStatus.enum.ts b/src/gameData/enum/battleStatus.enum.ts similarity index 50% rename from src/gameData/battle/enum/battleStatus.enum.ts rename to src/gameData/enum/battleStatus.enum.ts index a2ab92107..028d949f0 100644 --- a/src/gameData/battle/enum/battleStatus.enum.ts +++ b/src/gameData/enum/battleStatus.enum.ts @@ -1,8 +1,8 @@ /** - * Defines the possible states of a battle lifecycle. + * Defines all the possible states of a battle. */ export enum BattleStatus { - /** Match is registered and awaiting results from players. */ + /** Match is registered and awaiting results from the players. */ OPEN = 'OPEN', /** * Conflicting results detected or 2+ results received. @@ -10,9 +10,9 @@ export enum BattleStatus { */ PROCESSING = 'PROCESSING', - /** Results have been validated and rewards have been distributed. */ + /** Results have been validated and rewards have been given. */ COMPLETED = 'COMPLETED', - /** No results were received within the 30-minute grace period. */ + /** No results were received within the 30 minute period. */ TIMED_OUT = 'TIMED_OUT', } \ No newline at end of file diff --git a/src/gameData/battle/enum/gameType.enum.ts b/src/gameData/enum/gameType.enum.ts similarity index 100% rename from src/gameData/battle/enum/gameType.enum.ts rename to src/gameData/enum/gameType.enum.ts diff --git a/src/gameData/game.schema.ts b/src/gameData/game.schema.ts index 214deb24c..6e8a58e25 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,70 @@ 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: Number, enum: [1, 2], required: true }) - winner: number; + @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: Date, required: true }) - startedAt: Date; + @Prop({ type: Number }) + finalWinner: number; - @Prop({ type: Date, required: true }) - endedAt: Date; + @Prop({ type: Number, enum: [1, 2] }) + winner?: number; + + @Prop({ type: Date }) + startedAt?: 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..463f12f0d 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Put, UseGuards, Request } from '@nestjs/common'; import { GameDataService } from './gameData.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; @@ -11,6 +11,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 +69,23 @@ export class GameDataController { return new APIError({ reason: APIErrorReason.BAD_REQUEST }); } } + + @Post('battle/start') + async startBattle(@Body() startBattleDto: StartBattleDto) { + return this.service.registerBattle(startBattleDto); +} + +@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.module.ts b/src/gameData/gameData.module.ts index 4b42a94a9..dbe736a25 100644 --- a/src/gameData/gameData.module.ts +++ b/src/gameData/gameData.module.ts @@ -8,7 +8,6 @@ import { ClanModule } from '../clan/clan.module'; import { ClanInventoryModule } from '../clanInventory/clanInventory.module'; import { GameEventsHandlerModule } from '../gameEventsHandler/gameEventsHandler.module'; import { EventEmitterCommonModule } from '../common/service/EventEmitterService/EventEmitterCommon.module'; -import { BattleModule } from './battle/battle.module'; @Module({ imports: [ @@ -17,8 +16,7 @@ import { BattleModule } from './battle/battle.module'; ClanModule, ClanInventoryModule, GameEventsHandlerModule, - EventEmitterCommonModule, - BattleModule + EventEmitterCommonModule ], providers: [GameDataService], controllers: [GameDataController], diff --git a/src/gameData/gameData.service.ts b/src/gameData/gameData.service.ts index 4625a9709..b7b90947e 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,110 @@ 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); + } } From 44c36cc4b98f7f57a6dc4277ac1c4bf3715d5d45 Mon Sep 17 00:00:00 2001 From: CapoMK25 Date: Fri, 6 Mar 2026 20:16:45 +0200 Subject: [PATCH 3/5] addressed all the comments on the PR here --- .../data/gameData/BattleResultDtoBuilder.ts | 7 +- .../gameData/data/gameData/GameBuilder.ts | 44 +++- src/gameData/dto/startBattle.dto.ts | 10 +- src/gameData/dto/submitResult.dto.ts | 13 +- src/gameData/enum/battleStatus.enum.ts | 6 +- src/gameData/enum/gameType.enum.ts | 2 +- src/gameData/game.schema.ts | 33 +-- src/gameData/gameData.controller.ts | 39 ++-- src/gameData/gameData.module.ts | 2 +- src/gameData/gameData.service.ts | 201 +++++++++--------- 10 files changed, 206 insertions(+), 151 deletions(-) diff --git a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts index 2734f438e..011b1e555 100644 --- a/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts +++ b/src/__tests__/gameData/data/gameData/BattleResultDtoBuilder.ts @@ -2,14 +2,13 @@ import { BattleResultDto } from '../../../../gameData/dto/battleResult.dto'; import { RequestType } from '../../../../gameData/enum/requestType.enum'; export class BattleResultDtoBuilder { - private readonly base: any = { matchId: 'default-match', type: RequestType.RESULT, team1: [], team2: [], duration: 0, - result: 0, + result: 0, winnerTeam: 0, }; @@ -40,11 +39,11 @@ export class BattleResultDtoBuilder { setWinnerTeam(team: number): this { this.base.result = team; - this.base.winnerTeam = team; + this.base.winnerTeam = team; return this; } build(): BattleResultDto { return { ...this.base } as BattleResultDto; } -} \ No newline at end of file +} diff --git a/src/__tests__/gameData/data/gameData/GameBuilder.ts b/src/__tests__/gameData/data/gameData/GameBuilder.ts index eb04c03e8..60df8fab0 100644 --- a/src/__tests__/gameData/data/gameData/GameBuilder.ts +++ b/src/__tests__/gameData/data/gameData/GameBuilder.ts @@ -16,21 +16,45 @@ export class GameBuilder { gameType: GameType.CASUAL, status: BattleStatus.OPEN, receivedResults: [], - finalWinner: 0 + finalWinner: 0, }; build(): Game { return { ...this.base } as Game; } - setTeam1(team1: string[]): this { 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; } + setTeam1(team1: string[]): this { + 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; @@ -41,4 +65,4 @@ export class GameBuilder { this.base.gameType = gameType; return this; } -} \ No newline at end of file +} diff --git a/src/gameData/dto/startBattle.dto.ts b/src/gameData/dto/startBattle.dto.ts index 233fe3edf..38804dd44 100644 --- a/src/gameData/dto/startBattle.dto.ts +++ b/src/gameData/dto/startBattle.dto.ts @@ -1,4 +1,10 @@ -import { IsEnum, IsArray, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { + IsEnum, + IsArray, + IsMongoId, + IsOptional, + IsString, +} from 'class-validator'; import { GameType } from '../enum/gameType.enum'; export class StartBattleDto { @@ -32,4 +38,4 @@ export class StartBattleDto { @IsOptional() @IsString() matchId?: string; -} \ No newline at end of file +} diff --git a/src/gameData/dto/submitResult.dto.ts b/src/gameData/dto/submitResult.dto.ts index d94573be3..ada70cf8d 100644 --- a/src/gameData/dto/submitResult.dto.ts +++ b/src/gameData/dto/submitResult.dto.ts @@ -1,4 +1,11 @@ -import { IsString, IsInt, IsNotEmpty, Min, Max, IsPositive } from 'class-validator'; +import { + IsString, + IsInt, + IsNotEmpty, + Min, + Max, + IsPositive, +} from 'class-validator'; export class SubmitResultDto { /** @@ -18,7 +25,7 @@ export class SubmitResultDto { duration: number; /** - * The result of the battle. + * The result of the battle. * 1 represents a win for Team 1, 2 represents a win for Team 2. * @example 1 */ @@ -26,4 +33,4 @@ export class SubmitResultDto { @Min(1) @Max(2) result: number; -} \ No newline at end of file +} diff --git a/src/gameData/enum/battleStatus.enum.ts b/src/gameData/enum/battleStatus.enum.ts index 028d949f0..1cba634a1 100644 --- a/src/gameData/enum/battleStatus.enum.ts +++ b/src/gameData/enum/battleStatus.enum.ts @@ -5,8 +5,8 @@ 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. + /** * Conflicting results detected or 2+ results received. + * Indicates the conflict resolution timer is active. */ PROCESSING = 'PROCESSING', @@ -15,4 +15,4 @@ export enum BattleStatus { /** No results were received within the 30 minute period. */ TIMED_OUT = 'TIMED_OUT', -} \ No newline at end of file +} diff --git a/src/gameData/enum/gameType.enum.ts b/src/gameData/enum/gameType.enum.ts index 9db608da0..7b75330ac 100644 --- a/src/gameData/enum/gameType.enum.ts +++ b/src/gameData/enum/gameType.enum.ts @@ -2,4 +2,4 @@ export enum GameType { MATCHMAKING = 'matchmaking', CASUAL = 'casual', CUSTOM = 'custom', -} \ No newline at end of file +} diff --git a/src/gameData/game.schema.ts b/src/gameData/game.schema.ts index 6e8a58e25..1638879d4 100644 --- a/src/gameData/game.schema.ts +++ b/src/gameData/game.schema.ts @@ -43,13 +43,18 @@ export class Game { status: BattleStatus; @Prop({ - type: [{ - playerId: { type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER }, - winnerTeam: Number, - duration: Number, - receivedAt: { type: Date, default: Date.now } - }], - default: [] + type: [ + { + playerId: { + type: MongooseSchema.Types.ObjectId, + ref: ModelName.PLAYER, + }, + winnerTeam: Number, + duration: Number, + receivedAt: { type: Date, default: Date.now }, + }, + ], + default: [], }) receivedResults: { playerId: string; @@ -78,12 +83,16 @@ 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)); + 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`)); + return next( + new Error( + `Player ${intersection[0]} cannot be on both teams at the same time`, + ), + ); } next(); }); diff --git a/src/gameData/gameData.controller.ts b/src/gameData/gameData.controller.ts index 463f12f0d..8167c1340 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, Post, Put, UseGuards, Request } from '@nestjs/common'; +import { + Body, + Controller, + Post, + Put, + UseGuards, + Request, +} from '@nestjs/common'; import { GameDataService } from './gameData.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; @@ -72,20 +79,22 @@ export class GameDataController { @Post('battle/start') async startBattle(@Body() startBattleDto: StartBattleDto) { - return this.service.registerBattle(startBattleDto); -} + return this.service.registerBattle(startBattleDto); + } -@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); -} + @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.module.ts b/src/gameData/gameData.module.ts index dbe736a25..7ac15e0e0 100644 --- a/src/gameData/gameData.module.ts +++ b/src/gameData/gameData.module.ts @@ -16,7 +16,7 @@ import { EventEmitterCommonModule } from '../common/service/EventEmitterService/ ClanModule, ClanInventoryModule, GameEventsHandlerModule, - EventEmitterCommonModule + EventEmitterCommonModule, ], providers: [GameDataService], controllers: [GameDataController], diff --git a/src/gameData/gameData.service.ts b/src/gameData/gameData.service.ts index b7b90947e..748e69aea 100644 --- a/src/gameData/gameData.service.ts +++ b/src/gameData/gameData.service.ts @@ -312,109 +312,110 @@ export class GameDataService { } } - /** - * 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(); - } + /** + * 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(); - } + /** + * 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'); - /** - * 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); - } + battle.receivedResults.push({ + playerId, + winnerTeam: dto.result, + duration: dto.duration, + }); - /** - * 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; + 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(); - - await this.generateRaidTokens(battle); + 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); + } } From a804392df1f0764563ae68748e11118c09865d27 Mon Sep 17 00:00:00 2001 From: CapoMK25 Date: Sat, 7 Mar 2026 20:33:28 +0200 Subject: [PATCH 4/5] docs: restore swagger decorators and JSDocs --- src/gameData/gameData.controller.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/gameData/gameData.controller.ts b/src/gameData/gameData.controller.ts index 8167c1340..b14cf9b98 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -77,11 +77,39 @@ export class GameDataController { } } + /** + * 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, From 7fa02dd3dd768a139c94d89cb64555faeb14fe45 Mon Sep 17 00:00:00 2001 From: CapoMK25 Date: Sun, 8 Mar 2026 11:03:55 +0200 Subject: [PATCH 5/5] removed unused imports --- src/gameData/gameData.controller.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/gameData/gameData.controller.ts b/src/gameData/gameData.controller.ts index b14cf9b98..97b118042 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -2,9 +2,7 @@ import { Body, Controller, Post, - Put, - UseGuards, - Request, + Put } from '@nestjs/common'; import { GameDataService } from './gameData.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; @@ -79,7 +77,7 @@ export class GameDataController { /** * Initialize a new battle record - * * @remarks This endpoint is used to register the start of a battle. + * * @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. */ @@ -97,9 +95,9 @@ export class GameDataController { /** * 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. + * * 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" + * * 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.