Merge pull request #5 from exogen/feature/top-winning-percentage

Add API endpoint for top win-loss CTF records
This commit is contained in:
Anthony Mineo 2021-05-30 08:30:21 -04:00 committed by GitHub
commit ab028e52fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 271 additions and 58 deletions

View file

@ -1,4 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "trailingComma": "all",
} "useTabs": true
}

View file

@ -1,9 +1,6 @@
import { IsOptional, IsPositive, IsNotEmpty, IsIn, Max } from 'class-validator'; import { IsOptional, IsPositive, IsNotEmpty, IsIn, Max } from 'class-validator';
const filterableGameTypes = [ const filterableGameTypes = ['CTFGame', 'LakRabbitGame'] as const;
'CTFGame',
'LakRabbitGame'
] as const;
type FilterableGameType = typeof filterableGameTypes[number]; type FilterableGameType = typeof filterableGameTypes[number];
@ -14,12 +11,12 @@ const hitStats = [
'laserHitsTG', 'laserHitsTG',
'laserMATG', 'laserMATG',
'cgHitsTG', 'cgHitsTG',
'shockHitsTG' 'shockHitsTG',
] as const; ] as const;
type Stat = typeof hitStats[number]; type Stat = typeof hitStats[number];
export class TopPlayersQueryDto { export class TopAccuracyQueryDto {
@IsNotEmpty() @IsNotEmpty()
@IsIn(hitStats as any) @IsIn(hitStats as any)
stat: Stat; stat: Stat;
@ -41,3 +38,14 @@ export class TopPlayersQueryDto {
@Max(100) @Max(100)
limit: number; limit: number;
} }
export class TopWinsQueryDto {
@IsOptional()
@IsPositive()
minGames: number;
@IsOptional()
@IsPositive()
@Max(100)
limit: number;
}

View file

@ -3,7 +3,10 @@ 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'; import {
TopAccuracyQueryDto,
TopWinsQueryDto,
} from '../common/dto/top-players-query.dto';
@Controller('players') @Controller('players')
export class PlayersController { export class PlayersController {
@ -11,7 +14,7 @@ export class PlayersController {
// /players // /players
@Get() @Get()
@ApiOperation({ tags: [ 'Player' ], summary: 'Return a list of players' }) @ApiOperation({ tags: ['Player'], summary: 'Return a list of players' })
findAll(@Query() paginationQuery: PaginationQueryDto) { findAll(@Query() paginationQuery: PaginationQueryDto) {
const { limit = 10, offset = 0 } = paginationQuery; const { limit = 10, offset = 0 } = paginationQuery;
return this.playerService.findAll({ limit, offset }); return this.playerService.findAll({ limit, offset });
@ -19,23 +22,36 @@ export class PlayersController {
@Get('top/accuracy') @Get('top/accuracy')
@ApiOperation({ @ApiOperation({
tags: [ 'Player', 'Leaderboard' ], tags: ['Player', 'Leaderboard'],
summary: 'Return a leaderboard of players for a specific accuracy stat' summary: 'Return a leaderboard of players for a specific accuracy stat',
}) })
findTop(@Query() topPlayersQuery: TopPlayersQueryDto) { findTopAccuracy(@Query() topPlayersQuery: TopAccuracyQueryDto) {
const { const {
stat, stat,
gameType, gameType,
minGames = 10, minGames = 10,
minShots = 100, minShots = 100,
limit = 10 limit = 10,
} = topPlayersQuery; } = topPlayersQuery;
return this.playerService.findTop({ return this.playerService.findTopAccuracy({
stat, stat,
gameType, gameType,
minGames, minGames,
minShots, minShots,
limit limit,
});
}
@Get('top/wins')
@ApiOperation({
tags: ['Player', 'Leaderboard'],
summary: 'Return a leaderboard of players for win percentage',
})
findTopWins(@Query() topPlayersQuery: TopWinsQueryDto) {
const { minGames = 100, limit = 10 } = topPlayersQuery;
return this.playerService.findTopWins({
minGames,
limit,
}); });
} }
} }

View file

@ -9,8 +9,8 @@ import { Players } from './entities/Players';
import { GameDetail } from '../game/entities/GameDetail'; import { GameDetail } from '../game/entities/GameDetail';
@Module({ @Module({
imports: [ TypeOrmModule.forFeature([ Players, GameDetail ]), ConfigModule ], imports: [TypeOrmModule.forFeature([Players, GameDetail]), ConfigModule],
providers: [ PlayersService ], providers: [PlayersService],
controllers: [ PlayersController ] controllers: [PlayersController],
}) })
export class PlayersModule {} export class PlayersModule {}

View file

@ -6,15 +6,20 @@ 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'; import {
TopAccuracyQueryDto,
TopWinsQueryDto,
} from '../common/dto/top-players-query.dto';
@Injectable() @Injectable()
export class PlayersService { export class PlayersService {
constructor( constructor(
private readonly connection: Connection, private readonly connection: Connection,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@InjectRepository(Players) private readonly playersRepository: Repository<Players>, @InjectRepository(Players)
@InjectRepository(GameDetail) private readonly gameRepository: Repository<GameDetail> private readonly playersRepository: Repository<Players>,
@InjectRepository(GameDetail)
private readonly gameRepository: Repository<GameDetail>,
) {} ) {}
async findAll(paginationQuery: PaginationQueryDto) { async findAll(paginationQuery: PaginationQueryDto) {
@ -26,8 +31,8 @@ export class PlayersService {
skip: offset, skip: offset,
take: returnMaxLimit, take: returnMaxLimit,
order: { order: {
updatedAt: 'DESC' updatedAt: 'DESC',
} },
}); });
return players; return players;
@ -35,8 +40,8 @@ export class PlayersService {
async findOne(playerGuid: string) { async findOne(playerGuid: string) {
const player = await this.playersRepository.findOne({ const player = await this.playersRepository.findOne({
relations: [ 'gameDetails' ], relations: ['gameDetails'],
where: [ { playerGuid: playerGuid } ] where: [{ playerGuid: playerGuid }],
}); });
if (!player) { if (!player) {
throw new NotFoundException(`Player GUID: ${playerGuid} not found`); throw new NotFoundException(`Player GUID: ${playerGuid} not found`);
@ -44,8 +49,14 @@ export class PlayersService {
return player; return player;
} }
async findTop(topPlayersQuery: TopPlayersQueryDto) { async findTopAccuracy(topAccuracyQuery: TopAccuracyQueryDto) {
const { stat: hitsStat, gameType, minGames, minShots, limit } = topPlayersQuery; const {
stat: hitsStat,
gameType,
minGames,
minShots,
limit,
} = topAccuracyQuery;
const shotsStat = { const shotsStat = {
discDmgHitsTG: 'discShotsFiredTG', discDmgHitsTG: 'discShotsFiredTG',
@ -54,16 +65,18 @@ export class PlayersService {
laserHitsTG: 'laserShotsFiredTG', laserHitsTG: 'laserShotsFiredTG',
laserMATG: 'laserShotsFiredTG', laserMATG: 'laserShotsFiredTG',
cgHitsTG: 'cgShotsFiredTG', cgHitsTG: 'cgShotsFiredTG',
shockHitsTG: 'shockShotsFiredTG' shockHitsTG: 'shockShotsFiredTG',
}[hitsStat] }[hitsStat];
// Possibly make this a query param at some point? // Possibly make this a query param at some point?
const excludeDiscJumps = shotsStat === 'discShotsFiredTG'; const excludeDiscJumps = shotsStat === 'discShotsFiredTG';
const hitsValue = '(game.stats->:hitsStat->>0)::integer'; const hitsValue = '(game.stats->:hitsStat->>0)::integer';
const shotsValue = '(game.stats->:shotsStat->>0)::integer'; const shotsValue = '(game.stats->:shotsStat->>0)::integer';
const discJumpsValue = "COALESCE((game.stats->'discJumpTG'->>0)::integer, 0)"; const discJumpsValue =
const killerDiscJumpsValue = "COALESCE((game.stats->'killerDiscJumpTG'->>0)::integer, 0)"; "COALESCE((game.stats->'discJumpTG'->>0)::integer, 0)";
const killerDiscJumpsValue =
"COALESCE((game.stats->'killerDiscJumpTG'->>0)::integer, 0)";
// pgSQL doesn't let you reference aliased selections you've made in the // pgSQL doesn't let you reference aliased selections you've made in the
// same select statement, so unfortunately these computed JSON values are // same select statement, so unfortunately these computed JSON values are
@ -73,15 +86,14 @@ export class PlayersService {
// parameterized instead. // parameterized instead.
const aggregatedHits = `SUM(${hitsValue})::integer`; const aggregatedHits = `SUM(${hitsValue})::integer`;
// Add sums of `discJumpTG` and `killerDiscJumpTG`. // Add sums of `discJumpTG` and `killerDiscJumpTG`.
const aggregatedDiscJumps = const aggregatedDiscJumps = `(SUM(${discJumpsValue})::integer + SUM(${killerDiscJumpsValue})::integer)`;
`(SUM(${discJumpsValue})::integer + SUM(${killerDiscJumpsValue})::integer)`;
let aggregatedShots = `SUM(${shotsValue})::integer`; let aggregatedShots = `SUM(${shotsValue})::integer`;
if (excludeDiscJumps) { if (excludeDiscJumps) {
// Since subtracting disc jumps could theoretically drop the shots count // Since subtracting disc jumps could theoretically drop the shots count
// to 0, clamp it to at least the number of hits or 1, otherwise it'd be // to 0, clamp it to at least the number of hits or 1, otherwise it'd be
// possible to divide by zero. // possible to divide by zero.
aggregatedShots = `GREATEST(1, ${aggregatedHits}, ${aggregatedShots} - ${aggregatedDiscJumps})` aggregatedShots = `GREATEST(1, ${aggregatedHits}, ${aggregatedShots} - ${aggregatedDiscJumps})`;
} }
// Cast to float to avoid integer division truncating the result. // Cast to float to avoid integer division truncating the result.
@ -90,7 +102,8 @@ export class PlayersService {
// TODO: This whole query could probably be turned into a `ViewEntity` at // TODO: This whole query could probably be turned into a `ViewEntity` at
// some point, but I couldn't get that to work. // some point, but I couldn't get that to work.
let playersQuery = this.playersRepository.createQueryBuilder('player') let playersQuery = this.playersRepository
.createQueryBuilder('player')
.setParameters({ .setParameters({
hitsStat, hitsStat,
shotsStat, shotsStat,
@ -103,29 +116,38 @@ export class PlayersService {
'stats.game_count', 'stats.game_count',
'stats.hits', 'stats.hits',
'stats.shots', 'stats.shots',
'stats.accuracy' 'stats.accuracy',
]) ])
.innerJoin(subQuery => { .innerJoin(
let statsQuery = subQuery (subQuery) => {
.select(['game.player_guid']) let statsQuery = subQuery
.from(GameDetail, 'game') .select(['game.player_guid'])
.addSelect('COUNT(game.id)::integer', 'game_count') .from(GameDetail, 'game')
.addSelect(aggregatedHits, 'hits') .addSelect('COUNT(game.id)::integer', 'game_count')
.addSelect(aggregatedShots, 'shots') .addSelect(aggregatedHits, 'hits')
.addSelect(aggregatedAccuracy, 'accuracy') .addSelect(aggregatedShots, 'shots')
.where(`${shotsValue} > 0`) .addSelect(aggregatedAccuracy, 'accuracy')
.groupBy('game.player_guid'); .where(`${shotsValue} > 0`)
.groupBy('game.player_guid');
if (excludeDiscJumps) { if (excludeDiscJumps) {
statsQuery = statsQuery.addSelect(aggregatedDiscJumps, 'disc_jumps'); statsQuery = statsQuery.addSelect(
} aggregatedDiscJumps,
'disc_jumps',
);
}
if (gameType) { if (gameType) {
statsQuery = statsQuery.andWhere('game.gametype = :gameType', { gameType }) statsQuery = statsQuery.andWhere('game.gametype = :gameType', {
} gameType,
});
}
return statsQuery; return statsQuery;
}, 'stats', 'stats.player_guid = player.player_guid') },
'stats',
'stats.player_guid = player.player_guid',
)
.where('stats.game_count >= :minGames') .where('stats.game_count >= :minGames')
.andWhere('stats.shots >= :minShots') .andWhere('stats.shots >= :minShots')
.orderBy('stats.accuracy', 'DESC') .orderBy('stats.accuracy', 'DESC')
@ -145,14 +167,14 @@ export class PlayersService {
const rows = await playersQuery.getRawMany(); const rows = await playersQuery.getRawMany();
// `getRawMany` was used, so manually snake_case -> camelCase. // `getRawMany` was used, so manually snake_case -> camelCase.
const players = rows.map(row => ({ const players = rows.map((row) => ({
playerGuid: row.player_guid, playerGuid: row.player_guid,
playerName: row.player_name, playerName: row.player_name,
gameCount: row.game_count, gameCount: row.game_count,
hits: row.hits, hits: row.hits,
shots: row.shots, shots: row.shots,
discJumps: row.disc_jumps, discJumps: row.disc_jumps,
accuracy: row.accuracy accuracy: row.accuracy,
})); }));
return { return {
@ -165,7 +187,173 @@ export class PlayersService {
minGames, minGames,
minShots, minShots,
limit, limit,
players players,
};
}
async findTopWins(topWinsQuery: TopWinsQueryDto) {
const { minGames, limit } = topWinsQuery;
const gameType = 'CTFGame';
const query = this.playersRepository
.createQueryBuilder('player')
.setParameters({
gameType,
minGames,
})
.select(['stats.player_name', 'stats.player_guid'])
.addSelect('COUNT(stats.game_id)::integer', 'game_count')
.addSelect(
"COUNT(stats.player_match_result = 'win' OR NULL)::integer",
'win_count',
)
.addSelect(
"COUNT(stats.player_match_result = 'loss' OR NULL)::integer",
'loss_count',
)
.addSelect(
"COUNT(stats.player_match_result = 'draw' OR NULL)::integer",
'draw_count',
)
.addSelect(
"(COUNT(stats.player_match_result = 'win' OR NULL)::float + COUNT(stats.player_match_result = 'draw' OR NULL)::float / 2.0) / COUNT(stats.game_id)::float",
'win_percent',
)
.innerJoin(
(qb) => {
return (
qb
.select([
'game.player_name',
'game.player_guid',
'game.map',
'game.datestamp',
'join_g.*',
])
// Determine whether they spent at least 80% of the total match time on
// one team, and then determine whether that means they won or lost.
// Note that this team may be different from `dtTeamGame`.
.addSelect(
`CASE
WHEN
((game.stats->'timeOnTeamOneTG'->>0)::float / (game.stats->'matchRunTimeTG'->>0)::float) >= 0.8
THEN CASE
WHEN
join_g.score_storm > join_g.score_inferno
THEN 'win'
WHEN
join_g.score_storm < join_g.score_inferno
THEN 'loss'
WHEN
join_g.score_storm = join_g.score_inferno
THEN 'draw'
END
WHEN
((game.stats->'timeOnTeamTwoTG'->>0)::float / (game.stats->'matchRunTimeTG'->>0)::float) >= 0.8
THEN CASE
WHEN
join_g.score_inferno > join_g.score_storm
THEN 'win'
WHEN
join_g.score_inferno < join_g.score_storm
THEN 'loss'
WHEN
join_g.score_inferno = join_g.score_storm
THEN 'draw'
END
ELSE 'none'
END`.replace(/\s+/g, ' '),
'player_match_result',
)
.from(GameDetail, 'game')
.innerJoin(
(qb) => {
return (
qb
.select(['g.game_id'])
.addSelect(
"COUNT((g.stats->'dtTeamGame'->>0)::integer = 1 OR NULL)::integer",
'team_size_storm',
)
.addSelect(
"COUNT((g.stats->'dtTeamGame'->>0)::integer = 2 OR NULL)::integer",
'team_size_inferno',
)
// `teamScoreGame` can get screwed up: players on one team can be
// assigned the score from the other team (most likely due to team
// switching). So to determine each team's score, we take the
// average `teamScoreGame` of all players on that team. Due to the
// mentioned bug, it won't be 100% accurate, but should be good
// enough to determine which team won most of the time, which is all
// that matters for this stat.
.addSelect(
"AVG(CASE WHEN (g.stats->'dtTeamGame'->>0)::integer = 1 THEN (g.stats->'teamScoreGame'->>0)::integer ELSE NULL END)::integer / 100",
'score_storm',
)
.addSelect(
"AVG(CASE WHEN (g.stats->'dtTeamGame'->>0)::integer = 2 THEN (g.stats->'teamScoreGame'->>0)::integer ELSE NULL END)::integer / 100",
'score_inferno',
)
.from(GameDetail, 'g')
.groupBy('g.game_id')
);
},
'join_g',
'join_g.game_id = game.game_id',
)
.where('game.gametype = :gameType')
// Only count if the player's `gamePCT` was at least 80%. This is
// effectively how much of the match they were present for.
.andWhere("(game.stats->'gamePCT'->>0)::float >= 80")
// As an extra precaution against prematurely ended matches, only count
// games that lasted at least 10 minutes.
.andWhere("(game.stats->'matchRunTimeTG'->>0)::float >= 10")
// Each team must have at least 2 players.
.andWhere('join_g.team_size_storm >= 2')
.andWhere('join_g.team_size_inferno >= 2')
);
},
'stats',
'stats.player_guid = player.player_guid',
)
.where("stats.player_match_result != 'none'")
.having('COUNT(stats.game_id)::integer >= :minGames')
.groupBy('stats.player_guid')
.addGroupBy('stats.player_name')
.orderBy(
"(COUNT(stats.player_match_result = 'win' OR NULL)::float + COUNT(stats.player_match_result = 'draw' OR NULL)::float / 2.0) / COUNT(stats.game_id)::float",
'DESC',
)
.limit(limit);
// 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 query.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,
winCount: row.win_count,
lossCount: row.loss_count,
drawCount: row.draw_count,
winPercent: row.win_percent,
}));
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.
minGames,
gameType,
limit,
players,
}; };
} }
} }