feat: add ranks back in match-details

This commit is contained in:
Kalane 2021-09-16 22:29:20 +02:00
parent 973355c201
commit 6f0beec0d2
17 changed files with 291 additions and 31 deletions

View file

@ -5,6 +5,7 @@
:data="allyTeam" :data="allyTeam"
:all-players="[...allyTeam.players, ...enemyTeam.players]" :all-players="[...allyTeam.players, ...enemyTeam.players]"
:ally-team="true" :ally-team="true"
:ranks-loaded="data.ranksLoaded"
/> />
<div class="flex items-start justify-between px-3 py-2"> <div class="flex items-start justify-between px-3 py-2">
@ -23,6 +24,7 @@
:data="enemyTeam" :data="enemyTeam"
:all-players="[...allyTeam.players, ...enemyTeam.players]" :all-players="[...allyTeam.players, ...enemyTeam.players]"
:ally-team="false" :ally-team="false"
:ranks-loaded="data.ranksLoaded"
/> />
</div> </div>
<div v-else-if="data.status === 'loading' && detailsOpen"> <div v-else-if="data.status === 'loading' && detailsOpen">

View file

@ -228,7 +228,7 @@
{{ player.rank.shortName }} {{ player.rank.shortName }}
</div> </div>
</div> </div>
<div v-else-if="player.rank === undefined"> <div v-else-if="!ranksLoaded">
<DotsLoader width="30px" dot-width="10px" /> <DotsLoader width="30px" dot-width="10px" />
</div> </div>
<div v-else class="w-5 h-5"> <div v-else class="w-5 h-5">
@ -350,6 +350,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
ranksLoaded: {
type: Boolean,
default: false
}
}, },
computed: { computed: {

View file

@ -14,16 +14,39 @@ export const mutations = {
state.matches.push({ matchId, status: 'loading' }) state.matches.push({ matchId, status: 'loading' })
} }
}, },
MATCH_FOUND(state, matchDetails) { MATCH_FOUND(state, {matchDetails, ranksLoaded }) {
matchDetails.status = 'loaded' matchDetails.status = 'loaded'
matchDetails.ranksLoaded = ranksLoaded
// Set SoloQ as rank for now
if(ranksLoaded) {
for (const player of matchDetails.blueTeam.players) {
player.rank = player.rank && player.rank[420]
}
for (const player of matchDetails.redTeam.players) {
player.rank = player.rank && player.rank[420]
}
}
const index = state.matches.findIndex(m => m.gameId === matchDetails.gameId) const index = state.matches.findIndex(m => m.gameId === matchDetails.gameId)
Vue.set(state.matches, index, matchDetails) Vue.set(state.matches, index, matchDetails)
}, },
MATCH_RANKS_FOUND(state, { gameId, blueTeam, redTeam }) { MATCH_RANKS_FOUND(state, { gameId, ranksByPlayer }) {
const match = state.matches.find(m => m.gameId === gameId) const match = state.matches.find(m => m.gameId === gameId)
match.blueTeam.players = blueTeam
match.redTeam.players = redTeam for (const player of match.blueTeam.players) {
const ranks = ranksByPlayer[player.id]
if(!ranks) continue
Vue.set(player, 'rank', ranks[420])
}
for (const player of match.redTeam.players) {
const ranks = ranksByPlayer[player.id]
if(!ranks) continue
Vue.set(player, 'rank', ranks[420])
}
match.ranksLoaded = true
}, },
} }
@ -35,17 +58,17 @@ export const actions = {
const resp = await axios(({ url: 'match/details', data: { matchId }, method: 'POST' })).catch(() => { }) const resp = await axios(({ url: 'match/details', data: { matchId }, method: 'POST' })).catch(() => { })
console.log('--- DETAILS INFOS ---') console.log('--- DETAILS INFOS ---')
console.log(resp.data) console.log(resp.data)
commit('MATCH_FOUND', resp.data.matchDetails) const {matchDetails, ranksLoaded} = resp.data
commit('MATCH_FOUND', {matchDetails, ranksLoaded })
// TODO: add ranks back when it's done on the API // If the ranks of the players are not yet known
// // If the ranks of the players are not yet known if (!ranksLoaded) {
// if (resp.data.matchDetails.blueTeam.players[0].rank === undefined) { const ranks = await axios(({ url: 'match/details/ranks', data: { matchId }, method: 'POST' })).catch(() => { })
// const ranks = await axios(({ url: 'match/details/ranks', data: { gameId, region }, method: 'POST' })).catch(() => { }) if (!ranks) return
// if (!ranks) return console.log('--- RANK OF MATCH DETAILS ---')
// console.log('--- RANK OF MATCH DETAILS ---') console.log(ranks.data)
// console.log(ranks.data) commit('MATCH_RANKS_FOUND', { matchId, ranksByPlayer: ranks.data })
// commit('MATCH_RANKS_FOUND', { gameId, ...ranks.data }) }
// }
} }
} }

