Module:Infobox/Extension/Achievements

From Liquipedia Commons Wiki

---
-- @Liquipedia
-- page=Module:Infobox/Extension/Achievements
--
-- Please see https://github.com/Liquipedia/Lua-Modules to contribute
--

local Lua = require('Module:Lua')

local Array = Lua.import('Module:Array')
local FnUtil = Lua.import('Module:FnUtil')
local Info = Lua.import('Module:Info', {loadData = true})
local LeagueIcon = Lua.import('Module:LeagueIcon')
local Logic = Lua.import('Module:Logic')
local Namespace = Lua.import('Module:Namespace')
local Operator = Lua.import('Module:Operator')
local String = Lua.import('Module:StringUtils')
local TeamTemplate = Lua.import('Module:TeamTemplate')

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 ConditionUtil = Condition.Util

local CustomDefaultOptions = Lua.requireIfExists('Module:Infobox/Extension/Achievements/Custom') or {}

local Opponent = Lua.import('Module:Opponent/Custom')

local NON_BREAKING_SPACE = ' '
local DEFAULT_PLAYER_LIMIT = Info.config.defaultMaxPlayersPerPlacement or 10
local MAX_PARTY_SIZE = 4
local DEFAULT_BASE_CONDITIONS = {
	ConditionUtil.noneOf(ColumnName('liquipediatiertype'), {'Qualifier', 'Charity'}),
	ConditionNode(ColumnName('liquipediatier'), Comparator.eq, 1),
	ConditionNode(ColumnName('placement'), Comparator.eq, 1),
}

local Achievements = {}

---@class AchievementIconsArgs
---@field noTemplate boolean?
---@field onlyForFirstPrizePoolOfPage boolean?
---@field adjustItem? fun(item:table):table
---@field baseConditions AbstractConditionNode[]?
---@field player string?
---@field onlySolo boolean?
---@field playerLimit integer?

---Entry point for achievements icons in infobox player
---@param args AchievementIconsArgs?
---@return string?
function Achievements.player(args)
	if not Namespace.isMain() then return end
	args = args or {}
	local options = Achievements._readOptions(args)

	local player = args.player or mw.title.getCurrentTitle().text

	local onlySolo = Logic.readBool(args.onlySolo)

	local conditions = ConditionTree(BooleanOperator.all)
		:add(options.baseConditions)
		:add(Achievements._playerConditions(player, onlySolo, args.playerLimit or DEFAULT_PLAYER_LIMIT))
		:add(onlySolo and ConditionNode(ColumnName('opponenttype'), Comparator.eq, Opponent.solo) or nil)

	return Achievements.display(Achievements._fetchData(conditions), options)
end

---Builds player conditions for query in `Achievements.player`
---@param player string
---@param onlySolo boolean
---@param playerLimit integer
---@return ConditionTree
function Achievements._playerConditions(player, onlySolo, playerLimit)
	player = player:gsub(' ', '_')
	local playerNoUnderScore = player:gsub('_', ' ')

	local playerNames = {player, playerNoUnderScore}

	if onlySolo then
		return ConditionUtil.anyOf(ColumnName('opponentname'), playerNames) --[[@as ConditionTree]]
	end

	local playerConditions = ConditionTree(BooleanOperator.any):add(
		Array.map(Array.range(1, playerLimit), function(playerIndex)
			return ConditionUtil.anyOf(ColumnName('p' .. playerIndex, 'opponentplayers'), playerNames)
		end)
	)

	return playerConditions
end

---Entry point for infobox team to fetch both team achievements and solo achievements while on team as sep. icon strings
---@param args AchievementIconsArgs?
---@return string? #Team Achievements icon string
---@return string? #Solo Achievements while on team icon string
function Achievements.teamAndTeamSolo(args)
	if not Namespace.isMain() then return end
	local historicalPages = Achievements._getTeamNames()
	local options = Achievements._readOptions(args)

	return Achievements.display(Achievements._fetchDataForTeam(historicalPages, Opponent.team, options), options),
		Achievements.display(Achievements._fetchDataForTeam(historicalPages, Opponent.solo, options), options)
end

---Entry point for infobox team to fetch both team and player achievements while on team as icon strings
---@param args AchievementIconsArgs?
---@return string? #Team Achievements icon string
function Achievements.teamAll(args)
	if not Namespace.isMain() then return end
	local historicalPages = Achievements._getTeamNames()
	local options = Achievements._readOptions(args)

	return Achievements.display(Achievements._fetchDataForTeam(historicalPages, '!' .. Opponent.literal, options), options)
end

---Entry point for infobox team to fetch solo achievements while on team as icon strings
---@param args AchievementIconsArgs?
---@return string?
function Achievements.teamSolo(args)
	if not Namespace.isMain() then return end
	local options = Achievements._readOptions(args)

	return Achievements.display(Achievements._fetchDataForTeam(
		Achievements._getTeamNames(), Opponent.solo, options), options)
end

