Module:MatchGroup/Coordinates

From Liquipedia Commons Wiki
---
-- @Liquipedia
-- wiki=commons
-- page=Module:MatchGroup/Coordinates
--
-- Please see https://github.com/Liquipedia/Lua-Modules to contribute
--

local Array = require('Module:Array')
local Iterator = require('Module:Iterator')
local MathUtil = require('Module:MathUtil')
local String = require('Module:StringUtils')
local Table = require('Module:Table')
local TreeUtil = require('Module:TreeUtil')

---@class MatchGroupCoordinatesRoundProps
---@field depth integer
---@field depthCount integer
---@field roundIndex integer
---@field matchIndexInRound integer

local MatchGroupCoordinates = {}

---@param bracketDatasById table<string, MatchGroupUtilBracketBracketData>
---@param start string
---@return function
function MatchGroupCoordinates.dfsFrom(bracketDatasById, start)
	return TreeUtil.dfs(
		function(matchId)
			return bracketDatasById[matchId].lowerMatchIds
		end,
		start
	)
end

---@param bracket MatchGroupUtilBracket
---@return function
function MatchGroupCoordinates.dfs(bracket)
	return Iterator.flatMap(function(_, rootMatchId)
		return MatchGroupCoordinates.dfsFrom(bracket.bracketDatasById, rootMatchId)
	end, ipairs(bracket.rootMatchIds))
end

---@param bracketDatasById table<string, MatchGroupUtilBracketData>
---@return table<string, string>
function MatchGroupCoordinates.computeUpperMatchIds(bracketDatasById)
	local upperMatchIds = {}
	for matchId, bracketData in pairs(bracketDatasById) do
		for _, lowerMatchId in ipairs(bracketData.lowerMatchIds) do
			upperMatchIds[lowerMatchId] = matchId
		end
	end
	return upperMatchIds
end