View file

@ -1,6 +1,8 @@
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Match from 'App/Models/Match' import Match from 'App/Models/Match'
import MatchPlayerRankParser from 'App/Parsers/MatchPlayerRankParser'
import DetailedMatchSerializer from 'App/Serializers/DetailedMatchSerializer' import DetailedMatchSerializer from 'App/Serializers/DetailedMatchSerializer'
import MatchPlayerRankSerializer from 'App/Serializers/MatchPlayerRankSerializer'
import MatchService from 'App/Services/MatchService' import MatchService from 'App/Services/MatchService'
import StatsService from 'App/Services/StatsService' import StatsService from 'App/Services/StatsService'
import DetailedMatchValidator from 'App/Validators/DetailedMatchValidator' import DetailedMatchValidator from 'App/Validators/DetailedMatchValidator'
@ -8,7 +10,7 @@ import MatchesIndexValidator from 'App/Validators/MatchesIndexValidator'
export default class MatchesController { export default class MatchesController {
/** /**
* POST - Return data from matches searched by gameIds * POST - Return data from matches searched by matchIds
* @param ctx * @param ctx
*/ */
public async index({ request, response }: HttpContextContract) { public async index({ request, response }: HttpContextContract) {
@ -33,15 +35,33 @@ export default class MatchesController {
const match = await Match.query() const match = await Match.query()
.where('id', matchId) .where('id', matchId)
.preload('teams') .preload('teams')
.preload('players') .preload('players', (playersQuery) => {
playersQuery.preload('ranks')
})
.firstOrFail() .firstOrFail()
const matchDetails = DetailedMatchSerializer.serializeOneMatch(match) const { match: matchDetails, ranksLoaded } = DetailedMatchSerializer.serializeOneMatch(match)
console.timeEnd('MatchDetails') console.timeEnd('MatchDetails')
return response.json({ return response.json({
matchDetails, matchDetails,
ranksLoaded,
}) })
} }
/**
* POST - Return ranks of players for a specific game
* @param ctx
*/
public async showRanks({ request, response }: HttpContextContract) {
console.time('Ranks')
const { matchId } = await request.validate(DetailedMatchValidator)
const match = await Match.query().where('id', matchId).preload('players').firstOrFail()
const parsedRanks = await MatchPlayerRankParser.parse(match)
const serializedRanks = MatchPlayerRankSerializer.serialize(parsedRanks)
console.timeEnd('Ranks')
return response.json(serializedRanks)
}
} }

View file

@ -45,7 +45,7 @@ export default class SummonersController {
finalJSON.current = currentGame finalJSON.current = currentGame
// RANKED STATS // RANKED STATS
finalJSON.ranked = await SummonerService.getRanked(account, region) finalJSON.ranked = await SummonerService.getRanked(account.id, region)
// RECENT ACTIVITY // RECENT ACTIVITY
finalJSON.recentActivity = await StatsService.getRecentActivity(account.puuid) finalJSON.recentActivity = await StatsService.getRecentActivity(account.puuid)
@ -78,7 +78,6 @@ export default class SummonersController {
finalJSON.matchesDetails = await MatchService.getMatches(region, matchIds, puuid) finalJSON.matchesDetails = await MatchService.getMatches(region, matchIds, puuid)
// TODO: STATS
console.time('STATS') console.time('STATS')
finalJSON.stats = await StatsService.getSummonerStats(puuid, season) finalJSON.stats = await StatsService.getSummonerStats(puuid, season)
console.timeEnd('STATS') console.timeEnd('STATS')

View file

@ -1,5 +1,6 @@
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm' import { BaseModel, BelongsTo, belongsTo, column, HasMany, hasMany } from '@ioc:Adonis/Lucid/Orm'
import Match from './Match' import Match from './Match'
import MatchPlayerRank from './MatchPlayerRank'
import Summoner from './Summoner' import Summoner from './Summoner'
export default class MatchPlayer extends BaseModel { export default class MatchPlayer extends BaseModel {
@ -12,6 +13,12 @@ export default class MatchPlayer extends BaseModel {
@belongsTo(() => Match) @belongsTo(() => Match)
public match: BelongsTo<typeof Match> public match: BelongsTo<typeof Match>
@hasMany(() => MatchPlayerRank, {
localKey: 'id',
foreignKey: 'playerId',
})
public ranks: HasMany<typeof MatchPlayerRank>
@column() @column()
public participantId: number public participantId: number

View file

@ -0,0 +1,38 @@
import { DateTime } from 'luxon'
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm'
import MatchPlayer from './MatchPlayer'
export default class MatchPlayerRank extends BaseModel {
@column({ isPrimary: true })
public id: number
@column()
public playerId: number
@belongsTo(() => MatchPlayer, {
localKey: 'id',
foreignKey: 'playerId',
})
public player: BelongsTo<typeof MatchPlayer>
@column()
public gamemode: number
@column()
public tier: string
@column()
public rank: number
@column()
public lp: number
@column()
public wins: number
@column()
public losses: number
@column.dateTime({ autoCreate: true })
public createdAt: DateTime
}

View file

@ -0,0 +1,43 @@
import Database from '@ioc:Adonis/Lucid/Database'
import { notEmpty } from 'App/helpers'
import Match from 'App/Models/Match'
import MatchPlayer from 'App/Models/MatchPlayer'
import SummonerService from 'App/Services/SummonerService'
import { PlayerRankParsed } from './ParsedType'
class MatchPlayerRankParser {
public async parse(match: Match): Promise<PlayerRankParsed[]> {
const requests = match.players.map((p) => SummonerService.getRanked(p.summonerId, match.region))
const ranks = await Promise.all(requests)
const parsedRanks = ranks
.map((rank) => {
return Object.entries(rank).map(([queue, data]) => {
let player: MatchPlayer | undefined
if (!data || !(player = match.players.find((p) => p.summonerId === data.summonerId))) {
return
}
const rank: PlayerRankParsed = {
player_id: player.id,
gamemode: queue === 'soloQ' ? 420 : 440,
tier: data.tier,
rank: SummonerService.leaguesNumbers[data.rank],
lp: data.leaguePoints,
wins: data.wins,
losses: data.losses,
}
return rank
})
})
.flat()
.filter(notEmpty)
// Store ranks in DB
await Database.table('match_player_ranks').multiInsert(parsedRanks)
return parsedRanks
}
}
export default new MatchPlayerRankParser()

View file

@ -15,3 +15,13 @@ export enum TeamPosition {
BOTTOM, BOTTOM,
UTILITY, UTILITY,
} }
export interface PlayerRankParsed {
player_id: number
gamemode: number
tier: string
rank: number
lp: number
wins: number
losses: number
}

View file

@ -75,11 +75,19 @@ class DetailedMatchSerializer extends MatchSerializer {
).toFixed(1) + '%', ).toFixed(1) + '%',
dmgTaken: +((player.damageTaken * 100) / teamStats.dmgTaken).toFixed(1) + '%', dmgTaken: +((player.damageTaken * 100) / teamStats.dmgTaken).toFixed(1) + '%',
} }
const rank = player.ranks.length
? player.ranks.reduce((acc, rank) => {
acc[rank.gamemode] = this.getPlayerRank(rank)
return acc
}, {})
: undefined
return { return {
...this.getPlayerBase(player), ...this.getPlayerBase(player),
...this.getRuneIcons(player.perksSelected, player.perksSecondaryStyle), ...this.getRuneIcons(player.perksSelected, player.perksSecondaryStyle),
id: player.id,
stats, stats,
percentStats, percentStats,
rank,
} }
}) })
.sort(sortTeamByRole) .sort(sortTeamByRole)
@ -106,18 +114,24 @@ class DetailedMatchSerializer extends MatchSerializer {
} }
} }
public serializeOneMatch(match: Match): SerializedDetailedMatch { public serializeOneMatch(match: Match): { match: SerializedDetailedMatch; ranksLoaded: boolean } {
const blueTeam = match.teams.find((team) => team.color === 100)! const blueTeam = match.teams.find((team) => team.color === 100)!
const redTeam = match.teams.find((team) => team.color === 200)! const redTeam = match.teams.find((team) => team.color === 200)!
const bluePlayers: MatchPlayer[] = [] const bluePlayers: MatchPlayer[] = []
const redPlayers: MatchPlayer[] = [] const redPlayers: MatchPlayer[] = []
let ranksLoaded = false
for (const p of match.players) { for (const p of match.players) {
p.team === 100 ? bluePlayers.push(p) : redPlayers.push(p) p.team === 100 ? bluePlayers.push(p) : redPlayers.push(p)
if (p.ranks.length) {
ranksLoaded = true
}
} }
return { const serializedMatch = {
blueTeam: this.getTeamDetailed(blueTeam, bluePlayers, match.gameDuration), blueTeam: this.getTeamDetailed(blueTeam, bluePlayers, match.gameDuration),
date: match.date, date: match.date,
matchId: match.id, matchId: match.id,
@ -128,6 +142,11 @@ class DetailedMatchSerializer extends MatchSerializer {
season: match.season, season: match.season,
time: match.gameDuration, time: match.gameDuration,
} }
return {
match: serializedMatch,
ranksLoaded,
}
} }
} }