---Entry point for infobox team to fetch team achievements as icon strings
---@param args AchievementIconsArgs?
---@return string?
function Achievements.team(args)
	if not Namespace.isMain() then return end
	local options = Achievements._readOptions(args)

	return Achievements.display(Achievements._fetchDataForTeam(
		Achievements._getTeamNames(), Opponent.team, options), options)
end

---Fetches (historical) teamNames (both with underscore and without) of a given team
---@return string[]
function Achievements._getTeamNames()
	local pageName = mw.title.getCurrentTitle().text
	local historicalPages = TeamTemplate.queryHistoricalNames(pageName)
	assert(Logic.isNotEmpty(historicalPages), TeamTemplate.noTeamMessage(pageName))

	return Array.extend(
		Array.map(historicalPages, function(team) return (team:gsub(' ', '_')) end),
		Array.map(historicalPages, function(team) return (team:gsub('_', ' ')) end)
	)
end

---@class AchievementIconsOptions
---@field noTemplate boolean
---@field onlyForFirstPrizePoolOfPage boolean
---@field adjustItem fun(item:table):table
---@field baseConditions AbstractConditionNode[]

---Read options
---@param args AchievementIconsArgs?
---@return AchievementIconsOptions
function Achievements._readOptions(args)
	args = args or {}

	return {
		noTemplate = Logic.readBool(Logic.nilOr(args.noTemplate, CustomDefaultOptions.noTemplate)),
		onlyForFirstPrizePoolOfPage = Logic.readBool(Logic.nilOr(
			args.onlyForFirstPrizePoolOfPage,
			CustomDefaultOptions.onlyForFirstPrizePoolOfPage
		)),
		adjustItem = args.adjustItem or CustomDefaultOptions.adjustItem or FnUtil.identity,
		baseConditions = args.baseConditions or CustomDefaultOptions.baseConditions or DEFAULT_BASE_CONDITIONS,
	}
end

---@param historicalPages string[]
---@param opponentType OpponentType
---@param options AchievementIconsOptions
---@return table[]
function Achievements._fetchDataForTeam(historicalPages, opponentType, options)
	return Achievements._fetchData(Achievements._buildTeamConditions(historicalPages, opponentType, options))
end

---Builds query conditions for a team
---@param historicalPages string[]
---@param opponentType OpponentType
---@param options AchievementIconsOptions
---@return ConditionTree
function Achievements._buildTeamConditions(historicalPages, opponentType, options)
	local lpdbKeys = Achievements._getLpdbKeys(opponentType)
	local teamConditions = Array.map(lpdbKeys, function(lpdbKey)
		return ConditionUtil.anyOf(ColumnName(lpdbKey), historicalPages)
	end)

	return ConditionTree(BooleanOperator.all)
		:add(teamConditions)
		:add(ConditionNode(ColumnName('opponenttype'), Comparator.eq, opponentType))
		:add(options.baseConditions)
end

---@param opponentType OpponentType
---@return string[]
function Achievements._getLpdbKeys(opponentType)
	if opponentType == Opponent.team then
		return {'opponenttemplate'}
	end

	return Array.map(Array.range(1, MAX_PARTY_SIZE), function(opponentIndex)
		return 'opponentplayers_p' .. opponentIndex .. 'team'
	end)
end

---Query data for given conditions
---@param conditions AbstractConditionNode
---@return {icon:string?,icondark:string?,pagename:string,tournament:string?,date:osdate,prizepoolindex:integer}[]
function Achievements._fetchData(conditions)
	return mw.ext.LiquipediaDB.lpdb('placement', {
		conditions = tostring(conditions),
		query = 'icon, icondark, pagename, tournament, date, prizepoolindex',
		order = 'date asc',
		limit = 5000,
	})
end

---Build the display
---@param data {icon:string?,icondark:string?,pagename:string,tournament:string?,date:osdate,prizepoolindex:integer}[]
---@param options AchievementIconsOptions?
---@return string?
function Achievements.display(data, options)
	--read (default) options in case this function is accessed directly
	options = options or Achievements._readOptions()

	if not data or type(data[1]) ~= 'table' then
		return nil
	end

	Array.sortInPlaceBy(data, Operator.property('date'))

	return String.nilIfEmpty(table.concat(Array.map(data, function(item)
		return Achievements._displayIcon(item, options)
	end), NON_BREAKING_SPACE))
end

---Build the icon for a single entry
---@param item {icon:string?,icondark:string?,pagename:string,tournament:string?,date:osdate,prizepoolindex:integer}
---@param options AchievementIconsOptions
---@return string
function Achievements._displayIcon(item, options)
	if tonumber(item.prizepoolindex) ~= 1 and options.onlyForFirstPrizePoolOfPage then
		--can not return nil else Array.map breaks off
		return ''
	end

	options.adjustItem(item)

	return LeagueIcon.display{
		icon = Logic.emptyOr(item.icon, 'Gold.png'),
		iconDark = item.icondark,
		link = item.pagename,
		name = item.tournament,
		options = {noTemplate = options.noTemplate},
	}
end

return Achievements