diff --git a/components/match2/wikis/heroes/match_group_input_custom.lua b/components/match2/wikis/heroes/match_group_input_custom.lua new file mode 100644 index 00000000000..5e81344d6c5 --- /dev/null +++ b/components/match2/wikis/heroes/match_group_input_custom.lua @@ -0,0 +1,576 @@ +--- +-- @Liquipedia +-- wiki=heroes +-- page=Module:MatchGroup/Input/Custom +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Array = require('Module:Array') +local Json = require('Module:Json') +local Logic = require('Module:Logic') +local Lua = require('Module:Lua') +local String = require('Module:StringUtils') +local Table = require('Module:Table') +local Variables = require('Module:Variables') +local ChampionNames = mw.loadData('Module:HeroNames') +local Streams = require('Module:Links/Stream') + +local MatchGroupInput = Lua.import('Module:MatchGroup/Input', {requireDevIfEnabled = true}) +local Opponent = Lua.import('Module:Opponent', {requireDevIfEnabled = true}) + +local STATUS_SCORE = 'S' +local STATUS_DRAW = 'D' +local STATUS_DEFAULT_WIN = 'W' +local STATUS_FORFEIT = 'FF' +local STATUS_DISQUALIFIED = 'DQ' +local STATUS_DEFAULT_LOSS = 'L' +local ALLOWED_STATUSES = { + STATUS_DRAW, + STATUS_DEFAULT_WIN, + STATUS_FORFEIT, + STATUS_DISQUALIFIED, + STATUS_DEFAULT_LOSS, +} +local ALLOWED_VETOES = {'decider', 'pick', 'ban', 'defaultban'} +local MAX_NUM_OPPONENTS = 2 +local MAX_NUM_PLAYERS = 5 +local DEFAULT_BESTOF = 3 +local DEFAULT_MODE = 'team' +local NO_SCORE = -99 +local DUMMY_MAP = 'default' +local NP_STATUSES = {'skip', 'np', 'canceled', 'cancelled'} +local EPOCH_TIME = '1970-01-01 00:00:00' +local DEFAULT_RESULT_TYPE = 'default' +local NOT_PLAYED_SCORE = -1 +local NO_WINNER = -1 +local SECONDS_UNTIL_FINISHED_EXACT = 30800 +local SECONDS_UNTIL_FINISHED_NOT_EXACT = 86400 + +-- containers for process helper functions +local matchFunctions = {} +local mapFunctions = {} +local opponentFunctions = {} + +local CustomMatchGroupInput = {} + +-- called from Module:MatchGroup +function CustomMatchGroupInput.processMatch(match, options) + -- Count number of maps, check for empty maps to remove, and automatically count score + match = matchFunctions.getBestOf(match) + match = matchFunctions.getScoreFromMapWinners(match) + + -- process match + Table.mergeInto( + match, + matchFunctions.readDate(match) + ) + match = matchFunctions.getOpponents(match) + match = matchFunctions.getTournamentVars(match) + match = matchFunctions.getVodStuff(match) + match = matchFunctions.getExtraData(match) + + -- Adjust map data, especially set participants data + match = matchFunctions.adjustMapData(match) + + return match +end + +function matchFunctions.adjustMapData(match) + local opponents = {} + for opponentIndex = 1, MAX_NUM_OPPONENTS do + opponents[opponentIndex] = match['opponent' .. opponentIndex] + end + for _, map in Table.iter.pairsByPrefix(match, 'map') do + mapFunctions.getParticipants(map, opponents) + mapFunctions.getAdditionalExtraData(map) + end + + return match +end + +-- called from Module:Match/Subobjects +function CustomMatchGroupInput.processMap(map) + if map.map == DUMMY_MAP then + map.map = nil + end + map = mapFunctions.getScoresAndWinner(map) + map = mapFunctions.getTournamentVars(map) + + return map +end + +function CustomMatchGroupInput.processOpponent(record, date) + local opponent = Opponent.readOpponentArgs(record) + or Opponent.blank() + + -- Convert byes to literals + if opponent.type == Opponent.team and opponent.template:lower() == 'bye' then + opponent = {type = Opponent.literal, name = 'BYE'} + end + + Opponent.resolve(opponent, date) + MatchGroupInput.mergeRecordWithOpponent(record, opponent) +end + +-- called from Module:Match/Subobjects +function CustomMatchGroupInput.processPlayer(player) + return player +end + +-- +-- +-- function to check for draws +function CustomMatchGroupInput.placementCheckDraw(table) + local last + for _, scoreInfo in pairs(table) do + if scoreInfo.status ~= STATUS_SCORE and scoreInfo.status ~= STATUS_DRAW then + return false + end + if last and last ~= scoreInfo.score then + return false + else + last = scoreInfo.score + end + end + + return true +end + +function CustomMatchGroupInput.getResultTypeAndWinner(data, indexedScores) + -- Map or Match wasn't played, set not played + if + Table.includes(NP_STATUSES, data.finished) or + Table.includes(NP_STATUSES, data.winner) + then + data.resulttype = 'np' + data.finished = true + -- Map or Match is marked as finished. + -- Calculate and set winner, resulttype, placements and walkover (if applicable for the outcome) + elseif Logic.readBool(data.finished) then + if CustomMatchGroupInput.placementCheckDraw(indexedScores) then + data.winner = 0 + data.resulttype = 'draw' + indexedScores = CustomMatchGroupInput.setPlacement(indexedScores, data.winner, 'draw') + elseif CustomMatchGroupInput.placementCheckSpecialStatus(indexedScores) then + data.winner = CustomMatchGroupInput.getDefaultWinner(indexedScores) + data.resulttype = DEFAULT_RESULT_TYPE + if CustomMatchGroupInput.placementCheckFF(indexedScores) then + data.walkover = 'ff' + elseif CustomMatchGroupInput.placementCheckDQ(indexedScores) then + data.walkover = 'dq' + elseif CustomMatchGroupInput.placementCheckWL(indexedScores) then + data.walkover = 'l' + end + indexedScores = CustomMatchGroupInput.setPlacement(indexedScores, data.winner, DEFAULT_RESULT_TYPE) + else + local winner + indexedScores, winner = CustomMatchGroupInput.setPlacement(indexedScores, data.winner, nil, data.finished) + data.winner = data.winner or winner + end + end + + --set it as finished if we have a winner + if not Logic.isEmpty(data.winner) then + data.finished = true + end + + return data, indexedScores +end + +function CustomMatchGroupInput.setPlacement(opponents, winner, specialType, finished) + if specialType == 'draw' then + for _, opponent in pairs(opponents) do + opponent.placement = 1 + end + elseif specialType == DEFAULT_RESULT_TYPE then + for key in pairs(opponents) do + if key == winner then + opponents[key].placement = 1 + else + opponents[key].placement = 2 + end + end + else + local lastScore = NO_SCORE + local lastPlacement = NO_SCORE + local counter = 0 + for scoreIndex, opp in Table.iter.spairs(opponents, CustomMatchGroupInput.placementSortFunction) do + local score = tonumber(opp.score) + counter = counter + 1 + if counter == 1 and String.isEmpty(winner) then + if finished then + winner = scoreIndex + end + end + if lastScore == score then + opponents[scoreIndex].placement = tonumber(opponents[scoreIndex].placement) or lastPlacement + else + opponents[scoreIndex].placement = tonumber(opponents[scoreIndex].placement) or counter + lastPlacement = counter + lastScore = score or NO_SCORE + end + end + end + + return opponents, winner +end + +function CustomMatchGroupInput.placementSortFunction(table, key1, key2) + local value1 = tonumber(table[key1].score) or NO_SCORE + local value2 = tonumber(table[key2].score) or NO_SCORE + return value1 > value2 +end + +-- Check if any opponent has a none-standard status +function CustomMatchGroupInput.placementCheckSpecialStatus(table) + return Table.any(table, + function (_, scoreinfo) + return scoreinfo.status ~= STATUS_SCORE and String.isNotEmpty(scoreinfo.status) + end + ) +end + +-- function to check for forfeits +function CustomMatchGroupInput.placementCheckFF(table) + return Table.any(table, function (_, scoreinfo) return scoreinfo.status == STATUS_FORFEIT end) +end + +-- function to check for DQ's +function CustomMatchGroupInput.placementCheckDQ(table) + return Table.any(table, function (_, scoreinfo) return scoreinfo.status == STATUS_DISQUALIFIED end) +end + +-- function to check for W/L +function CustomMatchGroupInput.placementCheckWL(table) + return Table.any(table, function (_, scoreinfo) return scoreinfo.status == STATUS_DEFAULT_LOSS end) +end + +-- Get the winner when resulttype=default +function CustomMatchGroupInput.getDefaultWinner(table) + for index, scoreInfo in pairs(table) do + if scoreInfo.status == STATUS_DEFAULT_WIN then + return index + end + end + return NO_WINNER +end + +-- +-- match related functions +-- +function matchFunctions.getBestOf(match) + match.bestof = Logic.emptyOr(match.bestof, Variables.varDefault('bestof', DEFAULT_BESTOF)) + Variables.varDefine('bestof', match.bestof) + return match +end + +-- Calculate the match scores based on the map results (counting map wins) +-- Only update an opponents result if it's +-- 1) Not manually added +-- 2) At least one map has a winner +function matchFunctions.getScoreFromMapWinners(match) + local newScores = {} + local foundScores = false + + local mapIndex = 1 + while match['map'..mapIndex] do + local winner = tonumber(match['map'..mapIndex].winner) + foundScores = true + if winner and winner > 0 and winner <= MAX_NUM_OPPONENTS then + newScores[winner] = (newScores[winner] or 0) + 1 + end + mapIndex = mapIndex + 1 + end + + for index = 1, MAX_NUM_OPPONENTS do + if not match['opponent' .. index].score and foundScores then + match['opponent' .. index].score = newScores[index] or 0 + end + end + + return match +end + +function matchFunctions.readDate(matchArgs) + if matchArgs.date then + local dateProps = MatchGroupInput.readDate(matchArgs.date) + dateProps.hasDate = true + return dateProps + else + local suggestedDate = Variables.varDefaultMulti( + 'tournament_enddate', + 'tournament_startdate', + EPOCH_TIME + ) + return { + date = MatchGroupInput.getInexactDate(suggestedDate), + dateexact = false, + } + end +end + +function matchFunctions.getTournamentVars(match) + match.mode = Logic.emptyOr(match.mode, Variables.varDefault('tournament_mode', DEFAULT_MODE)) + match.publishertier = Logic.emptyOr(match.publishertier, Variables.varDefault('tournament_publishertier')) + return MatchGroupInput.getCommonTournamentVars(match) +end + +function matchFunctions.getVodStuff(match) + match.stream = Streams.processStreams(match) + match.vod = Logic.emptyOr(match.vod, Variables.varDefault('vod')) + + match.links = {} + local links = match.links + if match.reddit then links.reddit = match.reddit end + + return match +end + +function matchFunctions.getExtraData(match) + match.extradata = { + mapveto = matchFunctions.getMapVeto(match), + mvp = MatchGroupInput.readMvp(match), + casters = MatchGroupInput.readCasters(match), + } + return match +end + +-- Parse the mapVeto input +function matchFunctions.getMapVeto(match) + if not match.mapveto then return nil end + + local mapVeto = Json.parseIfString(match.mapveto) + + local data = { + vetostart = mapVeto.vetostart, + format = mapVeto.format, + } + for index, vetoType in ipairs(mw.text.split(mapVeto.types or '', ',')) do + vetoType = mw.text.trim(vetoType):lower() + if not Table.includes(ALLOWED_VETOES, vetoType) then + return nil -- Any invalid input will not store (ie hide) all vetoes. + end + table.insert(data, {type = vetoType, team1 = mapVeto['t1map'..index], team2 = mapVeto['t2map'..index]}) + end + + return data +end + +function matchFunctions.getOpponents(match) + local opponents = {} + local isScoreSet = false + + for _, opponent, opponentIndex in Table.iter.pairsByPrefix(match, 'opponent') do + CustomMatchGroupInput.processOpponent(opponent, match.date) + + -- Retrieve icon for team + if opponent.type == Opponent.team then + opponent.icon, opponent.icondark = opponentFunctions.getIcon(opponent.template) + end + + -- apply status + opponent.score = string.upper(opponent.score or '') + if Logic.isNumeric(opponent.score) then + opponent.score = tonumber(opponent.score) + opponent.status = STATUS_SCORE + isScoreSet = true + elseif Table.includes(ALLOWED_STATUSES, opponent.score) then + opponent.status = opponent.score + opponent.score = NOT_PLAYED_SCORE + end + + -- get players from vars for teams + if opponent.type == Opponent.team then + if not Logic.isEmpty(opponent.name) then + match = MatchGroupInput.readPlayersOfTeam(match, opponentIndex, opponent.name) + end + elseif Opponent.typeIsParty(opponent) then + opponent.match2players = Json.parseIfString(opponent.match2players) or {} + opponent.match2players[1] = opponent.match2players[1] or {} + opponent.match2players[1].name = opponent.match2players[1].name or opponent.name + elseif opponent.type ~= Opponent.literal then + error('Unsupported Opponent Type "' .. (opponent.type or '') .. '"') + end + + opponents[opponentIndex] = opponent + end + + matchFunctions.applyWalkover(match, opponents) + matchFunctions.checkIfFinished(match, isScoreSet, opponents) + + + -- apply placements and winner if finshed + if + not Logic.isEmpty(match.winner) or + Logic.readBool(match.finished) or + CustomMatchGroupInput.placementCheckSpecialStatus(opponents) + then + match.finished = true + match, opponents = CustomMatchGroupInput.getResultTypeAndWinner(match, opponents) + end + + -- Update all opponents with new values + for opponentIndex, opponent in pairs(opponents) do + match['opponent' .. opponentIndex] = opponent + end + return match +end + +function matchFunctions.applyWalkover(match, opponents) + --apply walkover input + match.walkover = string.upper(match.walkover or '') + if Logic.isNumeric(match.walkover) then + local winnerIndex = tonumber(match.walkover) + opponents = matchFunctions._makeAllOpponentsLoseByWalkover(opponents, STATUS_DEFAULT_LOSS) + opponents[winnerIndex].status = STATUS_DEFAULT_WIN + match.finished = true + elseif Logic.isNumeric(match.winner) and Table.includes(ALLOWED_STATUSES, match.walkover) then + local winnerIndex = tonumber(match.winner) + opponents = matchFunctions._makeAllOpponentsLoseByWalkover(opponents, match.walkover) + opponents[winnerIndex].status = STATUS_DEFAULT_WIN + match.finished = true + end +end + +function matchFunctions.checkIfFinished(match, isScoreSet, opponents) + -- see if match should actually be finished if bestof limit was reached + match.finished = Logic.readBool(match.finished) + or isScoreSet and ( + Array.any(opponents, function(opponent) return (tonumber(opponent.score) or 0) > match.bestof/2 end) + or Array.all(opponents, function(opponent) return (tonumber(opponent.score) or 0) == match.bestof/2 end) + ) + + -- see if match should actually be finished if score is set + if isScoreSet and not Logic.readBool(match.finished) and match.hasDate then + local currentUnixTime = os.time(os.date('!*t') --[[@as osdate]]) + local lang = mw.getContentLanguage() + local matchUnixTime = tonumber(lang:formatDate('U', match.date)) + local threshold = match.dateexact and SECONDS_UNTIL_FINISHED_EXACT + or SECONDS_UNTIL_FINISHED_NOT_EXACT + if matchUnixTime + threshold < currentUnixTime then + match.finished = true + end + end +end + +function matchFunctions._makeAllOpponentsLoseByWalkover(opponents, walkoverType) + for index, _ in pairs(opponents) do + opponents[index].score = NOT_PLAYED_SCORE + opponents[index].status = walkoverType + end + return opponents +end + +-- +-- map related functions +-- + +-- Parse extradata information +function mapFunctions.getAdditionalExtraData(map) + map.extradata.comment = map.comment + map.extradata.team1side = string.lower(map.team1side or '') + map.extradata.team2side = string.lower(map.team2side or '') +end + +-- Parse participant information +function mapFunctions.getParticipants(map, opponents) + local participants = {} + local championData = {} + for opponentIndex = 1, MAX_NUM_OPPONENTS do + for playerIndex = 1, MAX_NUM_PLAYERS do + local champ = map['t' .. opponentIndex .. 'h' .. playerIndex] + championData['team' .. opponentIndex .. 'champion' .. playerIndex] = + ChampionNames[champ] or champ + + championData['t' .. opponentIndex .. 'kda' .. playerIndex] = + map['t' .. opponentIndex .. 'kda' .. playerIndex] + + local player = map['t' .. opponentIndex .. 'p' .. playerIndex] + if String.isNotEmpty(player) then + participants = mapFunctions.attachToParticipant( + player, + opponentIndex, + opponents[opponentIndex].match2players, + participants, + championData['team' .. opponentIndex .. 'champion' .. playerIndex], + championData['team' .. opponentIndex .. 'kda' .. playerIndex] + ) + end + end + local banIndex = 1 + local currentBan = map['t' .. opponentIndex .. 'b' .. banIndex] + while currentBan do + championData['team' .. opponentIndex .. 'ban' .. banIndex] = ChampionNames[currentBan] or currentBan + banIndex = banIndex + 1 + currentBan = map['t' .. opponentIndex .. 'b' .. banIndex] + end + end + + map.extradata = championData + map.participants = participants +end + +function mapFunctions.attachToParticipant(player, opponentIndex, players, participants, champion, kda) + player = mw.ext.TeamLiquidIntegration.resolve_redirect(player):gsub(' ', '_') + for playerIndex, item in pairs(players or {}) do + if player == item.name then + participants[opponentIndex .. '_' .. playerIndex] = { + champion = champion, + kda = kda + } + break + end + end + + return participants +end + +-- Calculate Score and Winner of the map +function mapFunctions.getScoresAndWinner(map) + map.scores = {} + local indexedScores = {} + for scoreIndex = 1, MAX_NUM_OPPONENTS do + -- read scores + local score = map['score' .. scoreIndex] or map['t' .. scoreIndex .. 'score'] + local obj = {} + if not Logic.isEmpty(score) then + if Logic.isNumeric(score) then + obj.status = STATUS_SCORE + score = tonumber(score) + map['score' .. scoreIndex] = score + obj.score = score + elseif Table.includes(ALLOWED_STATUSES, score) then + obj.status = score + obj.score = NOT_PLAYED_SCORE + end + table.insert(map.scores, score) + indexedScores[scoreIndex] = obj + else + break + end + end + + map = CustomMatchGroupInput.getResultTypeAndWinner(map, indexedScores) + + return map +end + +function mapFunctions.getTournamentVars(map) + map.mode = Logic.emptyOr(map.mode, Variables.varDefault('tournament_mode', DEFAULT_MODE)) + return MatchGroupInput.getCommonTournamentVars(map) +end + +-- +-- opponent related functions +-- +function opponentFunctions.getIcon(template) + local raw = mw.ext.TeamTemplate.raw(template) + if raw then + local icon = Logic.emptyOr(raw.image, raw.legacyimage) + local iconDark = Logic.emptyOr(raw.imagedark, raw.legacyimagedark) + return icon, iconDark + end +end + +return CustomMatchGroupInput