View file

@ -0,0 +1,20 @@
import { PlayerRankParsed } from 'App/Parsers/ParsedType'
import MatchSerializer from './MatchSerializer'
import { SerializedPlayerRanksList } from './SerializedTypes'
class MatchPlayerRankSerializer extends MatchSerializer {
public serialize(ranks: PlayerRankParsed[]): SerializedPlayerRanksList {
const result = ranks.reduce((acc, rank) => {
if (!acc[rank.player_id]) {
acc[rank.player_id] = {}
}
acc[rank.player_id][rank.gamemode] = this.getPlayerRank(rank)
return acc
}, {} as SerializedPlayerRanksList)
return result
}
}
export default new MatchPlayerRankSerializer()

View file

@ -1,6 +1,7 @@
import MatchPlayer from 'App/Models/MatchPlayer' import MatchPlayer from 'App/Models/MatchPlayer'
import { TeamPosition } from 'App/Parsers/ParsedType' import { PlayerRankParsed, TeamPosition } from 'App/Parsers/ParsedType'
import CDragonService from 'App/Services/CDragonService' import CDragonService from 'App/Services/CDragonService'
import SummonerService from 'App/Services/SummonerService'
import { import {
SerializedBasePlayer, SerializedBasePlayer,
SerializedMatchChampion, SerializedMatchChampion,
@ -99,4 +100,15 @@ export default abstract class MatchSerializer {
summonerSpell2: this.getSummonerSpell(player.summoner2Id), summonerSpell2: this.getSummonerSpell(player.summoner2Id),
} }
} }
protected getPlayerRank(rank: PlayerRankParsed) {
return {
tier: rank.tier,
rank: rank.rank,
lp: rank.lp,
wins: rank.wins,
losses: rank.losses,
shortName: SummonerService.getRankedShortName(rank),
}
}
} }

