diff --git a/backend/src/creator-events/creator-events-predictions-stats.spec.ts b/backend/src/creator-events/creator-events-predictions-stats.spec.ts index 5fd0a0f3..7f8c2cfa 100644 --- a/backend/src/creator-events/creator-events-predictions-stats.spec.ts +++ b/backend/src/creator-events/creator-events-predictions-stats.spec.ts @@ -8,6 +8,9 @@ import { import { CreatorEvent } from '../matches/entities/creator-event.entity'; import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity'; import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { User } from '../users/entities/user.entity'; import { CreatorEventsService } from './creator-events.service'; describe('CreatorEventsService predictions and stats', () => { @@ -86,6 +89,18 @@ describe('CreatorEventsService predictions and stats', () => { provide: getRepositoryToken(CreatorEventLeaderboardEntry), useValue: {}, }, + { + provide: getRepositoryToken(Match), + useValue: {}, + }, + { + provide: getRepositoryToken(MatchPrediction), + useValue: {}, + }, + { + provide: getRepositoryToken(User), + useValue: {}, + }, { provide: getRepositoryToken(CreatorEventPayout), useValue: {}, diff --git a/backend/src/creator-events/creator-events.module.ts b/backend/src/creator-events/creator-events.module.ts index d5253dee..035107d4 100644 --- a/backend/src/creator-events/creator-events.module.ts +++ b/backend/src/creator-events/creator-events.module.ts @@ -3,6 +3,9 @@ import { CacheModule } from '@nestjs/cache-manager'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ContractModule } from '../contract/contract.module'; import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { User } from '../users/entities/user.entity'; import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity'; import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity'; import { @@ -15,11 +18,7 @@ import { CreatorEventsService } from './creator-events.service'; @Module({ imports: [ ContractModule, - TypeOrmModule.forFeature([ - CreatorEvent, - CreatorEventLeaderboardEntry, - CreatorEventPayout, - ]), + TypeOrmModule.forFeature([CreatorEvent, Match, MatchPrediction, User, CreatorEventLeaderboardEntry, CreatorEventPayout]), CacheModule.register(), ], controllers: [ diff --git a/backend/src/creator-events/creator-events.service.spec.ts b/backend/src/creator-events/creator-events.service.spec.ts index 6468aad4..ff8f4387 100644 --- a/backend/src/creator-events/creator-events.service.spec.ts +++ b/backend/src/creator-events/creator-events.service.spec.ts @@ -5,6 +5,9 @@ import { ContractService } from '../contract/contract.service'; import { CreatorEvent } from '../matches/entities/creator-event.entity'; import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity'; import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { User } from '../users/entities/user.entity'; import { CreatorEventsService } from './creator-events.service'; import { CreatorEventSearchStatus } from './dto/search-events-query.dto'; @@ -95,6 +98,18 @@ describe('CreatorEventsService searchEvents', () => { provide: getRepositoryToken(CreatorEventLeaderboardEntry), useValue: {}, }, + { + provide: getRepositoryToken(Match), + useValue: {}, + }, + { + provide: getRepositoryToken(MatchPrediction), + useValue: {}, + }, + { + provide: getRepositoryToken(User), + useValue: {}, + }, { provide: getRepositoryToken(CreatorEventPayout), useValue: {}, diff --git a/backend/src/creator-events/creator-events.service.ts b/backend/src/creator-events/creator-events.service.ts index 494c8cd7..4b0def7b 100644 --- a/backend/src/creator-events/creator-events.service.ts +++ b/backend/src/creator-events/creator-events.service.ts @@ -1,12 +1,6 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; -import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity'; -import { - PaginatedPayoutsDto, - PayoutEntryDto, - PayoutsQueryDto, -} from './dto/payouts.dto'; +import { Brackets, In, Repository } from 'typeorm'; import { ContractService, ContractEvent, @@ -15,7 +9,11 @@ import { ContractMatch, } from '../contract/contract.service'; import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { User } from '../users/entities/user.entity'; import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity'; +import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity'; import { EventByCodeResponseDto, MatchPreviewDto, @@ -48,6 +46,11 @@ import { LeaderboardEntryResponse, PaginatedLeaderboardResponse, } from './dto/leaderboard-query.dto'; +import { + PayoutsQueryDto, + PaginatedPayoutsDto, + PayoutEntryDto, +} from './dto/payouts.dto'; import { normalizeContractPrediction, resolveCorrectness, @@ -86,9 +89,14 @@ export class CreatorEventsService { private readonly contractService: ContractService, @InjectRepository(CreatorEvent) private readonly creatorEventRepository: Repository, + @InjectRepository(Match) + private readonly matchRepository: Repository, + @InjectRepository(MatchPrediction) + private readonly matchPredictionRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, @InjectRepository(CreatorEventLeaderboardEntry) private readonly leaderboardEntryRepository: Repository, - @InjectRepository(CreatorEventPayout) private readonly creatorEventPayoutRepository: Repository, ) {} @@ -542,6 +550,28 @@ export class CreatorEventsService { incorrectPredictions === 0 && pendingPredictions === 0; + let totalPoints = 0; + const userEntity = await this.userRepository.findOne({ + where: { stellar_address: address }, + }); + if (userEntity && matches.length > 0) { + const dbMatches = await this.matchRepository.find({ + where: { event: { on_chain_event_id: Number(eventId) } }, + }); + if (dbMatches.length > 0) { + const dbPredictions = await this.matchPredictionRepository.find({ + where: { + user: { id: userEntity.id }, + match: { id: In(dbMatches.map((m) => m.id)) }, + }, + }); + totalPoints = dbPredictions.reduce( + (sum, p) => sum + p.points_earned, + 0, + ); + } + } + return { address, totalMatches: matches.length, @@ -552,6 +582,7 @@ export class CreatorEventsService { accuracyPercentage, rank, isWinner, + totalPoints, }; } diff --git a/backend/src/creator-events/dto/user-score-response.dto.ts b/backend/src/creator-events/dto/user-score-response.dto.ts index 15173aff..c74b2919 100644 --- a/backend/src/creator-events/dto/user-score-response.dto.ts +++ b/backend/src/creator-events/dto/user-score-response.dto.ts @@ -35,4 +35,7 @@ export class UserScoreResponseDto { description: 'Whether user is a winner (all predictions correct)', }) isWinner: boolean; + + @ApiProperty({ description: 'Total points earned by the user' }) + totalPoints: number; } diff --git a/backend/src/indexer/indexer.service.ts b/backend/src/indexer/indexer.service.ts index d55e0eee..e209b0b9 100644 --- a/backend/src/indexer/indexer.service.ts +++ b/backend/src/indexer/indexer.service.ts @@ -428,6 +428,8 @@ export class IndexerService implements OnModuleInit { event_id: this.readBigInt(base, 'event_id'), predictor: this.readStr(base, 'predictor'), predicted_outcome: this.readStr(base, 'predicted_outcome'), + predicted_home_score: this.readNum(base, 'predicted_home_score'), + predicted_away_score: this.readNum(base, 'predicted_away_score'), predicted_at: this.readNum(base, 'predicted_at'), }; case 'MatchResultSubmitted': @@ -740,9 +742,8 @@ export class IndexerService implements OnModuleInit { ): Promise { const matchId = Number(data.match_id); const predictorAddress = this.readStr(data, 'predictor'); - const predictedOutcome = this.readStr(data, 'predicted_outcome'); - if (!matchId || !predictorAddress || !predictedOutcome) { + if (!matchId || !predictorAddress) { this.logger.warn('PredictionSubmitted skipped: missing data'); return; } @@ -768,18 +769,6 @@ export class IndexerService implements OnModuleInit { return; } - const normalizedOutcome = predictedOutcome.toUpperCase(); - if ( - ![PredictedOutcome.TEAM_A, PredictedOutcome.TEAM_B, PredictedOutcome.DRAW] - .map((o) => o.toString()) - .includes(normalizedOutcome) - ) { - this.logger.warn( - `PredictionSubmitted skipped: invalid outcome ${predictedOutcome}`, - ); - return; - } - const existing = await this.matchPredictionRepository.findOne({ where: { match: { id: match.id }, @@ -788,10 +777,57 @@ export class IndexerService implements OnModuleInit { }); if (existing) return; + const predictedHomeScore = + data.predicted_home_score !== undefined && data.predicted_home_score !== null + ? Number(data.predicted_home_score) + : null; + const predictedAwayScore = + data.predicted_away_score !== undefined && data.predicted_away_score !== null + ? Number(data.predicted_away_score) + : null; + + let predictedOutcome: PredictedOutcome; + if (predictedHomeScore !== null && predictedAwayScore !== null) { + if (predictedHomeScore > predictedAwayScore) { + predictedOutcome = PredictedOutcome.TEAM_A; + } else if (predictedHomeScore < predictedAwayScore) { + predictedOutcome = PredictedOutcome.TEAM_B; + } else { + predictedOutcome = PredictedOutcome.DRAW; + } + } else { + const rawOutcome = this.readStr(data, 'predicted_outcome'); + if (!rawOutcome) { + this.logger.warn( + 'PredictionSubmitted skipped: no scoreline or predicted_outcome', + ); + return; + } + const normalizedOutcome = rawOutcome.toUpperCase(); + if ( + ![ + PredictedOutcome.TEAM_A, + PredictedOutcome.TEAM_B, + PredictedOutcome.DRAW, + ] + .map((o) => o.toString()) + .includes(normalizedOutcome) + ) { + this.logger.warn( + `PredictionSubmitted skipped: invalid outcome ${rawOutcome}`, + ); + return; + } + predictedOutcome = normalizedOutcome as PredictedOutcome; + } + const prediction = this.matchPredictionRepository.create({ match, user, - predicted_outcome: normalizedOutcome as PredictedOutcome, + predicted_outcome: predictedOutcome, + predicted_home_score: predictedHomeScore, + predicted_away_score: predictedAwayScore, + points_earned: 0, is_correct: null, }); @@ -854,7 +890,7 @@ export class IndexerService implements OnModuleInit { await this.matchRepository.save(match); - await this.gradePredictions(match.id, winningTeam); + await this.gradePredictions(match); this.logger.log( `Indexed MatchResultSubmitted: match=${matchId} winner=${winningTeam}`, ); @@ -864,17 +900,47 @@ export class IndexerService implements OnModuleInit { this.broadcasterService.broadcastMatchResolved(data); } - private async gradePredictions( - matchId: string, - winningTeam: WinningTeam, - ): Promise { + private async gradePredictions(match: Match): Promise { const predictions = await this.matchPredictionRepository.find({ - where: { match: { id: matchId } }, + where: { match: { id: match.id } }, }); + const { home_score, away_score, winning_team, points_multiplier } = match; + for (const prediction of predictions) { - prediction.is_correct = - String(prediction.predicted_outcome) === String(winningTeam); + const outcomeCorrect = + String(prediction.predicted_outcome) === String(winning_team); + prediction.is_correct = outcomeCorrect; + + if (!outcomeCorrect) { + prediction.points_earned = 0; + continue; + } + + if ( + home_score !== null && + away_score !== null && + prediction.predicted_home_score !== null && + prediction.predicted_away_score !== null + ) { + const exactScore = + prediction.predicted_home_score === home_score && + prediction.predicted_away_score === away_score; + + const goalDiffCorrect = + prediction.predicted_home_score - prediction.predicted_away_score === + home_score - away_score; + + if (exactScore) { + prediction.points_earned = 4 * points_multiplier; + } else if (goalDiffCorrect) { + prediction.points_earned = 3 * points_multiplier; + } else { + prediction.points_earned = 1 * points_multiplier; + } + } else { + prediction.points_earned = 1 * points_multiplier; + } } if (predictions.length > 0) { diff --git a/backend/src/matches/entities/match-prediction.entity.ts b/backend/src/matches/entities/match-prediction.entity.ts index bbe9626a..f8c1028f 100644 --- a/backend/src/matches/entities/match-prediction.entity.ts +++ b/backend/src/matches/entities/match-prediction.entity.ts @@ -41,6 +41,15 @@ export class MatchPrediction { }) predicted_outcome: PredictedOutcome; + @Column({ type: 'int', nullable: true }) + predicted_home_score: number | null; + + @Column({ type: 'int', nullable: true }) + predicted_away_score: number | null; + + @Column({ type: 'int', default: 0 }) + points_earned: number; + @Column({ type: 'boolean', nullable: true }) is_correct: boolean | null; diff --git a/backend/src/migrations/1775900000000-AddScorelineAndPointsToMatchPrediction.ts b/backend/src/migrations/1775900000000-AddScorelineAndPointsToMatchPrediction.ts new file mode 100644 index 00000000..dc1b5d7c --- /dev/null +++ b/backend/src/migrations/1775900000000-AddScorelineAndPointsToMatchPrediction.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddScorelineAndPointsToMatchPrediction1775900000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('match_predictions', [ + new TableColumn({ + name: 'predicted_home_score', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'predicted_away_score', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'points_earned', + type: 'int', + default: 0, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('match_predictions', 'points_earned'); + await queryRunner.dropColumn('match_predictions', 'predicted_away_score'); + await queryRunner.dropColumn('match_predictions', 'predicted_home_score'); + } +}