diff --git a/lua/wikis/commons/MatchPage/Base.lua b/lua/wikis/commons/MatchPage/Base.lua index 2bd430f8d1d..ebee5f749d3 100644 --- a/lua/wikis/commons/MatchPage/Base.lua +++ b/lua/wikis/commons/MatchPage/Base.lua @@ -286,8 +286,8 @@ function BaseMatchPage:renderGames() local hasOverallStats = Logic.isNotEmpty(overallStats) if hasOverallStats then - tabs['name1'] = 'Overall Statistics' - tabs['content1'] = overallStats + tabs.name1 = 'Overall Statistics' + tabs.content1 = overallStats end Array.forEach(games, function(game, idx) diff --git a/lua/wikis/commons/Operator.lua b/lua/wikis/commons/Operator.lua index 308ae4037fc..cd617a128cd 100644 --- a/lua/wikis/commons/Operator.lua +++ b/lua/wikis/commons/Operator.lua @@ -19,6 +19,19 @@ function Operator.add(a, b) return a + b end +---A `nil`-safe version of `Operator.add` +---@param a number? +---@param b number? +---@return number? +function Operator.nilSafeAdd(a, b) + if not a then + return b + elseif not b then + return a + end + return a + b +end + ---Uses the __sub metamethod (a - b) ---@param a number ---@param b number diff --git a/lua/wikis/commons/Widget/Match/Page/StatsList.lua b/lua/wikis/commons/Widget/Match/Page/StatsList.lua index 123d699f523..588c5b677c7 100644 --- a/lua/wikis/commons/Widget/Match/Page/StatsList.lua +++ b/lua/wikis/commons/Widget/Match/Page/StatsList.lua @@ -36,9 +36,14 @@ function MatchPageStatsList:render() if Logic.isEmpty(self.props.data) then return end return Div{ classes = {'match-bm-team-stats-list'}, - children = Array.map(self.props.data, function (dataElement) - return self:_renderStat(dataElement) - end) + children = Array.map( + Array.filter(self.props.data, function (element) + return element.team1Value ~= nil or element.team2Value ~= nil + end), + function (dataElement) + return self:_renderStat(dataElement) + end + ) } end diff --git a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua index 884ee75bac7..d49b9b5155b 100644 --- a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua +++ b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua @@ -10,6 +10,8 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') local FnUtil = Lua.import('Module:FnUtil') local HeroNames = Lua.import('Module:ChampionNames', {loadData = true}) +local Logic = Lua.import('Module:Logic') +local Operator = Lua.import('Module:Operator') local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') @@ -17,16 +19,16 @@ local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local CustomMatchGroupInput = {} -local MatchFunctions = {} -local MapFunctions = {} - -MatchFunctions.OPPONENT_CONFIG = { - resolveRedirect = true, - pagifyTeamNames = false, - maxNumPlayers = 15, +local MatchFunctions = { + OPPONENT_CONFIG = { + resolveRedirect = true, + pagifyTeamNames = false, + maxNumPlayers = 15, + }, + DEFAULT_MODE = 'team', + getBestOf = MatchGroupInputUtil.getBestOf } -MatchFunctions.DEFAULT_MODE = 'team' -MatchFunctions.getBestOf = MatchGroupInputUtil.getBestOf +local MapFunctions = {} ---@class LeagueOfLegendsMapParserInterface ---@field getMap fun(mapInput: table): table @@ -37,6 +39,7 @@ MatchFunctions.getBestOf = MatchGroupInputUtil.getBestOf ---@field getHeroBans fun(map: table, opponentIndex: integer): string[]? ---@field getParticipants fun(map: table, opponentIndex: integer): table[]? ---@field getVetoPhase fun(map: table): table? +---@field extendMapOpponent? fun(map: table, opponentIndex: integer): table ---@param match table ---@param options? {isMatchPage: boolean?} @@ -60,11 +63,74 @@ function CustomMatchGroupInput.processMatch(match, options) MapParser = Lua.import('Module:MatchGroup/Input/Custom/Normal') end - return MatchGroupInputUtil.standardProcessMatch(match, MatchFunctions, nil, MapParser) + local processedMatch = MatchGroupInputUtil.standardProcessMatch(match, MatchFunctions, nil, MapParser) + + if options.isMatchPage then + CustomMatchGroupInput.aggregateStats(processedMatch) + end + return processedMatch +end + +---@param match {opponents: MGIParsedOpponent[], games: table[]} +function CustomMatchGroupInput.aggregateStats(match) + Array.forEach(match.opponents, function (opponent, opponentIndex) + ---@param name string + ---@return number? + local function aggregateStats(name) + return Array.reduce( + Array.map(match.games, function (game) + return (game.opponents[opponentIndex].stats or {})[name] + end), + Operator.nilSafeAdd + ) + end + opponent.extradata = Table.merge(opponent.extradata, { + kills = aggregateStats('kills'), + deaths = aggregateStats('deaths'), + assists = aggregateStats('assists'), + towers = aggregateStats('towers'), + inhibitors = aggregateStats('inhibitors'), + dragons = aggregateStats('dragons'), + atakhans = aggregateStats('atakhans'), + heralds = aggregateStats('heralds'), + barons = aggregateStats('barons') + }) + Array.forEach(opponent.match2players, function (player, playerIndex) + player.extradata = {characters = {}} + Array.forEach( + Array.filter(match.games, function (game) + return game.status ~= MatchGroupInputUtil.MATCH_STATUS.NOT_PLAYED + end), + function (game) + local gamePlayerData = game.opponents[opponentIndex].players[playerIndex] + if Logic.isEmpty(gamePlayerData) then + return + end + local parsedGameLength = Array.map( + Array.parseCommaSeparatedString(game.length --[[@as string]], ':'), function (element) + ---Directly using tonumber as arg to Array.map causes base out of range error + return tonumber(element) + end + ) + local gameLength = (parsedGameLength[1] or 0) * 60 + (parsedGameLength[2] or 0) + player.extradata.role = player.extradata.role or gamePlayerData.role + player.extradata.characters = Array.extend(player.extradata.characters, gamePlayerData.character) + player.extradata.kills = Operator.nilSafeAdd(player.extradata.kills, gamePlayerData.kills) + player.extradata.deaths = Operator.nilSafeAdd(player.extradata.deaths, gamePlayerData.deaths) + player.extradata.assists = Operator.nilSafeAdd(player.extradata.assists, gamePlayerData.assists) + player.extradata.damage = Operator.nilSafeAdd(player.extradata.damage, gamePlayerData.damagedone) + player.extradata.creepscore = Operator.nilSafeAdd(player.extradata.creepscore, gamePlayerData.creepscore) + player.extradata.gold = Operator.nilSafeAdd(player.extradata.gold, gamePlayerData.gold) + player.extradata.gameLength = Operator.nilSafeAdd(player.extradata.gameLength, gameLength) + end + ) + player.extradata.characters = Logic.nilIfEmpty(player.extradata.characters) + end) + end) end ---@param match table ----@param opponents table[] +---@param opponents MGIParsedOpponent[] ---@param MapParser LeagueOfLegendsMapParserInterface ---@return table[] function MatchFunctions.extractMaps(match, opponents, MapParser) @@ -75,6 +141,7 @@ function MatchFunctions.extractMaps(match, opponents, MapParser) getMap = MapParser.getMap, getLength = MapParser.getLength, getPlayersOfMapOpponent = FnUtil.curry(MapFunctions.getPlayersOfMapOpponent, MapParser), + extendMapOpponent = MapParser.extendMapOpponent } local maps = MatchGroupInputUtil.standardProcessMaps(match, opponents, mapParserWrapper) @@ -96,7 +163,7 @@ end ---@param match table ---@param games table[] ----@param opponents table[] +---@param opponents MGIParsedOpponent[] ---@return table function MatchFunctions.getExtraData(match, games, opponents) return { @@ -107,7 +174,7 @@ end ---@param MapParser LeagueOfLegendsMapParserInterface ---@param match table ---@param map table ----@param opponents table[] +---@param opponents MGIParsedOpponent[] ---@return table function MapFunctions.getExtraData(MapParser, match, map, opponents) local extraData = {} @@ -146,7 +213,7 @@ end ---@param MapParser LeagueOfLegendsMapParserInterface ---@param map table ----@param opponent table +---@param opponent MGIParsedOpponent ---@param opponentIndex integer ---@return table[] function MapFunctions.getPlayersOfMapOpponent(MapParser, map, opponent, opponentIndex) diff --git a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom/MatchPage.lua b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom/MatchPage.lua index 5c89fb8a29e..a1f8315c948 100644 --- a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom/MatchPage.lua +++ b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom/MatchPage.lua @@ -121,4 +121,34 @@ function CustomMatchGroupInputMatchPage.getObjectives(map, opponentIndex) } end +function CustomMatchGroupInputMatchPage.extendMapOpponent(map, opponentIndex) + local participants = CustomMatchGroupInputMatchPage.getParticipants(map, opponentIndex) + + if Logic.isEmpty(participants) then + return {picks = {}, stats = {}} + end + ---@cast participants -nil + + ---@param arr table[] + ---@param item string + ---@return number? + local function sumItem(arr, item) + return Array.reduce(Array.map(arr, Operator.property(item)), Operator.nilSafeAdd, 0) + end + + return { + side = CustomMatchGroupInputMatchPage.getSide(map, opponentIndex), + picks = Array.map(participants, Operator.property('character')), + stats = Table.merge( + { + gold = sumItem(participants, 'gold'), + kills = sumItem(participants, 'kills'), + deaths = sumItem(participants, 'deaths'), + assists = sumItem(participants, 'assists') + }, + CustomMatchGroupInputMatchPage.getObjectives(map, opponentIndex) + ) + } +end + return CustomMatchGroupInputMatchPage diff --git a/lua/wikis/leagueoflegends/MatchPage.lua b/lua/wikis/leagueoflegends/MatchPage.lua index a7581aa1d20..5273ff1a0f5 100644 --- a/lua/wikis/leagueoflegends/MatchPage.lua +++ b/lua/wikis/leagueoflegends/MatchPage.lua @@ -22,6 +22,8 @@ local Div = HtmlWidgets.Div local GeneralCollapsible = Lua.import('Module:Widget/GeneralCollapsible/Default') local IconFa = Lua.import('Module:Widget/Image/Icon/Fontawesome') local IconImage = Lua.import('Module:Widget/Image/Icon/Image') +local Link = Lua.import('Module:Widget/Basic/Link') +local MatchSummaryCharacters = Lua.import('Module:Widget/Match/Summary/Characters') local PlayerStat = Lua.import('Module:Widget/Match/Page/PlayerStat') local PlayerDisplay = Lua.import('Module:Widget/Match/Page/PlayerDisplay') local StatsList = Lua.import('Module:Widget/Match/Page/StatsList') @@ -31,9 +33,11 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@class LoLMatchPageGame: MatchPageGame ---@field vetoGroups {type: 'ban'|'pick', team: integer, character: string, vetoNumber: integer}[][][] +---@field opponents {players: table[], score: number?, status: string?, [string]: any}[] ---@class LoLMatchPage: BaseMatchPage ---@field games LoLMatchPageGame[] +---@operator call(MatchGroupUtilMatch): BaseMatchPage local MatchPage = Class.new(BaseMatchPage) local KEYSTONES = Table.map({ @@ -101,14 +105,12 @@ end function MatchPage:populateGames() Array.forEach(self.games, function(game) + local vetoPhase = game.extradata.vetophase or {} game.finished = game.winner ~= nil and game.winner ~= -1 game.teams = Array.map(game.opponents, function(opponent, teamIdx) - local team = {} + opponent.scoreDisplay = game.winner == teamIdx and 'W' or game.finished and 'L' or '-' - team.scoreDisplay = game.winner == teamIdx and 'W' or game.finished and 'L' or '-' - team.side = String.nilIfEmpty(game.extradata['team' .. teamIdx ..'side']) - - team.players = Array.map( + opponent.players = Array.map( Array.sortBy(Array.filter(opponent.players, Logic.isNotEmpty), function(player) return ROLE_ORDER[player.role] end), @@ -124,32 +126,17 @@ function MatchPage:populateGames() }) end ) - - if game.finished then - -- Aggregate stats - team.gold = MatchPage.abbreviateNumber(MatchPage.sumItem(team.players, 'gold')) - team.kills = MatchPage.sumItem(team.players, 'kills') - team.deaths = MatchPage.sumItem(team.players, 'deaths') - team.assists = MatchPage.sumItem(team.players, 'assists') - - -- Set fields - team.objectives = game.extradata['team' .. teamIdx .. 'objectives'] or {} - else - team.objectives = {} - end - - team.picks = Array.map(team.players, Operator.property('character')) - team.pickOrder = Array.filter(game.extradata.vetophase or {}, function(veto) + opponent.pickOrder = Array.filter(vetoPhase, function(veto) return veto.type == 'pick' and veto.team == teamIdx end) - team.bans = Array.filter(game.extradata.vetophase or {}, function(veto) + opponent.bans = Array.filter(vetoPhase, function(veto) return veto.type == 'ban' and veto.team == teamIdx end) - return team + return opponent end) - local _, vetoByTeam = Array.groupBy(game.extradata.vetophase or {}, Operator.property('team')) + local _, vetoByTeam = Array.groupBy(vetoPhase, Operator.property('team')) game.vetoGroups = {} Array.forEach(vetoByTeam, function(team, teamIndex) @@ -165,6 +152,216 @@ function MatchPage:populateGames() end) end +---@return Widget? +function MatchPage:renderOverallStats() + if self:isBestOfOne() then + return + end + + local function renderOverallTeamStats() + return { + HtmlWidgets.H3{children = 'Overall Team Stats'}, + Div{ + classes = {'match-bm-team-stats'}, + children = { + Div{ + classes = {'match-bm-lol-team-stats-header'}, + children = { + Div{ + classes = {'match-bm-lol-team-stats-header-team'}, + children = self.opponents[1].iconDisplay + }, + Div{ + classes = {'match-bm-team-stats-list-cell'}, + children = self:getTournamentIcon() + }, + Div{ + classes = {'match-bm-lol-team-stats-header-team'}, + children = self.opponents[2].iconDisplay + } + } + }, + MatchPage._buildTeamStatsList{ + finished = true, + data = Array.map(self.matchData.opponents, Operator.property('extradata')) + } + } + } + } + end + + ---@param stat integer + ---@param gameLength integer + ---@return string? + local function calculateStatPerMinute(stat, gameLength) + if gameLength <= 0 then + return + end + return string.format('%.2f', stat / gameLength * 60) + end + + ---@param player standardPlayer + ---@return Widget? + local function renderPlayerOverallPerformance(player) + if Logic.isEmpty(player.extradata) then + return + end + return Div{ + classes = {'match-bm-players-player match-bm-players-player--col-2'}, + children = WidgetUtil.collect( + Div{ + classes = {'match-bm-players-player-name'}, + children = { + Link{link = player.pageName, children = player.displayName}, + MatchSummaryCharacters{characters = player.extradata.characters, date = self.matchData.date}, + } + }, + Div{ + classes = {'match-bm-players-player-stats match-bm-players-player-stats--col-4'}, + children = { + PlayerStat{ + title = {KDA_ICON, 'KDA'}, + data = Array.interleave({ + player.extradata.kills, + player.extradata.deaths, + player.extradata.assists + }, SPAN_SLASH) + }, + PlayerStat{ + title = { + IconImage{ + imageLight = 'Lol stat icon cs.png', + caption = 'CS per minute', + link = '' + }, + 'CSM' + }, + data = calculateStatPerMinute(player.extradata.creepscore, player.extradata.gameLength) + }, + PlayerStat{ + title = {GOLD_ICON, 'GPM'}, + data = calculateStatPerMinute(player.extradata.gold, player.extradata.gameLength) + }, + PlayerStat{ + title = { + IconFa{ + iconName = 'damage', + additionalClasses = {'fa-flip-both'}, + hover = 'Damage per minute' + }, + 'DPM' + }, + data = calculateStatPerMinute(player.extradata.damage, player.extradata.gameLength) + } + } + } + ) + } + end + + return HtmlWidgets.Fragment{ + children = WidgetUtil.collect( + renderOverallTeamStats(), + HtmlWidgets.H3{children = 'Overall Player Performance'}, + Div{ + classes = {'match-bm-players-wrapper'}, + children = Array.map(self.opponents, function (opponent) + return Div{ + classes = {'match-bm-players-team'}, + children = WidgetUtil.collect( + Div{ + classes = {'match-bm-players-team-header'}, + children = opponent.iconDisplay + }, + Array.map( + Array.sortBy(opponent.players, function (player) + return ROLE_ORDER[player.extradata.role] or -1 + end), + renderPlayerOverallPerformance + ) + ) + } + end) + } + ) + } +end + +---@private +---@param props {finished: boolean, data: {kills: integer, deaths: integer, assists: integer, gold: number?, +---towers: integer, inhibitors: integer, grubs: integer?, heralds: integer?, atakhans: integer?, dragons: integer?, +---barons: integer?}[]} +---@return MatchPageStatsList +function MatchPage._buildTeamStatsList(props) + return StatsList{ + finished = props.finished, + data = { + { + icon = KDA_ICON, + name = 'KDA', + team1Value = Array.interleave({ + props.data[1].kills, + props.data[1].deaths, + props.data[1].assists + }, SPAN_SLASH), + team2Value = Array.interleave({ + props.data[2].kills, + props.data[2].deaths, + props.data[2].assists + }, SPAN_SLASH) + }, + { + icon = GOLD_ICON, + name = 'Gold', + team1Value = MatchPage.abbreviateNumber(props.data[1].gold), + team2Value = MatchPage.abbreviateNumber(props.data[2].gold) + }, + { + icon = IconImage{imageLight = 'Lol stat icon tower.png', link = ''}, + name = 'Towers', + team1Value = props.data[1].towers, + team2Value = props.data[2].towers + }, + { + icon = IconImage{imageLight = 'Lol stat icon inhibitor.png', link = ''}, + name = 'Inhibitors', + team1Value = props.data[1].inhibitors, + team2Value = props.data[2].inhibitors + }, + { + icon = IconImage{imageLight = 'Lol stat icon grub.png', link = ''}, + name = 'Void Grubs', + team1Value = props.data[1].grubs, + team2Value = props.data[2].grubs + }, + { + icon = IconImage{imageLight = 'Lol stat icon herald.png', link = ''}, + name = 'Rift Heralds', + team1Value = props.data[1].heralds, + team2Value = props.data[2].heralds + }, + { + icon = IconImage{imageLight = 'Lol stat icon atakhan.png', link = ''}, + name = 'Atakhan', + team1Value = props.data[1].atakhans, + team2Value = props.data[2].atakhans + }, + { + icon = IconImage{imageLight = 'Lol stat icon dragon.png', link = ''}, + name = 'Dragons', + team1Value = props.data[1].dragons, + team2Value = props.data[2].dragons + }, + { + icon = IconImage{imageLight = 'Lol stat icon baron.png', link = ''}, + name = 'Barons', + team1Value = props.data[1].barons, + team2Value = props.data[2].barons + }, + } + } +end + ---@param game LoLMatchPageGame ---@return Widget function MatchPage:renderGame(game) @@ -374,72 +571,9 @@ function MatchPage:_renderTeamStats(game) } } }, - StatsList{ + MatchPage._buildTeamStatsList{ finished = game.finished, - data = { - { - icon = KDA_ICON, - name = 'KDA', - team1Value = Array.interleave({ - game.teams[1].kills, - game.teams[1].deaths, - game.teams[1].assists - }, SPAN_SLASH), - team2Value = Array.interleave({ - game.teams[2].kills, - game.teams[2].deaths, - game.teams[2].assists - }, SPAN_SLASH) - }, - { - icon = GOLD_ICON, - name = 'Gold', - team1Value = game.teams[1].gold, - team2Value = game.teams[2].gold - }, - { - icon = IconImage{imageLight = 'Lol stat icon tower.png', link = ''}, - name = 'Towers', - team1Value = game.teams[1].objectives.towers, - team2Value = game.teams[2].objectives.towers - }, - { - icon = IconImage{imageLight = 'Lol stat icon inhibitor.png', link = ''}, - name = 'Inhibitors', - team1Value = game.teams[1].objectives.inhibitors, - team2Value = game.teams[2].objectives.inhibitors - }, - { - icon = IconImage{imageLight = 'Lol stat icon grub.png', link = ''}, - name = 'Void Grubs', - team1Value = game.teams[1].objectives.grubs, - team2Value = game.teams[2].objectives.grubs - }, - { - icon = IconImage{imageLight = 'Lol stat icon herald.png', link = ''}, - name = 'Rift Heralds', - team1Value = game.teams[1].objectives.heralds, - team2Value = game.teams[2].objectives.heralds - }, - { - icon = IconImage{imageLight = 'Lol stat icon atakhan.png', link = ''}, - name = 'Atakhan', - team1Value = game.teams[1].objectives.atakhans, - team2Value = game.teams[2].objectives.atakhans - }, - { - icon = IconImage{imageLight = 'Lol stat icon dragon.png', link = ''}, - name = 'Dragons', - team1Value = game.teams[1].objectives.dragons, - team2Value = game.teams[2].objectives.dragons - }, - { - icon = IconImage{imageLight = 'Lol stat icon baron.png', link = ''}, - name = 'Barons', - team1Value = game.teams[1].objectives.barons, - team2Value = game.teams[2].objectives.barons - }, - } + data = Array.map(game.opponents, Operator.property('stats')) } } }