diff --git a/lua/wikis/apexlegends/GetTournamentPlayerStatsCopyPaste.lua b/lua/wikis/apexlegends/GetTournamentPlayerStatsCopyPaste.lua new file mode 100644 index 00000000000..c2de11cf32e --- /dev/null +++ b/lua/wikis/apexlegends/GetTournamentPlayerStatsCopyPaste.lua @@ -0,0 +1,120 @@ +-- @Liquipedia +-- page=Module:GetTournamentPlayerStatsCopyPaste +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Arguments = Lua.import('Module:Arguments') +local Array = Lua.import('Module:Array') +local Class = Lua.import('Module:Class') +local HtmlWidgets = Lua.import('Module:Widget/Html') + +---@class TournamentPlayerStatsCopyPaste +local CopyPaste = Class.new() + +---@param args table +---@param key string +---@param default boolean +---@return boolean +local function readBool(args, key, default) + local value = args[key] + + if value == nil or value == '' then + return default + end + + value = tostring(value):lower() + + if value == '1' or value == 'true' or value == 'yes' then + return true + end + + if value == '0' or value == 'false' or value == 'no' then + return false + end + + return default +end + +---@param args table +---@return string[] +local function getStatFields(args) + local stats = {} + + if readBool(args, 'games', false) then + table.insert(stats, 'games') + end + if readBool(args, 'kills', true) then + table.insert(stats, 'kills') + end + if readBool(args, 'assists', true) then + table.insert(stats, 'assists') + end + if readBool(args, 'knocks', false) then + table.insert(stats, 'knocks') + end + if readBool(args, 'damage', false) then + table.insert(stats, 'damage') + end + if readBool(args, 'damageTaken', false) then + table.insert(stats, 'damageTaken') + end + + return stats +end + +---@param statFields string[] +---@return string +local function makePlayerRow(statFields) + local parts = {'{{Json|name='} + + for _, field in ipairs(statFields) do + table.insert(parts, field .. '=') + end + + return table.concat(parts, '|') .. '}}' +end + +---@param display string +---@return Renderable +function CopyPaste._generateCopyPaste(display) + return HtmlWidgets.Pre{ + classes = {'selectall'}, + children = mw.text.nowiki(display) + } +end + +---@param frame Frame +---@return Renderable +function CopyPaste.run(frame) + local args = Arguments.getArgs(frame) + + local id = args.id or '' + local tournament = args.tournament or '' + local playerCount = tonumber(args.players) or 20 + local statFields = getStatFields(args) + + assert(id ~= '', 'GetTournamentPlayerStatsCopyPaste: missing id') + assert(tournament ~= '', 'GetTournamentPlayerStatsCopyPaste: missing tournament') + assert(playerCount > 0, 'GetTournamentPlayerStatsCopyPaste: players must be greater than 0') + + local rows = Array.mapRange(1, playerCount, function() + return '|' .. makePlayerRow(statFields) + end) + + local output = table.concat({ + '{{TournamentPlayerStatsStore', + '|id=' .. id, + '|tournament=' .. tournament, + '|players={{Json', + table.concat(rows, '\n'), + '}}', + '}}', + }, '\n') + + return CopyPaste._generateCopyPaste(output) +end + +return CopyPaste diff --git a/lua/wikis/apexlegends/TournamentInputStats.lua b/lua/wikis/apexlegends/TournamentInputStats.lua new file mode 100644 index 00000000000..ec0df9592ed --- /dev/null +++ b/lua/wikis/apexlegends/TournamentInputStats.lua @@ -0,0 +1,538 @@ +--- +-- @Liquipedia +-- page=Module:TournamentInputStats +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + + +local Lua = require('Module:Lua') + +local Arguments = Lua.import('Module:Arguments') +local Array = Lua.import('Module:Array') +local Class = Lua.import('Module:Class') +local Info = Lua.import('Module:Info', {loadData = true}) +local Logic = Lua.import('Module:Logic') +local Lpdb = Lua.import('Module:Lpdb') +local MathUtil = Lua.import('Module:MathUtil') +local Opponent = Lua.import('Module:Opponent/Custom') +local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +local Page = Lua.import('Module:Page') +local PlayerDisplay = Lua.import('Module:Player/Display/Custom') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') +local TournamentStructure = Lua.import('Module:TournamentStructure') + +local Condition = Lua.import('Module:Condition') +local ConditionNode = Condition.Node +local Comparator = Condition.Comparator +local BooleanOperator = Condition.BooleanOperator +local ColumnName = Condition.ColumnName +local ConditionUtil = Condition.Util + +local TableWidgets = Lua.import('Module:Widget/Table2/All') +local HtmlWidgets = Lua.import('Module:Widget/Html') +local WidgetUtil = Lua.import('Module:Widget/Util') + +local Div = HtmlWidgets.Div +local Span = HtmlWidgets.Span +local I = HtmlWidgets.I +local Abbr = HtmlWidgets.Abbr + +local DEFAULT_PLAYER_COUNT = Table.getByPathOrNil(Info, {'config', 'participants', 'defaultPlayerNumber'}) or 3 + +local PLAYER_INPUT = { + MOUSE_KEYBOARD = 'Mouse & Keyboard', + CONTROLLER = 'Controller', + HYBRID = 'Hybrid', + UNKNOWN = 'Unknown', +} + +local TEAM_INPUT = { + MIXED = 'Mixed', +} + +local PLAYER_INPUT_ICON_DATA = { + [PLAYER_INPUT.MOUSE_KEYBOARD] = { + title = PLAYER_INPUT.MOUSE_KEYBOARD, + iconClasses = {'fas', 'fa-mouse'}, + }, + [PLAYER_INPUT.CONTROLLER] = { + title = PLAYER_INPUT.CONTROLLER, + iconClasses = {'fas', 'fa-gamepad-alt'}, + }, + [PLAYER_INPUT.HYBRID] = { + title = PLAYER_INPUT.HYBRID, + iconClasses = {'fas', 'fa-keyboard'}, + }, + [PLAYER_INPUT.UNKNOWN] = { + title = PLAYER_INPUT.UNKNOWN, + iconClasses = {'fas', 'fa-question-circle'}, + }, +} + +local INPUT_SUMMARY_BADGE_CLASSES = { + [PLAYER_INPUT.CONTROLLER] = 'forest-green-bg', + [PLAYER_INPUT.MOUSE_KEYBOARD] = 'sapphire-bg', + [PLAYER_INPUT.HYBRID] = 'vivid-violet-bg', + [TEAM_INPUT.MIXED] = 'bright-sun-bg', + [PLAYER_INPUT.UNKNOWN] = 'gray-bg', +} + +---@class TournamentInputStats: BaseClass +---@operator call(table): TournamentInputStats +local TournamentInputStats = Class.new(function(self, args) + self.args = args + self.tournamentPageNames = self:_readTournamentPageNames(args) + + self.teamRows = {} + self.totalPlayerCount = 0 + + self.lpdbInputsByPage = {} + self.manualFallbackInputsByPage = {} + + self.playerCounts = { + [PLAYER_INPUT.MOUSE_KEYBOARD] = 0, + [PLAYER_INPUT.CONTROLLER] = 0, + [PLAYER_INPUT.HYBRID] = 0, + [PLAYER_INPUT.UNKNOWN] = 0, + } + + self:_readManualFallbackInputs() +end) + +---@param frame Frame|table +---@return Widget +function TournamentInputStats.run(frame) + return TournamentInputStats(Arguments.getArgs(frame)):fetch():build() +end + +---@private +---@param args table +---@return string[] +function TournamentInputStats:_readTournamentPageNames(args) + local spec = TournamentStructure.readMatchGroupsSpec(args) or TournamentStructure.currentPageSpec() + + local tournamentPageNames = Array.unique(Array.filter( + Array.map(Array.flatten(spec.pageNames), Page.pageifyLink), + String.isNotEmpty + )) + + if Logic.isEmpty(tournamentPageNames) then + return {Page.pageifyLink(mw.title.getCurrentTitle().prefixedText)} + end + + return tournamentPageNames +end + +---@private +function TournamentInputStats:_readManualFallbackInputs() + Table.iter.forEachPair(self.args, function(key, value) + if type(key) ~= 'string' or not String.startsWith(key, 'input_') or String.isEmpty(value) then + return + end + + local pageName = Page.pageifyLink(key:sub(7)) + if pageName then + self.manualFallbackInputsByPage[pageName] = self:_toPlayerInput(value) + end + end) +end + +---@private +---@return string +function TournamentInputStats:_buildPlacementConditions() + return Condition.Tree(BooleanOperator.all):add{ + ConditionNode(ColumnName('mode'), Comparator.neq, 'award_individual'), + ConditionUtil.anyOf(ColumnName('pagename'), self.tournamentPageNames), + }:toString() +end + +---@private +---@param input string? +---@return string +function TournamentInputStats:_toPlayerInput(input) + if input == PLAYER_INPUT.MOUSE_KEYBOARD + or input == PLAYER_INPUT.CONTROLLER + or input == PLAYER_INPUT.HYBRID + then + return input + end + + return PLAYER_INPUT.UNKNOWN +end + +---@private +---@param pageName string? +---@param input string? +function TournamentInputStats:_storeLpdbInput(pageName, input) + pageName = Page.pageifyLink(pageName) + if not pageName then + return + end + + if Logic.isEmpty(self.lpdbInputsByPage[pageName]) then + self.lpdbInputsByPage[pageName] = input or '' + end +end + +---@private +---@param players standardPlayer[] +function TournamentInputStats:_fetchLpdbInputs(players) + local pageNames = Array.unique(Array.filter( + Array.map(players, function(player) + return Page.pageifyLink(player.pageName) + end), + String.isNotEmpty + )) + + if Logic.isEmpty(pageNames) then + return + end + + Lpdb.executeMassQuery('player', { + conditions = tostring(ConditionUtil.anyOf(ColumnName('pagename'), pageNames)), + query = 'pagename, extradata', + limit = 5000, + }, function(playerRecord) + self:_storeLpdbInput( + playerRecord.pagename, + Table.getByPathOrNil(playerRecord, {'extradata', 'input'}) + ) + end) +end + +---@private +---@param player standardPlayer +---@return string? +function TournamentInputStats:_getPlayerInput(player) + local pageName = Page.pageifyLink(player.pageName) + if not pageName then + return nil + end + + local lpdbInput = Logic.nilIfEmpty(self.lpdbInputsByPage[pageName]) + if lpdbInput then + return lpdbInput + end + + -- Manual fallback inputs are intentionally only used for redlinks. + if not Page.exists(pageName) then + return self.manualFallbackInputsByPage[pageName] + end + + return nil +end + +---@return self +function TournamentInputStats:fetch() + local opponents = {} + local allPlayers = {} + + Lpdb.executeMassQuery('placement', { + limit = 5000, + conditions = self:_buildPlacementConditions(), + query = 'opponentname, opponenttemplate, opponenttype, opponentplayers', + }, function(placement) + local opponent = Opponent.fromLpdbStruct(placement) + if Logic.isNotEmpty(opponent.players) then + table.insert(opponents, opponent) + Array.forEach(opponent.players, function(player) + if Logic.isNotEmpty(player.displayName) then + table.insert(allPlayers, player) + end + end) + end + end) + + self:_fetchLpdbInputs(allPlayers) + + Array.forEach(opponents, function(opponent) + local playerEntries = {} + local players = opponent.players or {} + + for index = 1, math.min(#players, DEFAULT_PLAYER_COUNT) do + local player = players[index] + if Logic.isNotEmpty(player.displayName) then + local input = self:_toPlayerInput(self:_getPlayerInput(player)) + + self.playerCounts[input] = (self.playerCounts[input] or 0) + 1 + self.totalPlayerCount = self.totalPlayerCount + 1 + + table.insert(playerEntries, { + player = player, + input = input, + }) + end + end + + if Logic.isNotEmpty(playerEntries) then + table.insert(self.teamRows, { + opponentName = opponent.name or Opponent.toName(opponent), + opponentTemplate = opponent.template, + playerEntries = playerEntries, + teamInput = self:_summarizeTeamInputs(playerEntries), + }) + end + end) + + Array.sortInPlaceBy(self.teamRows, function(row) + return mw.ustring.lower(row.opponentName or '') + end) + + return self +end + +---@private +---@param count integer +---@return string +function TournamentInputStats:_formatCountWithPercentage(count) + if self.totalPlayerCount <= 0 then + return tostring(count) + end + + return count .. ' (' .. MathUtil.formatPercentage(count / self.totalPlayerCount, 1) .. ')' +end + +---@private +---@param playerEntries table[] +---@return string +function TournamentInputStats:_summarizeTeamInputs(playerEntries) + local hasMouseKeyboard = Array.any(playerEntries, function(entry) + return entry.input == PLAYER_INPUT.MOUSE_KEYBOARD + end) + local hasController = Array.any(playerEntries, function(entry) + return entry.input == PLAYER_INPUT.CONTROLLER + end) + local hasHybrid = Array.any(playerEntries, function(entry) + return entry.input == PLAYER_INPUT.HYBRID + end) + + local knownTypeCount = 0 + local lastKnownType = PLAYER_INPUT.UNKNOWN + + if hasMouseKeyboard then + knownTypeCount = knownTypeCount + 1 + lastKnownType = PLAYER_INPUT.MOUSE_KEYBOARD + end + if hasController then + knownTypeCount = knownTypeCount + 1 + lastKnownType = PLAYER_INPUT.CONTROLLER + end + if hasHybrid then + knownTypeCount = knownTypeCount + 1 + lastKnownType = PLAYER_INPUT.HYBRID + end + + if knownTypeCount == 0 then + return PLAYER_INPUT.UNKNOWN + elseif knownTypeCount == 1 then + return lastKnownType + else + return TEAM_INPUT.MIXED + end +end + +---@private +---@param summaryValue string +---@return string +function TournamentInputStats:_getSummaryBadgeClass(summaryValue) + return INPUT_SUMMARY_BADGE_CLASSES[summaryValue] or INPUT_SUMMARY_BADGE_CLASSES[PLAYER_INPUT.UNKNOWN] +end + +---@private +---@param input string +---@return Widget +function TournamentInputStats:_buildInputIcon(input) + local data = PLAYER_INPUT_ICON_DATA[input] or PLAYER_INPUT_ICON_DATA[PLAYER_INPUT.UNKNOWN] + + return Abbr{ + attributes = {title = data.title}, + children = I{ + classes = data.iconClasses, + attributes = {['aria-hidden'] = 'true'}, + } + } +end + +---@private +---@param row table +---@return Widget|string +function TournamentInputStats:_buildTeamDisplay(row) + if String.isEmpty(row.opponentTemplate) then + return row.opponentName or '-' + end + + return OpponentDisplay.InlineTeamContainer{ + template = row.opponentTemplate, + style = 'short', + } +end + +---@private +---@param row table +---@return table +function TournamentInputStats:_buildPlayersDisplay(row) + return Array.interleave(Array.map(row.playerEntries, function(entry) + return PlayerDisplay.InlinePlayer{ + player = entry.player, + showFlag = false, + } + end), ', ') +end + +---@private +---@param row table +---@return table +function TournamentInputStats:_buildInputsDisplay(row) + return Array.interleave(Array.map(row.playerEntries, function(entry) + return self:_buildInputIcon(entry.input) + end), ' ') +end + +---@private +---@param label string +---@param value integer +---@return Widget +function TournamentInputStats:_buildSummaryBox(label, value) + return Div{ + classes = {'stats-summary-card'}, + children = { + Div{ + classes = {'stats-summary-card__subtitle'}, + children = label, + }, + Div{ + classes = {'stats-summary-card__title'}, + children = self:_formatCountWithPercentage(value), + }, + } + } +end + +---@private +---@return Widget +function TournamentInputStats:_buildSummaryBoxes() + return Div{ + classes = {'stats-summary-cards'}, + css = { + ['margin-bottom'] = '16px', + }, + children = WidgetUtil.collect( + self:_buildSummaryBox('Mouse & Keyboard Players', self.playerCounts[PLAYER_INPUT.MOUSE_KEYBOARD]), + self:_buildSummaryBox('Controller Players', self.playerCounts[PLAYER_INPUT.CONTROLLER]), + self.playerCounts[PLAYER_INPUT.HYBRID] > 0 + and self:_buildSummaryBox('Hybrid Players', self.playerCounts[PLAYER_INPUT.HYBRID]) + or nil, + self.playerCounts[PLAYER_INPUT.UNKNOWN] > 0 + and self:_buildSummaryBox('Unknown', self.playerCounts[PLAYER_INPUT.UNKNOWN]) + or nil + ) + } +end + +---@private +---@param summaryValue string +---@return Widget +function TournamentInputStats:_buildSummaryBadge(summaryValue) + return Span{ + classes = {self:_getSummaryBadgeClass(summaryValue)}, + css = { + display = 'inline-block', + padding = '4px 14px', + ['border-radius'] = '999px', + ['font-weight'] = '600', + ['white-space'] = 'nowrap', + }, + children = summaryValue, + } +end + +---@private +---@return Widget? +function TournamentInputStats:_buildEmptyStateRow() + if Logic.isNotEmpty(self.teamRows) then + return nil + end + + return TableWidgets.Row{ + children = TableWidgets.Cell{ + colspan = 4, + css = { + ['font-style'] = 'italic', + opacity = '0.7', + ['text-align'] = 'center', + }, + children = 'No player input data found.', + } + } +end + +---@return Widget +function TournamentInputStats:build() + local rows = Array.map(self.teamRows, function(row) + return TableWidgets.Row{ + children = { + TableWidgets.Cell{ + attributes = {['data-sort-value'] = row.opponentName or ''}, + children = self:_buildTeamDisplay(row), + }, + TableWidgets.Cell{ + nowrap = false, + children = self:_buildPlayersDisplay(row), + }, + TableWidgets.Cell{ + align = 'center', + children = self:_buildInputsDisplay(row), + }, + TableWidgets.Cell{ + align = 'center', + children = self:_buildSummaryBadge(row.teamInput), + }, + } + } + end) + + local tableWidget = TableWidgets.Table{ + sortable = true, + columns = { + {align = 'left'}, + {align = 'left'}, + {align = 'center'}, + {align = 'center'}, + }, + children = { + TableWidgets.TableHeader{ + children = { + TableWidgets.Row{ + children = { + TableWidgets.CellHeader{children = 'Team'}, + TableWidgets.CellHeader{children = 'Players'}, + TableWidgets.CellHeader{children = 'Inputs'}, + TableWidgets.CellHeader{children = 'Team input'}, + } + } + } + }, + TableWidgets.TableBody{ + children = WidgetUtil.collect( + self:_buildEmptyStateRow(), + rows + ) + }, + } + } + + return Div{ + css = { + display = 'inline-block', + ['max-width'] = '100%', + }, + children = { + self:_buildSummaryBoxes(), + tableWidget, + } + } +end + +return TournamentInputStats diff --git a/lua/wikis/apexlegends/TournamentPlayerStats/Calculator.lua b/lua/wikis/apexlegends/TournamentPlayerStats/Calculator.lua new file mode 100644 index 00000000000..17a5027566d --- /dev/null +++ b/lua/wikis/apexlegends/TournamentPlayerStats/Calculator.lua @@ -0,0 +1,312 @@ +--- +-- @Liquipedia +-- page=Module:TournamentPlayerStats/Calculator +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Arguments = Lua.import('Module:Arguments') +local Array = Lua.import('Module:Array') +local FnUtil = Lua.import('Module:FnUtil') +local Json = Lua.import('Module:Json') +local Logic = Lua.import('Module:Logic') +local Lpdb = Lua.import('Module:Lpdb') +local Operator = Lua.import('Module:Operator') +local Opponent = Lua.import('Module:Opponent') +local Page = Lua.import('Module:Page') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') + +---@class TournamentPlayerStats.RawRow +---@field name string +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? + +---@class TournamentPlayerStats.PlacementEntry +---@field displayName string +---@field pageName string +---@field flag string? +---@field team string? + +---@class TournamentPlayerStats.Player: standardPlayer +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? +---@field damageDiff number? + +---@class TournamentPlayerStats.PlacementIndex +---@field byPage table + +---@class TournamentPlayerStats.Data +---@field ids string[] +---@field players TournamentPlayerStats.Player[] + +local TournamentPlayerStatsCalculator = {} + +local DATAPOINT_TYPE = 'TournamentPlayerStats' + +---@type string[] +local STAT_FIELDS = { + 'games', + 'kills', + 'assists', + 'knocks', + 'damage', + 'damageTaken', +} + +---@param name string +---@return string? +local getPageName = FnUtil.memoize(Page.pageifyLink) + +---@param args table +---@return string +local function readTournamentPage(args) + local tournament = assert(String.nilIfEmpty(args.tournament), 'TournamentPlayerStats: missing tournament') + return Page.pageifyLink(tournament) or tournament +end + +---@param args table +---@return string[] +local function readIds(args) + return Array.extractValues(Table.filterByKey(args, function(key) + return key:match('^id%d*$') ~= nil + end)) +end + +---@param row table +---@return TournamentPlayerStats.RawRow? +local function rowFromInput(row) + if type(row) ~= 'table' then + return nil + end + + local name = String.nilIfEmpty(row.name) + if not name then + return nil + end + + return { + name = name, + games = tonumber(row.games), + kills = tonumber(row.kills), + assists = tonumber(row.assists), + knocks = tonumber(row.knocks), + damage = tonumber(row.damage), + damageTaken = tonumber(row.damageTaken), + } +end + +---@param rawData string|table? +---@return TournamentPlayerStats.RawRow[] +local function readPlayers(rawData) + local list = Json.parseStringified(rawData) + if type(list) ~= 'table' then + return {} + end + + return Array.map(list, rowFromInput) +end + +---@param frame Frame|table +function TournamentPlayerStatsCalculator.store(frame) + local args = Arguments.getArgs(frame) + + if Lpdb.isStorageDisabled() then + return '' + end + + local id = assert(String.nilIfEmpty(args.id), 'TournamentPlayerStats: missing id') + local tournamentPage = readTournamentPage(args) + local players = readPlayers(Logic.emptyOr(args.players, args.data, args[1])) + + local objectname = 'tournament_player_stats_' .. id + local data = { + type = DATAPOINT_TYPE, + name = id, + information = tournamentPage, + extradata = { + players = players, + }, + } + + mw.ext.LiquipediaDB.lpdb_datapoint(objectname, Json.stringifySubTables(data)) + + return '' +end + +---@param id string +---@return table? +local function fetchById(id) + return mw.ext.LiquipediaDB.lpdb('datapoint', { + conditions = '[[type::' .. DATAPOINT_TYPE .. ']] AND [[name::' .. id .. ']]', + query = 'extradata, information', + limit = 1, + })[1] +end + +---@param tournamentPage string +---@return TournamentPlayerStats.PlacementIndex +local function buildPlacementIndex(tournamentPage) + local index = { + byPage = {}, + } + + local title = mw.title.new(tournamentPage) + if not title then + return index + end + + local conditions = '[[pagename::' .. title.text:gsub(' ', '_') .. ']]' + if title.namespace ~= 0 then + conditions = conditions .. ' AND [[namespace::' .. title.namespace .. ']]' + end + + local rows = mw.ext.LiquipediaDB.lpdb('placement', { + conditions = conditions, + query = 'opponenttype, opponenttemplate, opponentplayers', + limit = 5000, + }) + + Array.forEach(rows, function(row) + if Logic.isEmpty(row.opponentplayers) then + return + end + + row.opponenttype = row.opponenttype or 'team' + local opponent = Opponent.fromLpdbStruct(row) + + Array.forEach(opponent.players or {}, function(player) + if Logic.isNotEmpty(player.pageName) and not index.byPage[player.pageName] then + index.byPage[player.pageName] = { + displayName = player.displayName, + pageName = player.pageName, + flag = player.flag, + team = opponent.template, + } + end + end) + end) + + return index +end + +TournamentPlayerStatsCalculator.getPlacementIndex = FnUtil.memoize(buildPlacementIndex) + +---@param raw table +---@param tournamentPage string +---@return TournamentPlayerStats.Player? +local function playerFromRow(raw, tournamentPage) + local placementIndex = TournamentPlayerStatsCalculator.getPlacementIndex(tournamentPage) + local pageName = getPageName(raw.name) + local placementEntry = pageName and placementIndex.byPage[pageName] or nil + + pageName = (placementEntry and placementEntry.pageName) or pageName + if not pageName then + return nil + end + + return { + displayName = (placementEntry and placementEntry.displayName) or raw.name, + pageName = pageName, + flag = placementEntry and placementEntry.flag or nil, + team = placementEntry and placementEntry.team or nil, + games = raw.games, + kills = raw.kills, + assists = raw.assists, + knocks = raw.knocks, + damage = raw.damage, + damageTaken = raw.damageTaken, + } +end + +---@param target TournamentPlayerStats.Player +---@param source TournamentPlayerStats.Player +local function mergePlayers(target, source) + Array.forEach(STAT_FIELDS, function(field) + target[field] = Operator.nilSafeAdd(target[field], source[field]) + end) + + target.pageName = target.pageName or source.pageName + target.flag = target.flag or source.flag + target.team = target.team or source.team +end + +---@param args table +---@return TournamentPlayerStats.Data +function TournamentPlayerStatsCalculator.getData(args) + local ids = readIds(args) + + ---@type table + local playersByKey = {} + + Array.forEach(ids, function(id) + local row = fetchById(id) + if not row then + return + end + + local tournamentPage = row.information + local storedPlayers = (row.extradata or {}).players or {} + + Array.forEach(storedPlayers, function(rawPlayer) + if type(rawPlayer) ~= 'table' then + return + end + + local player = playerFromRow(rawPlayer, tournamentPage) + if not player then + return + end + + local key = player.pageName + + if not playersByKey[key] then + playersByKey[key] = player + else + mergePlayers(playersByKey[key], player) + end + end) + end) + + local players = Array.extractValues(playersByKey) + + Array.forEach(players, function(player) + if player.damage and player.damageTaken then + player.damageDiff = player.damage - player.damageTaken + end + end) + + Array.sortInPlaceBy(players, function(player) return player end, function(a, b) + local teamA = String.nilIfEmpty(a.team) + local teamB = String.nilIfEmpty(b.team) + + if (teamA == nil) ~= (teamB == nil) then + return teamA ~= nil + end + + if teamA and teamB and teamA:lower() ~= teamB:lower() then + return teamA:lower() < teamB:lower() + end + + local killsA = a.kills or 0 + local killsB = b.kills or 0 + return killsA > killsB + end) + + return { + ids = ids, + players = players, + } +end + +return TournamentPlayerStatsCalculator diff --git a/lua/wikis/apexlegends/TournamentPlayerStats/Display.lua b/lua/wikis/apexlegends/TournamentPlayerStats/Display.lua new file mode 100644 index 00000000000..f038d220991 --- /dev/null +++ b/lua/wikis/apexlegends/TournamentPlayerStats/Display.lua @@ -0,0 +1,312 @@ +--- +-- @Liquipedia +-- page=Module:TournamentPlayerStats/Calculator +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Arguments = Lua.import('Module:Arguments') +local Array = Lua.import('Module:Array') +local FnUtil = Lua.import('Module:FnUtil') +local Json = Lua.import('Module:Json') +local Logic = Lua.import('Module:Logic') +local Lpdb = Lua.import('Module:Lpdb') +local Operator = Lua.import('Module:Operator') +local Opponent = Lua.import('Module:Opponent') +local Page = Lua.import('Module:Page') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') + +---@class TournamentPlayerStats.RawRow +---@field name string +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? + +---@class TournamentPlayerStats.PlacementEntry +---@field displayName string +---@field pageName string +---@field flag string? +---@field team string? + +---@class TournamentPlayerStats.Player: standardPlayer +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? +---@field damageDiff number? + +---@class TournamentPlayerStats.PlacementIndex +---@field byPage table + +---@class TournamentPlayerStats.Data +---@field ids string[] +---@field players TournamentPlayerStats.Player[] + +local TournamentPlayerStatsCalculator = {} + +local DATAPOINT_TYPE = 'TournamentPlayerStats' + +---@type string[] +local STAT_FIELDS = { + 'games', + 'kills', + 'assists', + 'knocks', + 'damage', + 'damageTaken', +} + +---@param name string +---@return string? +local getPageName = FnUtil.memoize(Page.pageifyLink) + +---@param args table +---@return string +local function readTournamentPage(args) + local tournament = assert(String.nilIfEmpty(args.tournament), 'TournamentPlayerStats: missing tournament') + return Page.pageifyLink(tournament) or tournament +end + +---@param args table +---@return string[] +local function readIds(args) + return Array.extractValues(Table.filterByKey(args, function(key) + return type(key) == 'string' and key:match('^id%d*$') ~= nil + end)) +end + +---@param row table +---@return TournamentPlayerStats.RawRow? +local function rowFromInput(row) + if type(row) ~= 'table' then + return nil + end + + local name = String.nilIfEmpty(row.name) + if not name then + return nil + end + + return { + name = name, + games = tonumber(row.games), + kills = tonumber(row.kills), + assists = tonumber(row.assists), + knocks = tonumber(row.knocks), + damage = tonumber(row.damage), + damageTaken = tonumber(row.damageTaken), + } +end + +---@param rawData string|table? +---@return TournamentPlayerStats.RawRow[] +local function readPlayers(rawData) + local list = Json.parseStringified(rawData) + if type(list) ~= 'table' then + return {} + end + + return Array.map(list, rowFromInput) +end + +---@param frame Frame|table +function TournamentPlayerStatsCalculator.store(frame) + local args = Arguments.getArgs(frame) + + if Lpdb.isStorageDisabled() then + return '' + end + + local id = assert(String.nilIfEmpty(args.id), 'TournamentPlayerStats: missing id') + local tournamentPage = readTournamentPage(args) + local players = readPlayers(Logic.emptyOr(args.players, args.data, args[1])) + + local objectname = 'tournament_player_stats_' .. id + local data = { + type = DATAPOINT_TYPE, + name = id, + information = tournamentPage, + extradata = { + players = players, + }, + } + + mw.ext.LiquipediaDB.lpdb_datapoint(objectname, Json.stringifySubTables(data)) + + return '' +end + +---@param id string +---@return table? +local function fetchById(id) + return mw.ext.LiquipediaDB.lpdb('datapoint', { + conditions = '[[type::' .. DATAPOINT_TYPE .. ']] AND [[name::' .. id .. ']]', + query = 'extradata, information', + limit = 1, + })[1] +end + +---@param tournamentPage string +---@return TournamentPlayerStats.PlacementIndex +local function buildPlacementIndex(tournamentPage) + local index = { + byPage = {}, + } + + local title = mw.title.new(tournamentPage) + if not title then + return index + end + + local conditions = '[[pagename::' .. title.text:gsub(' ', '_') .. ']]' + if title.namespace ~= 0 then + conditions = conditions .. ' AND [[namespace::' .. title.namespace .. ']]' + end + + local rows = mw.ext.LiquipediaDB.lpdb('placement', { + conditions = conditions, + query = 'opponenttype, opponenttemplate, opponentplayers', + limit = 5000, + }) + + Array.forEach(rows, function(row) + if Logic.isEmpty(row.opponentplayers) then + return + end + + row.opponenttype = row.opponenttype or 'team' + local opponent = Opponent.fromLpdbStruct(row) + + Array.forEach(opponent.players or {}, function(player) + if Logic.isNotEmpty(player.pageName) and not index.byPage[player.pageName] then + index.byPage[player.pageName] = { + displayName = player.displayName, + pageName = player.pageName, + flag = player.flag, + team = opponent.template, + } + end + end) + end) + + return index +end + +TournamentPlayerStatsCalculator.getPlacementIndex = FnUtil.memoize(buildPlacementIndex) + +---@param raw table +---@param tournamentPage string +---@return TournamentPlayerStats.Player? +local function playerFromRow(raw, tournamentPage) + local placementIndex = TournamentPlayerStatsCalculator.getPlacementIndex(tournamentPage) + local pageName = getPageName(raw.name) + local placementEntry = pageName and placementIndex.byPage[pageName] or nil + + pageName = (placementEntry and placementEntry.pageName) or pageName + if not pageName then + return nil + end + + return { + displayName = (placementEntry and placementEntry.displayName) or raw.name, + pageName = pageName, + flag = placementEntry and placementEntry.flag or nil, + team = placementEntry and placementEntry.team or nil, + games = raw.games, + kills = raw.kills, + assists = raw.assists, + knocks = raw.knocks, + damage = raw.damage, + damageTaken = raw.damageTaken, + } +end + +---@param target TournamentPlayerStats.Player +---@param source TournamentPlayerStats.Player +local function mergePlayers(target, source) + Array.forEach(STAT_FIELDS, function(field) + target[field] = Operator.nilSafeAdd(target[field], source[field]) + end) + + target.pageName = target.pageName or source.pageName + target.flag = target.flag or source.flag + target.team = target.team or source.team +end + +---@param args table +---@return TournamentPlayerStats.Data +function TournamentPlayerStatsCalculator.getData(args) + local ids = readIds(args) + + ---@type table + local playersByKey = {} + + Array.forEach(ids, function(id) + local row = fetchById(id) + if not row then + return + end + + local tournamentPage = row.information + local storedPlayers = (row.extradata or {}).players or {} + + Array.forEach(storedPlayers, function(rawPlayer) + if type(rawPlayer) ~= 'table' then + return + end + + local player = playerFromRow(rawPlayer, tournamentPage) + if not player then + return + end + + local key = player.pageName + + if not playersByKey[key] then + playersByKey[key] = player + else + mergePlayers(playersByKey[key], player) + end + end) + end) + + local players = Array.extractValues(playersByKey) + + Array.forEach(players, function(player) + if player.damage and player.damageTaken then + player.damageDiff = player.damage - player.damageTaken + end + end) + + Array.sortInPlaceBy(players, function(player) return player end, function(a, b) + local teamA = String.nilIfEmpty(a.team) + local teamB = String.nilIfEmpty(b.team) + + if (teamA == nil) ~= (teamB == nil) then + return teamA ~= nil + end + + if teamA and teamB and teamA:lower() ~= teamB:lower() then + return teamA:lower() < teamB:lower() + end + + local killsA = a.kills or 0 + local killsB = b.kills or 0 + return killsA > killsB + end) + + return { + ids = ids, + players = players, + } +end + +return TournamentPlayerStatsCalculator diff --git a/lua/wikis/apexlegends/Widget/TournamentPlayerStats/Table.lua b/lua/wikis/apexlegends/Widget/TournamentPlayerStats/Table.lua new file mode 100644 index 00000000000..43e75e4dd98 --- /dev/null +++ b/lua/wikis/apexlegends/Widget/TournamentPlayerStats/Table.lua @@ -0,0 +1,257 @@ +--- +-- @Liquipedia +-- page=Module:Widget/TournamentPlayerStats/Table +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local Class = Lua.import('Module:Class') +local Logic = Lua.import('Module:Logic') +local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +local PlayerDisplay = Lua.import('Module:Player/Display/Custom') + +local Widget = Lua.import('Module:Widget') +local Html = Lua.import('Module:Widget/Html') +local TableWidgets = Lua.import('Module:Widget/Table2/All') +local WidgetUtil = Lua.import('Module:Widget/Util') + +---@class TournamentPlayerStats.RawRow +---@field name string +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? + +---@class TournamentPlayerStats.Player: standardPlayer +---@field games number? +---@field kills number? +---@field assists number? +---@field knocks number? +---@field damage number? +---@field damageTaken number? +---@field damageDiff number? + +---@class TournamentPlayerStatsTableProps +---@field players TournamentPlayerStats.Player[] + +---@class TournamentPlayerStatsTable: Widget +---@operator call(TournamentPlayerStatsTableProps): TournamentPlayerStatsTable +local TournamentPlayerStatsTable = Class.new(Widget) + +TournamentPlayerStatsTable.defaultProps = { + players = {}, +} + +local CONTENT_LANGUAGE = mw.language.getContentLanguage() + +---@class TournamentPlayerStatsTableColumn +---@field key string +---@field label string + +---@type TournamentPlayerStatsTableColumn[] +local COLUMNS = { + {key = 'games', label = 'GP'}, + {key = 'kills', label = 'K'}, + {key = 'assists', label = 'A'}, + {key = 'knocks', label = 'Knocks'}, + {key = 'damage', label = 'Dmg dealt'}, + {key = 'damageTaken', label = 'Dmg taken'}, + {key = 'damageDiff', label = 'Dmg diff'}, +} + +---@param value number +---@return string +local function formatNumber(value) + return CONTENT_LANGUAGE:formatNum(value) +end + +---@param key string +---@param value number? +---@return string +local function formatStat(key, value) + if value == nil then + return '-' + end + + if key == 'damageDiff' then + if value > 0 then + return '+' .. formatNumber(value) + elseif value == 0 then + return '0' + end + end + + return formatNumber(value) +end + +---@param player TournamentPlayerStats.Player +---@return Renderable +local function renderTeam(player) + if Logic.isNotEmpty(player.team) then + return OpponentDisplay.InlineTeamContainer{ + template = player.team, + style = 'icon', + } + end + + return '-' +end + +---@param player TournamentPlayerStats.Player +---@return Renderable +local function renderPlayer(player) + return PlayerDisplay.InlinePlayer{ + player = player, + showFlag = true, + showLink = true, + } +end + +---@param key string +---@param player TournamentPlayerStats.Player +---@return Renderable +local function renderStat(key, player) + local value = player[key] + local text = formatStat(key, value) + + if key ~= 'damageDiff' or value == nil or value == 0 then + return text + end + + return Html.Span{ + classes = {value > 0 and 'forest-green-text' or 'cinnabar-text'}, + children = text, + } +end + +---@param title string +---@param player TournamentPlayerStats.Player? +---@param stat string +---@return Renderable? +local function summaryCard(title, player, stat) + if not player or player[stat] == nil or player[stat] <= 0 then + return nil + end + + return Html.Div{ + classes = {'stats-summary-card'}, + children = { + Html.Div{ + classes = {'stats-summary-card__subtitle'}, + children = title, + }, + Html.Div{ + classes = {'stats-summary-card__title'}, + children = player.displayName .. ' (' .. formatNumber(player[stat]) .. ')', + }, + }, + } +end + +---@param players TournamentPlayerStats.Player[] +---@return Renderable? +local function summaryCards(players) + if Logic.isEmpty(players) then + return nil + end + + local topKills = Array.maxBy(players, function(player) + return {player.kills or 0, player.assists or 0, player.damage or 0} + end) + local topAssists = Array.maxBy(players, function(player) + return {player.assists or 0, player.kills or 0, player.damage or 0} + end) + local topKnocks = Array.maxBy(players, function(player) + return {player.knocks or 0, player.kills or 0, player.damage or 0} + end) + local topDamage = Array.maxBy(players, function(player) + return {player.damage or 0, player.kills or 0, player.assists or 0} + end) + + return Html.Div{ + classes = {'stats-summary-cards'}, + css = {['margin-bottom'] = '16px'}, + children = WidgetUtil.collect( + summaryCard('Top Killer', topKills, 'kills'), + summaryCard('Top Assists', topAssists, 'assists'), + summaryCard('Top Damage', topDamage, 'damage'), + summaryCard('Top Knocks', topKnocks, 'knocks') + ), + } +end + +---@param activeColumns TournamentPlayerStatsTableColumn[] +---@return fun(player: TournamentPlayerStats.Player): Renderable +local function buildRow(activeColumns) + return function(player) + return TableWidgets.Row{children = WidgetUtil.collect( + TableWidgets.Cell{children = renderTeam(player)}, + TableWidgets.Cell{ + attributes = {['data-sort-value'] = player.pageName or player.displayName}, + children = renderPlayer(player), + }, + Array.map(activeColumns, function(column) + return TableWidgets.Cell{ + attributes = {['data-sort-value'] = player[column.key] or -1}, + children = renderStat(column.key, player), + } + end) + )} + end +end + +---@return Renderable? +function TournamentPlayerStatsTable:render() + local players = self.props.players + if Logic.isEmpty(players) then + return nil + end + + local activeColumns = Array.filter(COLUMNS, function(column) + return Array.any(players, function(player) + return player[column.key] ~= nil + end) + end) + + return Html.Div{ + css = { + display = 'inline-block', + ['max-width'] = '100%', + }, + children = { + summaryCards(players), + TableWidgets.Table{ + sortable = true, + columns = WidgetUtil.collect( + {align = 'center'}, + {align = 'left'}, + Array.map(activeColumns, function() + return { + align = 'right', + sortType = 'number', + } + end) + ), + children = { + TableWidgets.TableHeader{children = { + TableWidgets.Row{children = WidgetUtil.collect( + TableWidgets.CellHeader{children = 'Team'}, + TableWidgets.CellHeader{children = 'Player'}, + Array.map(activeColumns, function(column) + return TableWidgets.CellHeader{children = column.label} + end) + )} + }}, + TableWidgets.TableBody{children = Array.map(players, buildRow(activeColumns))}, + }, + }, + }, + } +end + +return TournamentPlayerStatsTable diff --git a/stylesheets/commons/Miscellaneous.scss b/stylesheets/commons/Miscellaneous.scss index 9b60fe62ad7..4b14b0cd159 100644 --- a/stylesheets/commons/Miscellaneous.scss +++ b/stylesheets/commons/Miscellaneous.scss @@ -2857,3 +2857,71 @@ body .fieldBox { background: var( --clr-surface-2 ); color: var( --clr-on-surface ); } + +/******************************************************************************* +Template(s): TournamentPlayerStats, TournamentInputStats +Author(s): Slothyman +Custom summary boxes above tables +*******************************************************************************/ + +.stats-summary-cards { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + position: relative; + margin-bottom: 1rem; +} + +.stats-summary-card { + flex: 1 1 150px; + min-width: 0; + border: 1px solid var( --clr-on-surface-light-primary-8 ); + border-top: 3px solid var( --clr-wiki-theme-primary ); + border-radius: 0.5rem; + padding: 0.75rem 0.5rem; + text-align: center; + background-color: #ffffff; + box-shadow: 0 0.125rem 0.25rem rgba( 0, 0, 0, 0.05 ); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + .theme--dark & { + background-color: var( --clr-on-surface-dark-primary-4 ); + border-color: var( --clr-on-surface-dark-primary-8 ); + border-top-color: var( --clr-wiki-theme-primary ); + box-shadow: 0 0.125rem 0.375rem rgba( 0, 0, 0, 0.2 ); + } + + @media ( hover: hover ) { + &:hover { + transform: translateY( -2px ); + box-shadow: 0 0.25rem 0.5rem rgba( 0, 0, 0, 0.1 ); + + .theme--dark & { + box-shadow: 0 0.25rem 0.5rem rgba( 0, 0, 0, 0.4 ); + } + } + } + + &__title { + font-size: 1.125rem; + font-weight: bold; + margin-top: 0.25rem; + color: var( --clr-secondary-7 ); + + .theme--dark & { + color: #ffffff; + } + } + + &__subtitle { + font-size: 0.75rem; + text-transform: uppercase; + font-weight: bold; + letter-spacing: 0.05em; + color: var( --clr-secondary-25 ); + + .theme--dark & { + color: var( --clr-secondary-90 ); + } + } +}