mirror of
https://github.com/amineo/t2-stat-parser.git
synced 2026-01-19 17:34:43 +00:00
Add accuracy leaderboard: players/top/accuracy endpoint
This commit is contained in:
parent
1015e4b8d4
commit
7504d1497c
43
app/api/src/common/dto/top-players-query.dto.ts
Normal file
43
app/api/src/common/dto/top-players-query.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue