diff --git a/lua/wikis/commons/StageWinningsCalculation.lua b/lua/wikis/commons/StageWinningsCalculation.lua new file mode 100644 index 00000000000..b6c6f02dcb8 --- /dev/null +++ b/lua/wikis/commons/StageWinningsCalculation.lua @@ -0,0 +1,133 @@ +--- +-- @Liquipedia +-- page=Module:StageWinningsCalculation +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local Opponent = Lua.import('Module:Opponent/Custom') +local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +local TournamentStructure = Lua.import('Module:TournamentStructure') + +local Condition = Lua.import('Module:Condition') +local ConditionTree = Condition.Tree +local ConditionNode = Condition.Node +local Comparator = Condition.Comparator +local BooleanOperator = Condition.BooleanOperator +local ColumnName = Condition.ColumnName + +local StageWinningsCalculation = {} + +---@param props {matchGroupsSpecProps: table, startDate: integer?, endDate: integer?, mode: string, +---startValue: number, valuePerWin: number, valueByScore: table?} +---@return {opponent: standardOpponent, matchWins: integer, matchLosses: integer, gameWins: integer, +---gameLosses: integer, winnings: number, scoreDetails: table}[] +function StageWinningsCalculation.run(props) + local matches = mw.ext.LiquipediaDB.lpdb('match2', { + conditions = StageWinningsCalculation._buildConditions(props), + query = 'match2opponents, winner', + limit = 5000 + }) + matches = Array.filter(matches, function(match) + return #match.match2opponents == 2 + end) + + local byName = {} + + Array.forEach(matches, function(match) + match.opponents = Array.map(match.match2opponents, Opponent.fromMatch2Record) + Array.forEach(match.opponents, function(opponent, opponentIndex) + local identifier = Opponent.toName(opponent) + opponent.name = identifier + opponent.score = match.match2opponents[opponentIndex].score + opponent.status = match.match2opponents[opponentIndex].status + byName[identifier] = byName[identifier] or { + opponent = opponent, + scoreDetails = {}, + matchWins = 0, + matchLosses = 0, + gameWins = 0, + gameLosses = 0, + winnings = 0, + } + end) + + local winnerId = tonumber(match.winner) + if winnerId ~= 1 and winnerId ~= 2 then return end + local loserId = 3 - winnerId + + local winner = match.opponents[winnerId] + local loser = match.opponents[loserId] + + local winnerScore = OpponentDisplay.InlineScore(winner) + local loserScore = OpponentDisplay.InlineScore(loser) + + local score = winnerScore .. '-' .. loserScore + local reversedScore = loserScore .. '-' .. winnerScore + + byName[winner.name].scoreDetails[score] = (byName[winner.name].scoreDetails[score] or 0) + 1 + byName[loser.name].scoreDetails[reversedScore] = (byName[loser.name].scoreDetails[reversedScore] or 0) + 1 + + byName[winner.name].matchWins = byName[winner.name].matchWins + 1 + byName[loser.name].matchLosses = byName[loser.name].matchLosses + 1 + + byName[winner.name].gameWins = byName[winner.name].gameWins + (tonumber(winner.score) or 0) + byName[loser.name].gameLosses = byName[loser.name].gameLosses + (tonumber(winner.score) or 0) + byName[winner.name].gameLosses = byName[winner.name].gameLosses + (tonumber(loser.score) or 0) + byName[loser.name].gameWins = byName[loser.name].gameWins + (tonumber(loser.score) or 0) + end) + + local opponents = Array.extractValues(byName) + + Array.forEach(opponents, function(opponent) + if props.mode == 'matchWins' then + opponent.winnings = props.startValue + opponent.matchWins * props.valuePerWin + return + elseif props.mode == 'gameWins' then + opponent.winnings = props.startValue + opponent.gameWins * props.valuePerWin + return + end + -- case: props.mode == 'scores' + local winnings = props.startValue + for score, count in pairs(opponent.scoreDetails) do + winnings = winnings + (props.valueByScore[score] or 0) * count + end + opponent.winnings = winnings + end) + + Array.sortInPlaceBy(opponents, function(opponent) + return {- opponent.winnings, - opponent.matchWins, - opponent.gameWins, Opponent.toName(opponent)} + end) + + return opponents + +end + +---@param props {matchGroupsSpecProps: table, startDate: integer?, endDate: integer?} +---@return string +function StageWinningsCalculation._buildConditions(props) + local conditions = ConditionTree(BooleanOperator.all):add{ + ConditionNode(ColumnName('finished'), Comparator.eq, '1'), + ConditionNode(ColumnName('status'), Comparator.neq, 'notplayed'), + ConditionNode(ColumnName('winner'), Comparator.neq, ''), + TournamentStructure.getMatch2Filter( + TournamentStructure.readMatchGroupsSpec(props.matchGroupsSpecProps) + or TournamentStructure.currentPageSpec() + ), + } + + if props.startDate then + conditions:add(ConditionNode(ColumnName('date'), Comparator.ge, props.startDate)) + end + + if props.endDate then + conditions:add(ConditionNode(ColumnName('date'), Comparator.le, props.endDate)) + end + + return tostring(conditions) +end + +return StageWinningsCalculation diff --git a/lua/wikis/commons/Widget/StageWinnings.lua b/lua/wikis/commons/Widget/StageWinnings.lua new file mode 100644 index 00000000000..b46f5510043 --- /dev/null +++ b/lua/wikis/commons/Widget/StageWinnings.lua @@ -0,0 +1,254 @@ +--- +-- @Liquipedia +-- page=Module:Widget/StageWinnings +-- +-- 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 Currency = Lua.import('Module:Currency') +local DateExt = Lua.import('Module:Date/Ext') +local FnUtil = Lua.import('Module:FnUtil') +local Logic = Lua.import('Module:Logic') +local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') +local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +local StageWinningsCalculation = Lua.import('Module:StageWinningsCalculation') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') +local Variables = Lua.import('Module:Variables') + +local HtmlWidgets = Lua.import('Module:Widget/Html/All') +local Widget = Lua.import('Module:Widget') +local Widgets = Lua.import('Module:Widget/All') +local WidgetUtil = Lua.import('Module:Widget/Util') + +local BASE_CURRENCY = 'USD' + +---@class StageWinningProps +---@field tournaments string +---@field ids string? +---@field sdate string|number|osdate|osdateparam? +---@field edate string|number|osdate|osdateparam? +---@field prizeMode 'matchWins'|'gameWins'|'scores' +---@field valueStart number? +---@field valuePerWin number? +---@field n-m number? # n amd m integers +---@field localcurrency string? +---@field width integer? +---@field cutafter integer? +---@field title string? +---@field precision integer? +---@field autoexchange boolean +---@field showMatchWL boolean +---@field showGameWL boolean +---@field showScore boolean + +---@class StageWinnings: Widget +---@operator call(table): StageWinnings +---@field props StageWinningProps +local StageWinnings = Class.new(Widget) +StageWinnings.defaultProps = { + tournaments = mw.title.getCurrentTitle().text, + delimiter = ',', + autoexchange = true, + prizeMode = 'matchWins' +} + +---@return Widget? +function StageWinnings:render() + local props = self.props + + local startDate = DateExt.readTimestamp(props.sdate) + local endDate = DateExt.readTimestamp(props.edate) + + local valueByScore = Table.filterByKey(props, function(key) + if key:match('^%d+%-%d+$') ~= nil then + return true + end + local keyParts = Array.parseCommaSeparatedString(key, '-') + return #keyParts == 2 and + Array.all(keyParts, function(keyPart) return Table.includes(MatchGroupInputUtil.STATUS, keyPart) end) + end) + valueByScore = Table.map(valueByScore, function(key, value) + return key, tonumber(value) + end) + + assert(props.prizeMode == 'matchWins' or props.prizeMode == 'gameWins' or props.prizeMode == 'scores', + 'Invalid prizeMode input') + assert(props.prizeMode ~= 'scores' or Logic.isNotEmpty(valueByScore), + 'No values per scores defined') + + local opponentList = StageWinningsCalculation.run{ + matchGroupsSpecProps = Table.filterByKey(props, function(key) + return String.startsWith(key, 'tournament') or String.startsWith(key, 'matchGroupId') + end) --[[@as table]], + startDate = startDate, + endDate = endDate, + mode = props.prizeMode, + valueByScore = valueByScore, + startValue = tonumber(props.valueStart) or 0, + valuePerWin = tonumber(props.valuePerWin) or 0, + } + + if Logic.isNotEmpty(props.localcurrency) and Logic.readBool(props.autoexchange) then + Currency.display(props.localcurrency, nil, {setVariables = true}) + self.currencyRate = Currency.getExchangeRate{ + currency = props.localcurrency, + currencyRate = Variables.varDefault('exchangerate_' .. props.localcurrency:upper()), + date = DateExt.toYmdInUtc(endDate or DateExt.getContextualDateOrNow()), + setVariables = false + } + end + + return Widgets.DataTable{ + classes = {'prizepooltable', 'collapsed'}, + tableCss = { + ['text-align'] = 'center', + ['margin-top'] = 0, + ['margin-bottom'] = 0, + width = 'auto', + }, + tableAttributes = { + ['data-cutafter'] = (tonumber(props.cutafter) or 5) + 1, -- +1 due to 2nd headerRow + ['data-opentext'] = 'Show remaining participants', + ['data-closetext'] = 'Hide remaining participants', + }, + children = WidgetUtil.collect( + -- first header + HtmlWidgets.Tr{ + children = { + HtmlWidgets.Th{ + attributes = {colspan = '100%'}, + children = {props.title or 'Group Stage Winnings'}, + }, + }, + }, + -- second header + self:_headerRow(), + -- rows + Array.map(opponentList, FnUtil.curry(self._row, self)) + ), + } +end + +---@return Widget +function StageWinnings:_headerRow() + local props = self.props + return HtmlWidgets.Tr{ + children = WidgetUtil.collect( + HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {'Participants'}, + }, + (Logic.readBool(props.showMatchWL) or props.prizeMode == 'matchWins') and HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {'Matches'}, + } or nil, + (Logic.readBool(props.showGameWL) or props.prizeMode == 'gameWins') and HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {'Games'}, + } or nil, + Logic.readBool(props.showScore) and HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {'Score Details'}, + } or nil, + Logic.isNotEmpty(props.localcurrency) and HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {Currency.display(props.localcurrency)}, + } or nil, + (Logic.readBool(props.autoexchange) or Logic.isEmpty(props.localcurrency)) and HtmlWidgets.Th{ + css = {width = 'auto'}, + children = {Currency.display(BASE_CURRENCY)}, + } or nil + ), + } +end + +---@param data {opponent: standardOpponent, matchWins: integer, matchLosses: integer, gameWins: integer, +---gameLosses: integer, winnings: number, scoreDetails: table} +---@return Widget +function StageWinnings:_row(data) + local props = self.props + + local currencyDisplayConfig = { + displaySymbol = true, + formatValue = true, + displayCurrencyCode = false, + formatPrecision = tonumber(props.precision) or 0, + } + + return HtmlWidgets.Tr{ + children = WidgetUtil.collect( + HtmlWidgets.Td{ + css = {['text-align'] = 'left'}, + children = {OpponentDisplay.InlineOpponent{opponent = data.opponent}}, + }, + (Logic.readBool(props.showMatchWL) or props.prizeMode == 'matchWins') and HtmlWidgets.Td{ + css = {width = 'auto'}, + children = { + data.matchWins, + '-', + data.matchLosses + }, + } or nil, + (Logic.readBool(props.showGameWL) or props.prizeMode == 'gameWins') and HtmlWidgets.Td{ + css = {width = 'auto'}, + children = { + data.gameWins, + '-', + data.gameLosses + }, + } or nil, + Logic.readBool(props.showScore) and HtmlWidgets.Td{ + css = {['text-align'] = 'left', width = 'auto'}, + children = StageWinnings._detailedScores(data.scoreDetails), + } or nil, + Logic.isNotEmpty(props.localcurrency) and HtmlWidgets.Td{ + css = {width = 'auto'}, + children = {Currency.display( + props.localcurrency, + data.winnings, + currencyDisplayConfig + )}, + } or nil, + (Logic.readBool(props.autoexchange) or Logic.isEmpty(props.localcurrency)) and HtmlWidgets.Td{ + css = {width = 'auto'}, + children = {Currency.display( + BASE_CURRENCY, + data.winnings * (self.currencyRate or 1), + currencyDisplayConfig + )}, + } or nil + ), + } +end + +---@param scoresTable table +---@return (string|Widget)[] +function StageWinnings._detailedScores(scoresTable) + ---@type {wins: integer, losses: integer, count: integer} + local scoreInfos = Array.extractValues(Table.map(scoresTable, function(score, count) + local wins, losses = score:match('(%d+)%-(%d+)') + return score, {wins = tonumber(wins), losses = tonumber(losses), count = count} + end)) + table.sort(scoreInfos, function(a, b) + local diffA = a.wins - a.losses + local diffB = b.wins - b.losses + if diffA ~= diffB then + return diffA > diffB + end + return a.wins > b.wins + end) + + return Array.interleave( + Array.map(scoreInfos, function(scoreInfo) + return scoreInfo.wins .. '-' .. scoreInfo.losses .. ': ' .. scoreInfo.count .. ' times' + end), + HtmlWidgets.Br{} + ) +end + +return StageWinnings