diff --git a/lua/wikis/counterstrike/MainPageLayout/data.lua b/lua/wikis/counterstrike/MainPageLayout/data.lua index 7fdf0d9e218..dcefcf8d9ff 100644 --- a/lua/wikis/counterstrike/MainPageLayout/data.lua +++ b/lua/wikis/counterstrike/MainPageLayout/data.lua @@ -18,6 +18,7 @@ local FilterButtonsWidget = Lua.import('Module:Widget/FilterButtons') local ThisDayWidgets = Lua.import('Module:Widget/MainPage/ThisDay') local TransfersList = Lua.import('Module:Widget/MainPage/TransfersList') local WantToHelp = Lua.import('Module:Widget/MainPage/WantToHelp') +local VRSStandings = Lua.import('Module:Widget/VRSStandings') local CONTENT = { @@ -74,6 +75,16 @@ local CONTENT = { padding = true, boxid = MainPageLayoutUtil.BoxId.TOURNAMENTS_TICKER, }, + vrsStandings = { + heading = 'Valve Regional Standings', + body = VRSStandings{ + shouldFetch = true, + fetchLimit = 5, + mainpage = true, + }, + padding = false, + boxid = 1521, + } } return { @@ -151,13 +162,17 @@ return { mobileOrder = 1, content = CONTENT.specialEvents, }, + { + mobileOrder = 3, + content = CONTENT.vrsStandings, + }, { mobileOrder = 4, - content = CONTENT.thisDay, + content = CONTENT.transfers, }, { mobileOrder = 5, - content = CONTENT.transfers, + content = CONTENT.thisDay, }, { mobileOrder = 7, diff --git a/lua/wikis/counterstrike/VRSStandingsData.lua b/lua/wikis/counterstrike/VRSStandingsData.lua new file mode 100644 index 00000000000..c15a8adecc1 --- /dev/null +++ b/lua/wikis/counterstrike/VRSStandingsData.lua @@ -0,0 +1,251 @@ +--- +-- @Liquipedia +-- page=Module:VRSStandingsData +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local DateExt = Lua.import('Module:Date/Ext') +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/Custom') +local Table = Lua.import('Module:Table') + +local Condition = Lua.import('Module:Condition') +local BooleanOperator = Condition.BooleanOperator +local Comparator = Condition.Comparator + +local DATAPOINT_TYPE_MAIN = 'vrs_ranking' +local DATAPOINT_TYPE_LIVE = 'vrs_ranking_live' +local DATAPOINT_TYPE_LIQUIPEDIA = 'vrs_ranking_liquipedia' +local DATAPOINT_TYPE_PREDICTION = 'vrs_ranking_prediction' + +---@class VRSStandingsData +local VRSStandingsData = {} + +VRSStandingsData.DATAPOINT_TYPE_MAIN = DATAPOINT_TYPE_MAIN +VRSStandingsData.DATAPOINT_TYPE_LIVE = DATAPOINT_TYPE_LIVE +VRSStandingsData.DATAPOINT_TYPE_LIQUIPEDIA = DATAPOINT_TYPE_LIQUIPEDIA +VRSStandingsData.DATAPOINT_TYPE_PREDICTION = DATAPOINT_TYPE_PREDICTION + +---@class VRSStandingsStanding +---@field place number +---@field points number +---@field local_place number? +---@field global_place number? +---@field opponent standardOpponent + +---@class VRSStandingsSettings +---@field title string +---@field shouldFetch boolean +---@field fetchLimit number? +---@field filterRegion string? +---@field filterSubregion string? +---@field filterCountry string[]? +---@field filterDisplayName string? +---@field filterType 'none' | 'region' | 'subregion' | 'country' +---@field mainpage boolean +---@field datapointType string +---@field updated string + +---Parses props, fetches or reads inline data, stores if needed, applies +---filters, and returns the final standings list alongside resolved settings. +---@param props table +---@return VRSStandingsStanding[] +---@return VRSStandingsSettings +function VRSStandingsData.getStandings(props) + local datapointType = props.datapointType or DATAPOINT_TYPE_LIVE + + local updated + if props.updated == 'latest' then + assert(Logic.readBool(props.shouldFetch), '\'Latest\' can only be used for fetching data') + updated = 'latest' + elseif props.updated then + updated = DateExt.toYmdInUtc(props.updated) + else + if Logic.readBool(props.shouldFetch) then + updated = 'latest' + else + error('A date must be provided when not fetching data') + end + end + + ---@type VRSStandingsSettings + local settings = { + title = props.title, + shouldFetch = Logic.readBool(props.shouldFetch), + fetchLimit = tonumber(props.fetchLimit), + filterRegion = props.filterRegion, + filterSubregion = props.filterSubregion, + filterCountry = Array.parseCommaSeparatedString(props.filterCountry), + filterDisplayName = props.filterDisplayName, + mainpage = Logic.readBool(props.mainpage), + datapointType = datapointType, + updated = updated, + filterType = 'none', + } + + if settings.filterRegion then + settings.filterType = 'region' + elseif settings.filterSubregion then + settings.filterType = 'subregion' + elseif settings.filterCountry and #settings.filterCountry > 0 then + settings.filterType = 'country' + end + + ---@type VRSStandingsStanding[] + local standings = {} + + if settings.shouldFetch then + local fetchedStandings, fetchedDate = VRSStandingsData._fetch(settings.updated, settings.datapointType) + standings = fetchedStandings + settings.updated = string.sub(fetchedDate, 1, 10) or settings.updated + else + Table.iter.forEachPair(props, function(key, value) + if not string.match(key, '^%d+$') then + return + end + + local data = Json.parse(value) + + local opponent = Opponent.readOpponentArgs(Table.merge(data, { + type = Opponent.team, + })) + + data[1] = nil + opponent.players = Array.map(Array.range(1, 5), FnUtil.curry(Opponent.readPlayerArgs, data)) + + opponent.extradata = opponent.extradata or {} + opponent.extradata.region = data.region + opponent.extradata.subregion = data.subregion + opponent.extradata.country = data.country + + table.insert(standings, { + place = tonumber(key), + points = tonumber(data.points), + opponent = opponent + }) + end) + + VRSStandingsData._store(settings.updated, settings.datapointType, standings) + end + + Array.sortInPlaceBy(standings, Operator.property('place')) + + -- Filtering + standings = Array.filter(standings, function(entry) + local extradata = entry.opponent.extradata or {} + + if settings.filterType == 'region' then + return extradata.region == settings.filterRegion + end + + if settings.filterType == 'subregion' then + return extradata.subregion == settings.filterSubregion + end + + if settings.filterType == 'country' then + local filterSet = {} + for _, flag in ipairs(settings.filterCountry) do + filterSet[flag] = true + end + local matchingPlayers = Array.filter(entry.opponent.players, function(player) + return player ~= nil + and player.flag ~= nil + and filterSet[player.flag] + end) + return #matchingPlayers >= 3 + end + + return true + end) + + if settings.fetchLimit then + standings = Array.sub(standings, 1, settings.fetchLimit) + end + + Array.forEach(standings, function(entry, index) + entry.local_place = index + if settings.filterType ~= 'none' then + entry.global_place = entry.place + end + end) + + return standings, settings +end + +---@private +---@param updated string +---@param datapointType string +---@param standings VRSStandingsStanding[] +function VRSStandingsData._store(updated, datapointType, standings) + if Lpdb.isStorageDisabled() then + return + end + + local dataPoint = Lpdb.DataPoint:new{ + objectname = datapointType .. '_' .. updated, + type = datapointType, + name = 'Unofficial VRS (' .. updated .. ')', + date = updated, + extradata = standings + } + + dataPoint:save() +end + +---@private +---@param updated string +---@param datapointType string +---@return VRSStandingsStanding[] +---@return string +function VRSStandingsData._fetch(updated, datapointType) + local conditions = Condition.Tree(BooleanOperator.all):add{ + Condition.Node(Condition.ColumnName('namespace'), Comparator.eq, 0), + } + + if updated ~= 'latest' then + conditions:add{ + Condition.Node(Condition.ColumnName('date'), Comparator.eq, updated) + } + end + + if datapointType == DATAPOINT_TYPE_MAIN then + conditions:add{ + Condition.Node(Condition.ColumnName('type'), Comparator.eq, DATAPOINT_TYPE_MAIN) + } + elseif datapointType == DATAPOINT_TYPE_LIQUIPEDIA then + conditions:add{ + Condition.Node(Condition.ColumnName('type'), Comparator.eq, DATAPOINT_TYPE_LIQUIPEDIA) + } + elseif datapointType == DATAPOINT_TYPE_PREDICTION then + conditions:add{ + Condition.Node(Condition.ColumnName('type'), Comparator.eq, DATAPOINT_TYPE_PREDICTION) + } + else + conditions:add( + Condition.Util.anyOf( + Condition.ColumnName('type'), + {DATAPOINT_TYPE_LIVE, DATAPOINT_TYPE_MAIN} + ) + ) + end + + local data = mw.ext.LiquipediaDB.lpdb('datapoint', { + conditions = conditions:toString(), + query = 'extradata, date', + order = 'date desc', + limit = 1, + }) + + assert(data[1], 'No VRS data found for type "' .. datapointType .. '" on date "' .. updated .. '"') + return data[1].extradata, data[1].date +end + +return VRSStandingsData diff --git a/lua/wikis/counterstrike/Widget/VRSStandings.lua b/lua/wikis/counterstrike/Widget/VRSStandings.lua new file mode 100644 index 00000000000..44bc4a4ace3 --- /dev/null +++ b/lua/wikis/counterstrike/Widget/VRSStandings.lua @@ -0,0 +1,213 @@ +--- +-- @Liquipedia +-- page=Module:Widget/VRSStandings +-- +-- 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 MathUtil = Lua.import('Module:MathUtil') +local PlayerDisplay = Lua.import('Module:Player/Display/Custom') +local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') + +local TableWidgets = Lua.import('Module:Widget/Table2/All') +local Widget = Lua.import('Module:Widget') +local WidgetUtil = Lua.import('Module:Widget/Util') +local HtmlWidgets = Lua.import('Module:Widget/Html/All') + +local Link = Lua.import('Module:Widget/Basic/Link') +local Icon = Lua.import('Module:Icon') + +local VRSStandingsData = Lua.import('Module:VRSStandingsData') + +local FOOTER_LINK = 'Valve_Regional_Standings' + +---@class VRSStandings: Widget +---@operator call(table): VRSStandings +---@field props table +local VRSStandings = Class.new(Widget) +VRSStandings.defaultProps = { + title = 'VRS Standings', + datapointType = VRSStandingsData.DATAPOINT_TYPE_LIVE, +} + +---@param settings VRSStandingsSettings +---@return Widget[] +local function buildHeaderCells(settings) + local filtered = settings.filterType ~= 'none' + return WidgetUtil.collect( + TableWidgets.CellHeader{children = 'Rank'}, + filtered and TableWidgets.CellHeader{children = 'Global Rank'} or nil, + TableWidgets.CellHeader{children = 'Points'}, + TableWidgets.CellHeader{children = 'Team'}, + not filtered and TableWidgets.CellHeader{children = 'Region'} or nil, + not settings.mainpage and TableWidgets.CellHeader{children = 'Roster'} or nil + ) +end + +---@param settings VRSStandingsSettings +---@return Widget +local function buildHeaderRow(settings) + return TableWidgets.TableHeader{ + children = { + TableWidgets.Row{children = buildHeaderCells(settings)} + } + } +end + +---@param settings VRSStandingsSettings +---@return table[] +local function buildColumns(settings) + local filtered = settings.filterType ~= 'none' + local columns = WidgetUtil.collect( + {align = 'center', sortType = 'number'}, + filtered and {align = 'center', sortType = 'number'} or nil, + {align = 'center', sortType = 'number'}, + {align = 'left'}, + not filtered and {align = 'center'} or nil, + not settings.mainpage and {align = 'left'} or nil + ) + if settings.mainpage then + Array.forEach(columns, function(col) + col.width = (100 / #columns) .. '%' + end) + end + return columns +end + +---@param settings VRSStandingsSettings +---@return Widget +local function buildTitle(settings) + local regionMap = { + AS = 'Asia', + AM = 'Americas', + EU = 'Europe' + } + local titleName = 'Global' + if settings.filterType == 'region' then + titleName = regionMap[settings.filterRegion] or settings.filterRegion + elseif settings.filterType == 'subregion' then + titleName = settings.filterDisplayName or 'Subregion' + elseif settings.filterType == 'country' then + titleName = settings.filterDisplayName or 'Country' + end + return HtmlWidgets.Div{ + children = { + HtmlWidgets.Div{ + children = { + HtmlWidgets.B{children = 'Unofficial ' .. titleName .. ' VRS'}, + HtmlWidgets.Span{children = 'Last updated: ' .. settings.updated} + }, + classes = {'ranking-table__top-row-text'} + }, + HtmlWidgets.Div{ + children = { + HtmlWidgets.Span{children = 'Data by Liquipedia'}, + }, + classes = {'ranking-table__top-row-logo-container'} + } + }, + classes = {'ranking-table__top-row'}, + } +end + +---@return Widget +local function buildFooter() + return Link{ + link = FOOTER_LINK, + linktype = 'internal', + children = { + HtmlWidgets.Div{ + children = {'See Rankings Page', Icon.makeIcon{iconName = 'goto'}}, + classes = {'ranking-table__footer-button'}, + } + }, + } +end + +---@param standing VRSStandingsStanding +---@param mainpage boolean +---@return Widget +function VRSStandings._row(standing, mainpage) + local extradata = standing.opponent.extradata or {} + + local cells + if standing.global_place then + cells = WidgetUtil.collect( + TableWidgets.Cell{children = standing.local_place}, + TableWidgets.Cell{children = standing.global_place}, + TableWidgets.Cell{ + children = MathUtil.formatRounded{value = standing.points, precision = 1} + }, + TableWidgets.Cell{ + children = OpponentDisplay.InlineOpponent{ + opponent = standing.opponent + } + } + ) + else + cells = WidgetUtil.collect( + TableWidgets.Cell{children = standing.place}, + TableWidgets.Cell{ + children = MathUtil.formatRounded{value = standing.points, precision = 1} + }, + TableWidgets.Cell{ + children = OpponentDisplay.InlineOpponent{ + opponent = standing.opponent + } + }, + TableWidgets.Cell{children = extradata.region or ''} + ) + end + + if not mainpage then + table.insert(cells, + TableWidgets.Cell{ + children = Array.map(standing.opponent.players, function(player) + return HtmlWidgets.Span{ + css = {display="inline-block", width="160px"}, + children = PlayerDisplay.InlinePlayer({player = player}) + } + end) + } + ) + end + + return TableWidgets.Row{children = cells} +end + +---@return Widget +function VRSStandings:render() + local standings, settings = VRSStandingsData.getStandings(self.props) + + if #standings == 0 then + return HtmlWidgets.Div{ + children = { + HtmlWidgets.B{children = 'No teams found for the selected filter.'} + }, + css = {padding = '12px'} + } + end + + local tableWidget = TableWidgets.Table{ + title = buildTitle(settings), + sortable = false, + columns = buildColumns(settings), + footer = settings.mainpage and buildFooter() or nil, + css = settings.mainpage and {width = '100%'} or nil, + children = { + buildHeaderRow(settings), + TableWidgets.TableBody{ + children = Array.map(standings, function(entry) + return VRSStandings._row(entry, settings.mainpage) + end) + } + }, + } + return tableWidget +end + +return VRSStandings