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 { PlayersService } from './players.service';
|
||||||
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
|
||||||
|
import { TopPlayersQueryDto } from '../common/dto/top-players-query.dto';
|
||||||
|
|
||||||
@Controller('players')
|
@Controller('players')
|
||||||
export class PlayersController {
|
export class PlayersController {
|
||||||
|
|
@ -15,4 +16,26 @@ export class PlayersController {
|
||||||
const { limit = 10, offset = 0 } = paginationQuery;
|
const { limit = 10, offset = 0 } = paginationQuery;
|
||||||
return this.playerService.findAll({ limit, offset });
|
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 { Players } from './entities/Players';
|
||||||
import { GameDetail } from '../game/entities/GameDetail';
|
import { GameDetail } from '../game/entities/GameDetail';
|
||||||
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
|
||||||
|
import { TopPlayersQueryDto } from '../common/dto/top-players-query.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlayersService {
|
export class PlayersService {
|
||||||
|
|
@ -42,4 +43,129 @@ export class PlayersService {
|
||||||
}
|
}
|
||||||
return player;
|
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