diff --git a/server-new/app/Models/Match.ts b/server-new/app/Models/Match.ts index 776b3bc..a899e8d 100644 --- a/server-new/app/Models/Match.ts +++ b/server-new/app/Models/Match.ts @@ -20,10 +20,10 @@ export interface ParticipantDetails { summonerId: string, champion: Champion, role: string, - primaryRune: string, - secondaryRune: string, + primaryRune: string | null, + secondaryRune: string | null, level: number, - items: Item[], + items: (Item | null)[], firstSum: SummonerSpell | number, secondSum: SummonerSpell | number, stats: Stats, @@ -39,32 +39,32 @@ export interface Champion { icon: string } -interface SummonerSpell { +export interface SummonerSpell { name: string, description: string, icon: string } -interface Rank { +export interface Rank { tier: string, shortName: string } -interface ParticipantBasic { +export interface ParticipantBasic { account_id: string, name: string, role: string, champion: Champion } -interface Item { +export interface Item { image: string, name: string, description: string, price: number } -interface Stats { +export interface Stats { kills: number, deaths: number, assists: number, @@ -74,21 +74,21 @@ interface Stats { dmgChamp: number, dmgObj: number, dmgTaken: number, - kda: number, + kda: number | string, realKda: number, - criticalStrike: number, - killingSpree: number, - doubleKills: number, - tripleKills: number, - quadraKills: number, - pentaKills: number, - heal: number, - towers: number, - longestLiving: number, - kp: number, + criticalStrike?: number, + killingSpree?: number, + doubleKills?: number, + tripleKills?: number, + quadraKills?: number, + pentaKills?: number, + heal?: number, + towers?: number, + longestLiving?: number, + kp: number | string, } -interface PercentStats { +export interface PercentStats { minions: number, vision: number, gold: string, diff --git a/server-new/app/Services/RoleIdentiticationService.ts b/server-new/app/Services/RoleIdentiticationService.ts index d031381..f2a2ae2 100644 --- a/server-new/app/Services/RoleIdentiticationService.ts +++ b/server-new/app/Services/RoleIdentiticationService.ts @@ -1,6 +1,14 @@ import Redis from '@ioc:Adonis/Addons/Redis' import got from 'got/dist/source' +export interface FinalRoleComposition { + 'TOP'?: number, + 'JUNGLE'?: number, + 'MIDDLE'?: number, + 'BOTTOM'?: number, + 'SUPPORT'?: number, +} + export interface RoleComposition { 'TOP'?: number, 'JUNGLE'?: number, @@ -186,7 +194,12 @@ class RoleIdentificationService { * @param jungle * @param support */ - public getRoles (championPositions: ChampionsRates, composition: number[], jungle?: number, support?: number) { + public getRoles ( + championPositions: ChampionsRates, + composition: number[], + jungle?: number, + support?: number + ): FinalRoleComposition { // Set composition champion playrate to 0% if not present in the json data for (const compChamp of composition) { if (championPositions[compChamp]) { diff --git a/server-new/app/Services/SummonerService.ts b/server-new/app/Services/SummonerService.ts index dea8713..02a3fdc 100644 --- a/server-new/app/Services/SummonerService.ts +++ b/server-new/app/Services/SummonerService.ts @@ -3,6 +3,17 @@ import { SummonerDTO } from 'App/Services/Jax/src/Endpoints/SummonerEndpoint' import { LeagueEntryDTO } from './Jax/src/Endpoints/LeagueEndpoint' import { SummonerModel } from 'App/Models/Summoner' +export interface LeagueEntriesByQueue { + soloQ?: LeagueEntryByQueue, + flex5v5?: LeagueEntryByQueue +} + +export interface LeagueEntryByQueue extends LeagueEntryDTO { + fullRank: string, + winrate: string, + shortName: string | number +} + class SummonerService { private uniqueLeagues = ['CHALLENGER', 'GRANDMASTER', 'MASTER'] private leaguesNumbers = { 'I': 1, 'II': 2, 'III': 3, 'IV': 4 } @@ -11,7 +22,7 @@ class SummonerService { * Helper to transform League Data from the Riot API * @param league raw data of the league from Riot API */ - private getleagueData (league?: LeagueEntryDTO) { + private getleagueData (league?: LeagueEntryDTO): LeagueEntryByQueue | null { if (!league) { return null } @@ -45,7 +56,7 @@ class SummonerService { * @param account of the summoner * @param summonerDB summoner in the database */ - public getAllSummonerNames (account: SummonerDTO, summonerDB:SummonerModel) { + public getAllSummonerNames (account: SummonerDTO, summonerDB: SummonerModel) { const names = summonerDB.names ? summonerDB.names : [] if (!names.find(n => n.name === account.name)) { @@ -64,12 +75,11 @@ class SummonerService { * @param account * @param region */ - public async getRanked (account: SummonerDTO, region: string) { + public async getRanked (account: SummonerDTO, region: string): Promise { const ranked = await Jax.League.summonerID(account.id, region) const result = { - soloQ: this.getleagueData(ranked.find(e => e.queueType === 'RANKED_SOLO_5x5')) || null, - flex5v5: this.getleagueData(ranked.find(e => e.queueType === 'RANKED_FLEX_SR')) || null, - flex3v3: this.getleagueData(ranked.find(e => e.queueType === 'RANKED_FLEX_TT')) || null, + soloQ: this.getleagueData(ranked.find(e => e.queueType === 'RANKED_SOLO_5x5')) || undefined, + flex5v5: this.getleagueData(ranked.find(e => e.queueType === 'RANKED_FLEX_SR')) || undefined, } return result } diff --git a/server-new/app/Transformers/BasicMatchTransformer.ts b/server-new/app/Transformers/BasicMatchTransformer.ts new file mode 100644 index 0000000..74e2d33 --- /dev/null +++ b/server-new/app/Transformers/BasicMatchTransformer.ts @@ -0,0 +1,83 @@ +import { MatchModel, ParticipantBasic } from 'App/Models/Match' +import { MatchDto } from 'App/Services/Jax/src/Endpoints/MatchEndpoint' +import { SummonerDTO } from 'App/Services/Jax/src/Endpoints/SummonerEndpoint' +import MatchTransformer from 'App/Transformers/MatchTransformer' + +class BasicMatchTransformer extends MatchTransformer { + /** + * Transform raw data for 1 match + * @param match + * @param account + */ + private transformOneMatch (match: MatchDto, account: SummonerDTO) { + // Global data about the match + const globalInfos = super.getGameInfos(match) + + const identity = match.participantIdentities.find((p) => p.player.currentAccountId === account.accountId) + const player = match.participants[identity!.participantId - 1] + + let win = match.teams.find((t) => t.teamId === player.teamId)!.win + + // Match less than 5min + if (match.gameDuration < 300) { + win = 'Remake' + } + + // Player data + const playerData = super.getPlayerData(match, player, false) + + // Teams data + const allyTeam:ParticipantBasic[] = [] + const enemyTeam:ParticipantBasic[] = [] + for (let summoner of match.participantIdentities) { + const allData = match.participants[summoner.participantId - 1] + const playerInfos = { + account_id: summoner.player.currentAccountId, + name: summoner.player.summonerName, + role: super.getRoleName(allData.timeline, match.queueId), + champion: super.getChampion(allData.championId), + } + + if (allData.teamId === player.teamId) { + allyTeam.push(playerInfos) + } else { + enemyTeam.push(playerInfos) + } + } + + // Roles + super.getMatchRoles(match, allyTeam, enemyTeam, player.teamId, playerData) + + return { + account_id: identity!.player.currentAccountId, + summoner_puuid: account.puuid, + gameId: match.gameId, + result: win, + allyTeam, + enemyTeam, + ...globalInfos, + ...playerData, + } + } + + /** + * Transform raw data from Riot API + * @param matches data from Riot API, Array of match or a single match + * @param ctx context + */ + public async transform (matches: MatchDto[] | MatchDto, { account }: { account: SummonerDTO }) { + await super.getContext() + + if (Array.isArray(matches)) { + const finalMatches:MatchModel[] = [] + matches.forEach((match, index) => { + finalMatches[index] = this.transformOneMatch(match, account) + }) + return finalMatches + } else { + return this.transformOneMatch(matches, account) as MatchModel + } + } +} + +export default new BasicMatchTransformer() diff --git a/server-new/app/Transformers/LiveMatchTransformer.ts b/server-new/app/Transformers/LiveMatchTransformer.ts new file mode 100644 index 0000000..9d4018f --- /dev/null +++ b/server-new/app/Transformers/LiveMatchTransformer.ts @@ -0,0 +1,72 @@ +import { queuesWithRole } from 'App/helpers' +import { CurrentGameInfo, CurrentGameParticipant } from 'App/Services/Jax/src/Endpoints/SpectatorEndpoint' +import { FinalRoleComposition } from 'App/Services/RoleIdentiticationService' +import SummonerService, { LeagueEntriesByQueue } from 'App/Services/SummonerService' +import MatchTransformer, { PlayerRole } from './MatchTransformer' + +class LiveMatchTransformer extends MatchTransformer { + /** + * Get player soloQ and flex rank from his summonerName + * @param participant + * @param region + */ + private async getPlayerRank (participant: CurrentGameParticipant, region: string) { + const account = await SummonerService.getAccount(participant.summonerName, region) + let ranked: LeagueEntriesByQueue + if (account) { + ranked = await SummonerService.getRanked(account, region) + } + + return { + ...participant, + level: account ? account.summonerLevel : undefined, + rank: account ? ranked! : undefined, + } + } + + /** + * Transform raw data from Riot API + * @param liveMatch + * @param ctx + */ + public async transform (liveMatch: CurrentGameInfo, { region }: { region: string }) { + await super.getContext() + + // Roles + const blueTeam: PlayerRole[] = [] // 100 + const redTeam: PlayerRole[] = [] // 200 + let blueRoles: FinalRoleComposition = {} + let redRoles: FinalRoleComposition = {} + const needsRole = this.championRoles && queuesWithRole.includes(liveMatch.gameQueueConfigId) + if (needsRole) { + liveMatch.participants.map(p => { + const playerRole = { champion: p.championId, jungle: p.spell1Id === 11 || p.spell2Id === 11 } + p.teamId === 100 ? blueTeam.push(playerRole) : redTeam.push(playerRole) + }) + + blueRoles = super.getTeamRoles(blueTeam) + redRoles = super.getTeamRoles(redTeam) + } + + for (const participant of liveMatch.participants) { + // Perks + participant.runes = participant.perks ? + super.getPerksImages(participant.perks.perkIds[0], participant.perks.perkSubStyle) + : {} + + // Roles + if (needsRole) { + const roles = participant.teamId === 100 ? blueRoles : redRoles + participant.role = Object.entries(roles).find(([, champion]) => participant.championId === champion)![0] + } + } + + // Ranks + const requestsParticipants = liveMatch.participants.map(p => this.getPlayerRank(p, region)) + liveMatch.participants = await Promise.all(requestsParticipants) + + return liveMatch + } +} + +export default new LiveMatchTransformer() diff --git a/server-new/app/Transformers/MatchTransformer.ts b/server-new/app/Transformers/MatchTransformer.ts new file mode 100644 index 0000000..83e0966 --- /dev/null +++ b/server-new/app/Transformers/MatchTransformer.ts @@ -0,0 +1,314 @@ +import { getSeasonNumber, queuesWithRole, sortTeamByRole, supportItems } from 'App/helpers' +import Jax from 'App/Services/Jax' +import { MatchDto, ParticipantDto, ParticipantTimelineDto } from 'App/Services/Jax/src/Endpoints/MatchEndpoint' +import { Item, ParticipantBasic, ParticipantDetails, PercentStats, Stats } from 'App/Models/Match' +import RoleIdentificationService from 'App/Services/RoleIdentiticationService' + +export interface PlayerRole { + champion: number, + jungle?: boolean, + support?: boolean, +} + +export default abstract class MatchTransformer { + protected champions: any + protected items: any + protected perks: any + protected perkstyles: any + protected summonerSpells: any + protected championRoles: any + protected sortTeamByRole: (a: ParticipantBasic, b: ParticipantBasic) => number + /** + * Get global Context with CDragon Data + */ + public async getContext () { + const items = await Jax.CDragon.items() + const champions = await Jax.CDragon.champions() + const perks = await Jax.CDragon.perks() + const perkstyles = await Jax.CDragon.perkstyles() + const summonerSpells = await Jax.CDragon.summonerSpells() + const championRoles = await RoleIdentificationService.pullData().catch(() => { }) + + this.champions = champions + this.items = items + this.perks = perks + this.perkstyles = perkstyles.styles + this.summonerSpells = summonerSpells + this.championRoles = championRoles + this.sortTeamByRole = sortTeamByRole + } + + /** + * Get champion specific data + * @param id of the champion + */ + public getChampion (id: number) { + const champion = { ...this.champions.find(c => c.id === id) } + champion.icon = ` + https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/ + ${champion.squarePortraitPath.split('/assets/')[1].toLowerCase()} + ` + delete champion.squarePortraitPath + return champion + } + + /** + * Get global data about the match + */ + public getGameInfos (match: MatchDto) { + return { + map: match.mapId, + gamemode: match.queueId, + date: match.gameCreation, + region: match.platformId.toLowerCase(), + season: getSeasonNumber(match.gameCreation), + time: match.gameDuration, + } + } + + /** + * Get player specific data during the match + * @param match + * @param player + * @param detailed : detailed or not stats + * @param teamStats : if detailed, the teamStats argument is mandatory + */ + public getPlayerData (match: MatchDto, player: ParticipantDto, detailed: boolean, teamStats: any = {}) { + const identity = match.participantIdentities.find(p => p.participantId === player.participantId) + const name = identity!.player.summonerName + const champion = this.getChampion(player.championId) + const role = this.getRoleName(player.timeline, match.queueId) + const level = player.stats.champLevel + + // Regular stats / Full match stats + const stats: Stats = { + kills: player.stats.kills, + deaths: player.stats.deaths, + assists: player.stats.assists, + minions: player.stats.totalMinionsKilled + player.stats.neutralMinionsKilled, + vision: player.stats.visionScore, + gold: player.stats.goldEarned, + dmgChamp: player.stats.totalDamageDealtToChampions, + dmgObj: player.stats.damageDealtToObjectives, + dmgTaken: player.stats.totalDamageTaken, + kp: 0, + kda: 0, + realKda: 0, + } + + if (stats.kills + stats.assists !== 0 && stats.deaths === 0) { + stats.kda = '∞' + stats.realKda = stats.kills + stats.assists + } else { + stats.kda = +(stats.deaths === 0 ? 0 : ((stats.kills + stats.assists) / stats.deaths)).toFixed(2) + stats.realKda = stats.kda + } + + // Percent stats / Per minute stats : only for detailed match + let percentStats: PercentStats + if (detailed) { + percentStats = { + minions: +(stats.minions / (match.gameDuration / 60)).toFixed(2), + vision: +(stats.vision / (match.gameDuration / 60)).toFixed(2), + gold: +(player.stats.goldEarned * 100 / teamStats.gold).toFixed(1) + '%', + dmgChamp: +(player.stats.totalDamageDealtToChampions * 100 / teamStats.dmgChamp).toFixed(1) + '%', + dmgObj: +(teamStats.dmgObj ? player.stats.damageDealtToObjectives * 100 / teamStats.dmgObj : 0).toFixed(1) + '%', + dmgTaken: +(player.stats.totalDamageTaken * 100 / teamStats.dmgTaken).toFixed(1) + '%', + } + + stats.kp = teamStats.kills === 0 ? '0%' : +((stats.kills + stats.assists) * 100 / teamStats.kills).toFixed(1) + '%' + } else { + const totalKills = match.participants.reduce((prev, current) => { + if (current.teamId !== player.teamId) { + return prev + } + return prev + current.stats.kills + }, 0) + + stats.criticalStrike = player.stats.largestCriticalStrike + stats.killingSpree = player.stats.largestKillingSpree + stats.doubleKills = player.stats.doubleKills + stats.tripleKills = player.stats.tripleKills + stats.quadraKills = player.stats.quadraKills + stats.pentaKills = player.stats.pentaKills + stats.heal = player.stats.totalHeal + stats.towers = player.stats.turretKills + stats.longestLiving = player.stats.longestTimeSpentLiving + stats.kp = totalKills === 0 ? 0 : +((stats.kills + stats.assists) * 100 / totalKills).toFixed(1) + } + + let primaryRune: string | null = null + let secondaryRune: string | null = null + if (player.stats.perkPrimaryStyle) { + ({ primaryRune, secondaryRune } = this.getPerksImages(player.stats.perk0, player.stats.perkSubStyle)) + } + + const items: (Item | null)[] = [] + for (let i = 0; i < 6; i++) { + const id = player.stats['item' + i] + if (id === 0) { + items.push(null) + continue + } + + const item = this.items.find((i: any) => i.id === id) + const itemUrl = item.iconPath.split('/assets/')[1].toLowerCase() + + items.push({ + image: `https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/${itemUrl}`, + name: item.name, + description: item.description, + price: item.priceTotal, + }) + } + + const firstSum = player.spell1Id + const secondSum = player.spell2Id + + const playerData: ParticipantDetails = { + name, + summonerId: identity!.player.summonerId, + champion, + role, + primaryRune, + secondaryRune, + level, + items, + firstSum, + secondSum, + stats, + } + if (detailed) { + playerData.percentStats = percentStats! + } + + return playerData + } + + /** + * Return the icons of the primary rune and secondary category + * @param perk0 primary perks id + * @param perkSubStyle secondary perks category + */ + public getPerksImages (perk0: number, perkSubStyle: number) { + const firstRune = this.perks.find((p: any) => p.id === perk0) + const firstRuneUrl = firstRune.iconPath.split('/assets/')[1].toLowerCase() + const primaryRune = `https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/${firstRuneUrl}` + + const secondRuneStyle = this.perkstyles.find((p: any) => p.id === perkSubStyle) + + const secondRuneStyleUrl = secondRuneStyle ? secondRuneStyle.iconPath.split('/assets/')[1].toLowerCase() : null + const secondaryRune = secondRuneStyleUrl ? + `https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/${secondRuneStyleUrl}` + : '' + + return { primaryRune, secondaryRune } + } + + /** + * Return the lane of the summoner according to timeline + * @param timeline from Riot Api + * @param gamemode of the match to check if a role is needed + */ + public getRoleName (timeline: ParticipantTimelineDto, gamemode: number) { + if (!queuesWithRole.includes(gamemode)) { + return 'NONE' + } + + if (timeline.lane === 'BOTTOM' && timeline.role.includes('SUPPORT')) { + return 'SUPPORT' + } + + return timeline.lane + } + + /** + * Return the 5 roles of a team based on champions + * @param team 5 champions + smite from a team + */ + public getTeamRoles (team: PlayerRole[]) { + const teamJunglers = team.filter(p => p.jungle && !p.support) + const jungle = teamJunglers.length === 1 ? teamJunglers[0].champion : undefined + const teamSupports = team.filter(p => p.support && !p.jungle) + const support = teamSupports.length === 1 ? teamSupports[0].champion : undefined + + return RoleIdentificationService.getRoles(this.championRoles, team.map(p => p.champion), jungle, support) + } + + /** + * Update roles for a team if Riot's ones are badly identified + * @param team 5 players data of the team + * @param champs 5 champions + smite from the team + * @param playerData data of the searched player, only for basic matches + */ + public updateTeamRoles (team: ParticipantBasic[], champs: PlayerRole[], playerData?: ParticipantDetails) { + // const actualRoles = [...new Set(team.map(p => p.role))] + // if (actualRoles.length === 5) { + // return + // } + + const identifiedChamps = this.getTeamRoles(champs) + for (const summoner of team) { + summoner.role = Object.entries(identifiedChamps).find(([, champion]) => summoner.champion.id === champion)![0] + + if (playerData && summoner.champion.id === playerData.champion.id) { + playerData.role = summoner.role + } + } + } + + /** + * + * @param match from Riot Api + * @param allyTeam 5 players of the first team + * @param enemyTeam 5 players of the second team + * @param allyTeamId team id of the searched player, only for basic matches + * @param playerData data of the searched player, only for basic matches + */ + public getMatchRoles ( + match: MatchDto, + allyTeam: ParticipantBasic[], + enemyTeam: ParticipantBasic[], + allyTeamId = 100, + playerData?: ParticipantDetails + ) { + if (!this.championRoles || !queuesWithRole.includes(match.queueId)) { + return + } + + let allyChamps: PlayerRole[] = [] + let enemyChamps: PlayerRole[] = [] + match.participants.map(p => { + const items = [p.stats.item0, p.stats.item1, p.stats.item2, p.stats.item3, p.stats.item4, p.stats.item5] + const playerRole = { + champion: p.championId, + jungle: p.spell1Id === 11 || p.spell2Id === 11, + support: supportItems.some(suppItem => items.includes(suppItem)), + } + p.teamId === allyTeamId ? allyChamps.push(playerRole) : enemyChamps.push(playerRole) + }) + + this.updateTeamRoles(allyTeam, allyChamps, playerData) + this.updateTeamRoles(enemyTeam, enemyChamps) + + allyTeam.sort(this.sortTeamByRole) + enemyTeam.sort(this.sortTeamByRole) + } + + /** + * Get Summoner Spell Data from CDragon + * @param id of the summonerSpell + */ + public getSummonerSpell (id: number) { + if (id === 0) { + return null + } + const spell = this.summonerSpells.find((s: any) => s.id === id) + const spellName = spell.iconPath.split('/assets/')[1].toLowerCase() + return { + name: spell.name, + description: spell.description, + icon: `https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/${spellName}`, + } + } +} diff --git a/server-new/app/helpers.ts b/server-new/app/helpers.ts index 8246741..9fa9548 100644 --- a/server-new/app/helpers.ts +++ b/server-new/app/helpers.ts @@ -1,3 +1,5 @@ +import { ParticipantBasic } from './Models/Match' + /** * League of Legends queues with defined role for each summoner */ @@ -40,7 +42,7 @@ export function getSeasonNumber (timestamp: number) { * @param a first role * @param b second role */ -export function sortTeamByRole (a:any, b:any) { +export function sortTeamByRole (a:ParticipantBasic, b:ParticipantBasic) { const sortingArr = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'SUPPORT'] return sortingArr.indexOf(a.role) - sortingArr.indexOf(b.role) }