From 7504d1497c5dcb02fbe47fb469bac54f3e79d677 Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Thu, 29 Apr 2021 18:04:53 -0700 Subject: [PATCH] Add accuracy leaderboard: players/top/accuracy endpoint --- .../src/common/dto/top-players-query.dto.ts | 43 ++++++ app/api/src/players/players.controller.ts | 23 ++++ app/api/src/players/players.service.ts | 126 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 app/api/src/common/dto/top-players-query.dto.ts diff --git a/app/api/src/common/dto/top-players-query.dto.ts b/app/api/src/common/dto/top-players-query.dto.ts new file mode 100644 index 0000000..31aa8df --- /dev/null +++ b/app/api/src/common/dto/top-players-query.dto.ts @@ -0,0 +1,43 @@ +import { IsOptional, IsPositive, IsNotEmpty, IsIn, Max } from 'class-validator'; + +const filterableGameTypes = [ + 'CTFGame', + 'LakRabbitGame' +] as const; + +type FilterableGameType = typeof filterableGameTypes[number]; + +const hitStats = [ + 'discHitsTG', + 'discDmgHitsTG', + 'discMATG', + 'laserHitsTG', + 'laserMATG', + 'cgHitsTG', + 'shockHitsTG' +] as const; + +type Stat = typeof hitStats[number]; + +export class TopPlayersQueryDto { + @IsNotEmpty() + @IsIn(hitStats as any) + stat: Stat; + + @IsOptional() + @IsIn(filterableGameTypes as any) + gameType: FilterableGameType; + + @IsOptional() + @IsPositive() + minGames: number; + + @IsOptional() + @IsPositive() + minShots: number; + + @IsOptional() + @IsPositive() + @Max(100) + limit: number; +} diff --git a/app/api/src/players/players.controller.ts b/app/api/src/players/players.controller.ts index 62df7e9..5d68a96 100644 --- a/app/api/src/players/players.controller.ts +++ b/app/api/src/players/players.controller.ts @@ -3,6 +3,7 @@ import { ApiOperation } from '@nestjs/swagger'; import { PlayersService } from './players.service'; import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { TopPlayersQueryDto } from '../common/dto/top-players-query.dto'; @Controller('players') export class PlayersController { @@ -15,4 +16,26 @@ export class PlayersController { const { limit = 10, offset = 0 } = paginationQuery; return this.playerService.findAll({ limit, offset }); } + + @Get('top/accuracy') + @ApiOperation({ + tags: [ 'Player', 'Leaderboard' ], + summary: 'Return a leaderboard of players for a specific accuracy stat' + }) + findTop(@Query() topPlayersQuery: TopPlayersQueryDto) { + const { + stat, + gameType, + minGames = 10, + minShots = 100, + limit = 10 + } = topPlayersQuery; + return this.playerService.findTop({ + stat, + gameType, + minGames, + minShots, + limit + }); + } } diff --git a/app/api/src/players/players.service.ts b/app/api/src/players/players.service.ts index a895acc..ae1fa10 100644 --- a/app/api/src/players/players.service.ts +++ b/app/api/src/players/players.service.ts @@ -6,6 +6,7 @@ import { Connection, Repository } from 'typeorm'; import { Players } from './entities/Players'; import { GameDetail } from '../game/entities/GameDetail'; import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { TopPlayersQueryDto } from '../common/dto/top-players-query.dto'; @Injectable() export class PlayersService { @@ -42,4 +43,129 @@ export class PlayersService { } return player; } + + async findTop(topPlayersQuery: TopPlayersQueryDto) { + const { stat: hitsStat, gameType, minGames, minShots, limit } = topPlayersQuery; + + const shotsStat = { + discDmgHitsTG: 'discShotsFiredTG', + discHitsTG: 'discShotsFiredTG', + discMATG: 'discShotsFiredTG', + laserHitsTG: 'laserShotsFiredTG', + laserMATG: 'laserShotsFiredTG', + cgHitsTG: 'cgShotsFiredTG', + shockHitsTG: 'shockShotsFiredTG' + }[hitsStat] + + // Possibly make this a query param at some point? + const excludeDiscJumps = shotsStat === 'discShotsFiredTG'; + + const hitsValue = '(game.stats->:hitsStat->>0)::integer'; + const shotsValue = '(game.stats->:shotsStat->>0)::integer'; + const discJumpsValue = "(game.stats->'discJumpTG'->>0)::integer"; + const killerDiscJumpsValue = "(game.stats->'killerDiscJumpTG'->>0)::integer"; + + // pgSQL doesn't let you reference aliased selections you've made in the + // same select statement, so unfortunately these computed JSON values are + // repeated in a couple places. Using template strings here to easily repeat + // the same expression instead of having multiple nested subqueries. Rest + // assured, no user-supplied values are ever interpolated, those are all + // parameterized instead. + const aggregatedHits = `SUM(${hitsValue})::integer`; + // Add sums of `discJumpTG` and `killerDiscJumpTG`. + const aggregatedDiscJumps = + `(SUM(${discJumpsValue})::integer + SUM(${killerDiscJumpsValue})::integer)`; + + let aggregatedShots = `SUM(${shotsValue})::integer`; + if (excludeDiscJumps) { + // Since subtracting disc jumps could theoretically drop the shots count + // to 0, clamp it to at least the number of hits, otherwise it'd be + // possible to have >100% accuracy. + aggregatedShots = `GREATEST(${aggregatedHits}, ${aggregatedShots} - ${aggregatedDiscJumps})` + } + + // Cast to float to avoid integer division truncating the result. + const aggregatedAccuracy = `(${aggregatedHits}::float / ${aggregatedShots}::float)`; + + // TODO: This whole query could probably be turned into a `ViewEntity` at + // some point, but I couldn't get that to work. + + let playersQuery = this.playersRepository.createQueryBuilder('player') + .setParameters({ + hitsStat, + shotsStat, + minGames, + minShots, + }) + .select([ + 'player.player_guid', + 'player.player_name', + 'stats.game_count', + 'stats.hits', + 'stats.shots', + 'stats.accuracy' + ]) + .innerJoin(subQuery => { + let statsQuery = subQuery + .select(['game.player_guid']) + .from(GameDetail, 'game') + .addSelect('COUNT(game.id)::integer', 'game_count') + .addSelect(aggregatedHits, 'hits') + .addSelect(aggregatedShots, 'shots') + .addSelect(aggregatedAccuracy, 'accuracy') + .where(`${shotsValue} > 0`) + .groupBy('game.player_guid'); + + if (excludeDiscJumps) { + statsQuery = statsQuery.addSelect(aggregatedDiscJumps, 'disc_jumps'); + } + + if (gameType) { + statsQuery = statsQuery.andWhere('game.gametype = :gameType', { gameType }) + } + + return statsQuery; + }, 'stats', 'stats.player_guid = player.player_guid') + .where('stats.game_count >= :minGames') + .andWhere('stats.shots >= :minShots') + .orderBy('stats.accuracy', 'DESC') + .limit(limit); + + if (excludeDiscJumps) { + playersQuery = playersQuery.addSelect('stats.disc_jumps'); + } + + // Uncomment to debug: + // console.log(query.getQueryAndParameters()); + + // typeorm doesn't let you select computed columns since they're not part + // of the entity definition. There are workarounds, but I'm not a fan of any + // and would rather use `getRawMany()` for now, which does include them. + // See: https://github.com/typeorm/typeorm/issues/296 + const rows = await playersQuery.getRawMany(); + + // `getRawMany` was used, so manually snake_case -> camelCase. + const players = rows.map(row => ({ + playerGuid: row.player_guid, + playerName: row.player_name, + gameCount: row.game_count, + hits: row.hits, + shots: row.shots, + discJumps: row.disc_jumps, + accuracy: row.accuracy + })); + + return { + // Even though some of these parameters might have been supplied as input, + // it's still useful to know what values were actually used, in case + // defaults were used instead, values were clamped to min/max, etc. + hitsStat, + shotsStat, + excludeDiscJumps, + minGames, + minShots, + limit, + players + }; + } }