---@param bracket MatchGroupUtilBracket
---@return string[][]
---@return table<string, integer>
function MatchGroupCoordinates.computeSections(bracket)
	local sectionIxs = {}
	local sections = {}
	for matchId in MatchGroupCoordinates.dfs(bracket) do
		local bracketData = bracket.bracketDatasById[matchId]
		local upperMatch = bracketData.upperMatchId and bracket.bracketDatasById[bracketData.upperMatchId]
		local isNewSection = bracketData.header ~= nil
			and (not upperMatch or matchId ~= upperMatch.lowerMatchIds[1])
			and not String.endsWith(matchId, 'RxMTP')
		if isNewSection then
			table.insert(sections, {})
		end
		table.insert(sections[#sections], matchId)
		sectionIxs[matchId] = #sections
	end
	return sections, sectionIxs
end

---@param bracketDatasById table<string, MatchGroupUtilBracketData>
---@param startMatchId string
---@return table<string, integer>
---@return integer
function MatchGroupCoordinates.computeDepthsFrom(bracketDatasById, startMatchId)
	local depths = {}
	local maxDepth = -1
	local function visit(matchId, depth)
		local bracketData = bracketDatasById[matchId]
		depths[matchId] = depth
		maxDepth = math.max(maxDepth, depth + bracketData.skipRound)
		for _, lowerMatchId in ipairs(bracketData.lowerMatchIds) do
			visit(lowerMatchId, depth + 1 + bracketData.skipRound)
		end
	end
	visit(startMatchId, 0)
	return depths, maxDepth + 1
end

---@param bracket MatchGroupUtilBracket
---@param sectionIxs table<string, integer>
---@return table<string, integer>
function MatchGroupCoordinates.computeSemanticDepths(bracket, sectionIxs)
	local depths = {}
	local function visit(matchId, depth)
		local lowerMatchIds = bracket.bracketDatasById[matchId].lowerMatchIds
		local lastLowerId = #lowerMatchIds and lowerMatchIds[#lowerMatchIds]
		local isGrandFinal = lastLowerId and sectionIxs[lastLowerId] ~= sectionIxs[matchId]
		if isGrandFinal then
			depth = depth - 1
		end
		depths[matchId] = depth
		for _, lowerMatchId in ipairs(lowerMatchIds) do
			visit(lowerMatchId, depth + 1)
		end
	end

	local groups, _ = Array.groupBy(
		Array.filter(bracket.rootMatchIds, function(matchId) return not String.endsWith(matchId, 'RxMTP') end),
		function(rootMatchId) return sectionIxs[rootMatchId] end
	)
	for _, group in ipairs(groups) do
		local initialDepth = MathUtil.ilog2(#group) + 1
		for _, rootMatchId in ipairs(group) do
			visit(rootMatchId, initialDepth)
		end
	end

	return depths
end

---@param bracket MatchGroupUtilBracket
---@return string[][]
---@return table<string, MatchGroupCoordinatesRoundProps>
function MatchGroupCoordinates.computeRounds(bracket)
	local rounds = {}
	local roundPropsByMatchId = {}
	for _, rootMatchId in ipairs(bracket.rootMatchIds) do
		local depths, depthCount = MatchGroupCoordinates.computeDepthsFrom(bracket.bracketDatasById, rootMatchId)
		for _ = #rounds + 1, depthCount do
			table.insert(rounds, {})
		end

		for matchId, depth in pairs(depths) do
			roundPropsByMatchId[matchId] = {
				depth = depth,
				depthCount = depthCount,
			}
		end
	end

	for _, rootMatchId in ipairs(bracket.rootMatchIds) do
		for matchId in MatchGroupCoordinates.dfsFrom(bracket.bracketDatasById, rootMatchId) do
			local roundProps = roundPropsByMatchId[matchId]

			-- All roots are left aligned, except the third place match which is right aligned
			local roundIndex = String.endsWith(matchId, 'RxMTP')
				and #rounds
				or roundProps.depthCount - roundProps.depth

			table.insert(rounds[roundIndex], matchId)
			roundProps.matchIndexInRound = #rounds[roundIndex]
			roundProps.roundIndex = roundIndex
		end
	end

	return rounds, roundPropsByMatchId
end

---@param sections string[][]
---@param roundPropsByMatchId table<string, MatchGroupCoordinatesRoundProps>
---@return table<string, integer>
function MatchGroupCoordinates.computeSemanticRounds(sections, roundPropsByMatchId)
	local semanticRoundIxs = {}

	for _, section in ipairs(sections) do
		local rounds = {}
		for _, matchId in ipairs(section) do
			local roundIndex = roundPropsByMatchId[matchId].roundIndex
			for _ = #rounds + 1, roundIndex do
				table.insert(rounds, {})
			end
			table.insert(rounds[roundIndex], matchId)
		end

		local semanticRoundIx = 1
		for _, round in ipairs(rounds) do
			for _, matchId in ipairs(round) do
				semanticRoundIxs[matchId] = semanticRoundIx
			end
			if #round ~= 0 then
				semanticRoundIx = semanticRoundIx + 1
			end
		end
	end

	return semanticRoundIxs
end

--[[
Computes properties of a match that describe its position within
the overall bracket.

Brackets are partitioned vertically into sections and roots, and
partitioned horizontally into rounds, columns, depths, and semantic
depths.

Section: Refers to the upper/lower bracket. Single-elim brackets
have one section, double-elim have two. More complicated brackets
can have 3+ sections. The grand finals match is considered to be
part of the upper bracket.

Root: Roots are matches that don't advance to another match in the
bracket. Roots vertically partition the bracket into non-connected
trees. A common use case for multiple roots is if a bracket is
truncated after a round. For example, 16SE-4Qual concludes after the
Ro8, so it has 4 roots.

Round: Rounds are semantic labeling of matches that tracks progress
within a tournament - higher rounds occur later in the tournament.
Brackets created with the bracket designer have matches of the same
round appear in one column. Custom brackets can have rounds not
aligned with columns. Match IDs are grouped by rounds.

Column: The bracket display uses a column layout for matches. The
columns partition the bracket horizontally. For most brackets, there
is no difference between columns and rounds.

Depth: The depth of a match is its distance from its root match.
Root matches have depth 0, each additional round increases the depth
by 1. Skipped rounds are included in the depth.

Semantic depth: The semantic depth encodes the X in "Round of X".
Specifically it is the base 2 logarithm of X, so that the finals has
semantic depth 1, semifinals 2, quarterfinals 3, etc. In double
elimination brackets, the upper bracket finals and lower bracket
finals have semantic depth 1, and the grand finals has semantic
depth 0. Ignores skipped rounds.

Fields reference:
coords.depth: 0-based depth (distance from root). Includes skipped rounds.
coords.depthCount = How deep the tree from the root extends. This is usually 1
more than the max depth, but can be deeper if there are childless matches with
skipRound set.
coords.matchIndexInRound: Index of the match within the round containing it.
coords.rootIndex: Index of the root whose tree contains the match.
coords.roundCount: Number of rounds in the bracket.
coords.roundIndex: Index of the round containing the match.
coords.sectionCount: Number of sections in the bracket.
coords.sectionIndex: Index of the section containing the match. (0=upper, 1=lower for double elim)
coords.semanticDepth: 1 for Finals, 2 for Semi-finals, 3 for Quarterfinals, etc. 0 for Grand Finals.
coords.semanticRoundIndex: Index of the round, skipping rounds that have no matches in the section

All indexes start from 1. coords.depth is 0-based and coords.semanticDepth is
1-based (0 denotes grand final). When stored to LPDB, the 1-based indexes are
converted to 0-based.
]]
---@param bracket MatchGroupUtilBracket
---@return {coordinatesByMatchId:table<string, MatchGroupUtilMatchCoordinates>, rounds:string[][], sections:string[][]}
function MatchGroupCoordinates.computeCoordinates(bracket)
	local sections, sectionIxs = MatchGroupCoordinates.computeSections(bracket)
	local rounds, roundPropsByMatchId = MatchGroupCoordinates.computeRounds(bracket)
	local semanticDepths = MatchGroupCoordinates.computeSemanticDepths(bracket, sectionIxs)
	local semanticRoundIxs = MatchGroupCoordinates.computeSemanticRounds(sections, roundPropsByMatchId)

	local coordinatesByMatchId = {}
	for rootIndex, rootMatchId in ipairs(bracket.rootMatchIds) do
		for matchId in MatchGroupCoordinates.dfsFrom(bracket.bracketDatasById, rootMatchId) do
			coordinatesByMatchId[matchId] = Table.merge(
				roundPropsByMatchId[matchId],
				{
					rootIndex = rootIndex,
					roundCount = #rounds,
					sectionCount = #sections,
					sectionIndex = sectionIxs[matchId],
					semanticDepth = semanticDepths[matchId],
					semanticRoundIndex = semanticRoundIxs[matchId],
				}
			)
		end
	end

	return {
		coordinatesByMatchId = coordinatesByMatchId,
		rounds = rounds,
		sections = sections,
	}
end

--[[
Returns a list of sections. Each section contains the matchIds for the matches
in that section of a bracket. The bracket must have coordinates data
previously computed.

The list is identical to the one returned by
MatchGroupCoordinates.computeSections.
]]
---@param bracket MatchGroupUtilBracket
---@return string[][]
function MatchGroupCoordinates.getSectionsFromCoordinates(bracket)
	return MatchGroupCoordinates.groupMatchIdsByField(bracket, 'sectionIndex')
end

--[[
Returns a list of rounds. Each round contains the matchIds for the matches in
that round of a bracket. The bracket must have coordinates data previously
computed.

The list is identical to the one returned by
MatchGroupCoordinates.computeRounds.
]]
---@param bracket MatchGroupUtilBracket
---@return string[][]
function MatchGroupCoordinates.getRoundsFromCoordinates(bracket)
	return MatchGroupCoordinates.groupMatchIdsByField(bracket, 'roundIndex')
end

---@param bracket MatchGroupUtilBracket
---@param fieldName string
---@return string[][]
function MatchGroupCoordinates.groupMatchIdsByField(bracket, fieldName)
	local countFieldName = fieldName:gsub('Index$', 'Count')
	local count = Table.getByPathOrNil(bracket.matches, {1, 'bracketData', 'coordinates', countFieldName}) or 0

	local byField = Array.map(Array.range(1, count), function() return {} end)
	for matchId in MatchGroupCoordinates.dfs(bracket) do
		local coordinates = bracket.coordinatesByMatchId[matchId]
		table.insert(byField[coordinates[fieldName]], matchId)
	end
	return byField
end

--[[
Compute the number of opponents from each round onward in a bracket.
Computes an array, where each key is round (column) of the bracket,
and the value is the number of opponents who are either in the
current round (column) or are seeded into a later round (column).
]]
---@param bracket MatchGroupUtilBracket
---@return integer[]
function MatchGroupCoordinates.computeBracketOpponentCounts(bracket)
	local countsByRound = MatchGroupCoordinates.computeRawCounts(bracket)

	-- Only include positive counts
	return Array.map(countsByRound, function(countsInRound)
		local sum = 0
		for _, count in ipairs(countsInRound) do
			if count >= 0 then
				sum = sum + count
			end
		end
		return sum
	end)
end

--[[
Computes the number of opponents from each round onward for each section in a
bracket. Lower bracket counts may be negative. This indicates either that an
opponent from the upper bracket dropped down to an earlier round, or that some
opponents leave the tournament directly from the upper bracket.

The third place match is not counted.
]]
---@param bracket MatchGroupUtilBracket
---@return integer[][]
function MatchGroupCoordinates.computeRawCounts(bracket)
	local reverseRounds = Array.reverse(bracket.rounds)

	local countsBySection = Array.map(Array.range(1, #bracket.sections), function(sectionIx) return 0 end)
	local countsByReverseRound = {}
	for _, round in ipairs(reverseRounds) do
		for _, matchId in ipairs(round) do
			local coordinates = bracket.coordinatesByMatchId[matchId]
			local bracketData = bracket.bracketDatasById[matchId]

			local sectionIndex = coordinates.sectionIndex
			local count = 1
			if not bracketData.upperMatchId then
				count = count + 1
				if String.endsWith(matchId, 'RxMTP') then
					count = 0
				end
			elseif coordinates.semanticDepth == 1 then
				-- UB or LB final into grand final
				count = sectionIndex == 1 and 0 or 2
			end
			countsBySection[sectionIndex] = countsBySection[sectionIndex] + count

			-- Loser of match drops down
			if sectionIndex + 1 <= #bracket.sections and coordinates.semanticDepth ~= 0 and not bracketData.qualLose then
				countsBySection[sectionIndex + 1] = countsBySection[sectionIndex + 1] - 1
			end
		end
		table.insert(countsByReverseRound, Table.copy(countsBySection))
	end

	return Array.reverse(countsByReverseRound)
end

return MatchGroupCoordinates