View file

@ -121,6 +121,7 @@ export interface SerializedDetailedMatchBan {
} }
export interface SerializedDetailedMatchPlayer extends SerializedBasePlayer { export interface SerializedDetailedMatchPlayer extends SerializedBasePlayer {
id: number
stats: SerializedDetailedMatchStats stats: SerializedDetailedMatchStats
percentStats: SerializedDetailedMatchPercentStats percentStats: SerializedDetailedMatchPercentStats
primaryRune: string | null primaryRune: string | null
@ -160,3 +161,20 @@ export interface SerializedDetailedMatchPercentStats {
minions: number minions: number
vision: number vision: number
} }
export interface SerializedPlayerRanksList {
[summonerId: string]: SerializedPlayerRanks
}
export interface SerializedPlayerRanks {
[gamemode: number]: SerializedPlayerRank
}
export interface SerializedPlayerRank {
tier: string
rank: number
lp: number
wins: number
losses: number
shortName: number | string
}

View file

@ -2,6 +2,8 @@ import Jax from './Jax'
import { SummonerDTO } from 'App/Services/Jax/src/Endpoints/SummonerEndpoint' import { SummonerDTO } from 'App/Services/Jax/src/Endpoints/SummonerEndpoint'
import { LeagueEntryDTO } from './Jax/src/Endpoints/LeagueEndpoint' import { LeagueEntryDTO } from './Jax/src/Endpoints/LeagueEndpoint'
import Summoner from 'App/Models/Summoner' import Summoner from 'App/Models/Summoner'
import { PlayerRankParsed } from 'App/Parsers/ParsedType'
import MatchPlayerRank from 'App/Models/MatchPlayerRank'
export interface LeagueEntriesByQueue { export interface LeagueEntriesByQueue {
soloQ?: LeagueEntryByQueue soloQ?: LeagueEntryByQueue
@ -15,8 +17,16 @@ export interface LeagueEntryByQueue extends LeagueEntryDTO {
} }
class SummonerService { class SummonerService {
private uniqueLeagues = ['CHALLENGER', 'GRANDMASTER', 'MASTER'] private readonly uniqueLeagues = ['CHALLENGER', 'GRANDMASTER', 'MASTER']
private leaguesNumbers = { I: 1, II: 2, III: 3, IV: 4 } public readonly leaguesNumbers = { I: 1, II: 2, III: 3, IV: 4 }
public getRankedShortName(rank: PlayerRankParsed | MatchPlayerRank) {
return this.uniqueLeagues.includes(rank.tier) ? rank.lp : rank.tier[0] + rank.rank
}
public getWinrate(wins: number, losses: number) {
return +((wins * 100) / (wins + losses)).toFixed(1) + '%'
}
/** /**
* Helper to transform League Data from the Riot API * Helper to transform League Data from the Riot API
@ -29,7 +39,7 @@ class SummonerService {
const fullRank = this.uniqueLeagues.includes(league.tier) const fullRank = this.uniqueLeagues.includes(league.tier)
? league.tier ? league.tier
: `${league.tier} ${league.rank}` : `${league.tier} ${league.rank}`
const winrate = +((league.wins * 100) / (league.wins + league.losses)).toFixed(1) + '%' const winrate = this.getWinrate(league.wins, league.losses)
const shortName = this.uniqueLeagues.includes(league.tier) const shortName = this.uniqueLeagues.includes(league.tier)
? league.leaguePoints ? league.leaguePoints
: league.tier[0] + this.leaguesNumbers[league.rank] : league.tier[0] + this.leaguesNumbers[league.rank]
@ -70,8 +80,8 @@ class SummonerService {
* @param account * @param account
* @param region * @param region
*/ */
public async getRanked(account: SummonerDTO, region: string): Promise<LeagueEntriesByQueue> { public async getRanked(summonerId: string, region: string): Promise<LeagueEntriesByQueue> {
const ranked = await Jax.League.summonerID(account.id, region) const ranked = await Jax.League.summonerID(summonerId, region)
const result: LeagueEntriesByQueue = {} const result: LeagueEntriesByQueue = {}
if (ranked && ranked.length) { if (ranked && ranked.length) {
@ -80,7 +90,6 @@ class SummonerService {
result.flex5v5 = result.flex5v5 =
this.getleagueData(ranked.find((e) => e.queueType === 'RANKED_FLEX_SR')) || undefined this.getleagueData(ranked.find((e) => e.queueType === 'RANKED_FLEX_SR')) || undefined
} }
return result return result
} }
} }

View file

@ -110,3 +110,10 @@ export function sortTeamByRole<T extends SortableByRole>(a: T, b: T) {
const sortingArr = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'UTILITY'] const sortingArr = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'UTILITY']
return sortingArr.indexOf(a.role) - sortingArr.indexOf(b.role) return sortingArr.indexOf(a.role) - sortingArr.indexOf(b.role)
} }
// https://stackoverflow.com/a/46700791/9188650
export function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
if (value === null || value === undefined) return false
const testDummy: TValue = value
return true
}

View file

@ -0,0 +1,29 @@
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class MatchPlayerRanks extends BaseSchema {
protected tableName = 'match_player_ranks'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('player_id').unsigned().notNullable()
table.integer('gamemode').notNullable()
table.string('tier', 11).notNullable()
table.integer('rank').notNullable()
table.integer('lp').notNullable()
table.integer('wins').notNullable()
table.integer('losses').notNullable()
/**
* Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
*/
table.timestamp('created_at', { useTz: true })
})
}
public async down() {
this.schema.dropTable(this.tableName)
}
}

View file

@ -37,6 +37,6 @@ Route.post('/summoner/records', 'SummonersController.records')
Route.post('/match', 'MatchesController.index') Route.post('/match', 'MatchesController.index')
Route.post('/match/details', 'MatchesController.show') Route.post('/match/details', 'MatchesController.show')
// Route.post('/match/details/ranks', 'MatchesController.showRanks') Route.post('/match/details/ranks', 'MatchesController.showRanks')
Route.get('/cdragon/runes', 'CDragonController.runes') Route.get('/cdragon/runes', 'CDragonController.runes')