Module:Move7

From Wavu Wiki, the 🌊 wavy Tekken wiki
Revision as of 08:04, 20 September 2023 by Lume (talk | contribs)

Documentation for this module may be created at Module:Move7/doc

local O = require("Module:O")
local cargo = mw.ext.cargo

function maxOrNil(a, b)
	if a and b then
		return math.max(tonumber(a) or 0, tonumber(b) or 0)
	elseif a and not b then
		return a
	elseif b and not a then
		return b
	else
		return nil
	end
end

local function appendFrom(dst, src)
	for k, v in pairs(src) do
		dst[k] = v
	end
end

function cargoBoolToBool(str)
	assert(type(str) == 'string')
	
	return str == '1'
end

function boolToCargoBool(b)
	assert(type(b) == 'boolean')
	
	if b then
		return '1'
	else
		return nil
	end
end

local function moveNotFoundMsg(id)
	return "move with id = '" .. id .. "' not found"
end

local function parseMoveId(id)
	-- '#' cannot be used within cargo query
	-- see: https://www.mediawiki.org/wiki/Extension_talk:Cargo/Archive_January_to_February_2020#Error_in_%22where%22_parameter:_the_string_%22#%22_cannot_be_used_within_#cargo_query.
	if id:find('#') then
		return id:gsub('#', '#')
	else
		return id
	end
end

local function parseMove(args)
	assert(args.id)
	
	local result = {}
	appendFrom(result, args)
	
	result.id = parseMoveId(result.id)
	
	if result.range then
		result.reach = result.range
		result.range = nil
	end
	
	return result
end

local function storeMove(argsUnparsed, frame)
	local args = {'_table=Move7'}
	appendFrom(args, parseMove(argsUnparsed))
	
	frame:callParserFunction{ name = '#cargo_store', args = args }
end

local function queryMove(id, args)
	local id = parseMoveId(id)
	
	local result = cargo.query('Move7', args, { where = "id = '"..id.."'" })[1]
	assert(result, moveNotFoundMsg(id))
	return result
end

--[[
	Calls 'f' for every move in a string ending with 'moveId'.
	
	Parameters:
		moveId - id of the last move in a string.
		columns - list of columns to select from MoveDataCargoTest table.
			      Should not contain 'parent', it is selected by default.
		f - function that will be called for every move in a string, including last.
			Takes 1 parameter: move - table containing key-value pairs (column name -> column value).
	
	Throws:
		- if there is a cycle in a string (2 moves reference each other and etc.).
--]]
local function forEachMove(moveId, columns, f)
	local visitedParents = { sorted = {} }
	
	while moveId and moveId ~= '' do
		visitedParents[moveId] = true
		table.insert(visitedParents.sorted, moveId)
		
		local move = queryMove(moveId, 'parent,'..table.concat(columns, ','))

		moveId = move['parent']
		assert(not visitedParents[moveId], 'Found parent cycle: ' .. table.concat(visitedParents.sorted, ', ') .. ', ' .. moveId)
		
		f(move)
	end
end

local function fillDamage(damageCell, punisher)
	damageCell:wikitext(punisher.damage)
	if punisher.mini or punisher.staple or punisher.wall or punisher.rage then
		local character = assert(punisher.moveId:match('^(.-)%-'), 'moveId is invalid: ' .. punisher.moveId)
		
		local comboLinks = {}
		local insertComboLink = function(section, damage)
			if damage then
				table.insert(comboLinks, '[['..character..' combos#'..section..'|'..damage..']]')
			end
		end
		insertComboLink('Mini-combos', punisher.mini)
		insertComboLink('Staples', punisher.staple)
		insertComboLink('Wall', punisher.wall)
		insertComboLink('Rage', punisher.rage)
		
		damageCell:wikitext(' (' .. table.concat(comboLinks, '/') .. ')')
	end
	damageCell:wikitext(punisher.damageNote)
end

local function fillDamageOnBackturned(damageCell, punisher)
	damageCell:wikitext(punisher.damage)
	if punisher.staple then
		local character = assert(punisher.moveId:match('^(.-)%-'), 'moveId is invalid: ' .. punisher.moveId)
		damageCell:wikitext(' ([['..character..' combos#Back-turned opponent|'..punisher.staple..']])')
	end
	damageCell:wikitext(punisher.damageNote)
end


local p = {};

p.store = function(frame)
	storeMove(frame:getParent().args, frame)
end

p.display = function(frame)
	local leads = {}
	forEachMove(frame:getParent().args['parent'], {'input','target','damage'}, function(parent)
		parent['parent'] = nil
		
		for k, v in pairs(parent) do
			local leadName = k .. 'Lead'
			if not leads[leadName] then
				leads[leadName] = ''
			end
			
			leads[leadName] = v .. leads[leadName]
		end
	end)
	if not next(leads) then
		return frame:expandTemplate{ title = 'MoveDisplay7', args = frame:getParent().args }
	end
	
	local args = {}
	appendFrom(args, frame:getParent().args)
	appendFrom(args, leads)
	return frame:expandTemplate{ title = 'MoveDisplay7', args = args }
end

p.inherit = function(frame)
	local function getQueryId(frame)
		local id = assert(frame:getParent().args[1], '1st unnamed param must be move id')
		return mw.text.trim(id) -- whitespace is stripped only from named params
	end
	
	local id = getQueryId(frame)
	local result = queryMove(
		id,
		'id,parent,name,input,target,damage,reach,tracksLeft,tracksRight,startup,recv,tot,crush,block,hit,ch,notes'
			.. ',Move7._pageName=page'
	)
	
	for k, v in pairs(result) do
		local override = nil
		if k == 'reach' then
			override = frame:getParent().args['range']
		else
			override = frame:getParent().args[k]
		end
		if override then
			result[k] = override
		end
	end
	
	storeMove(result, frame)
	
	result['inheritedFrom'] = '[['..result['page']..'#'..id..'|'..id..']]'
	result['range'] = result['reach']
	return frame:expandTemplate{ title = 'MoveImpl7', args = result }
end

p.punisherTable = function(frame)
	local function notFoundMsg(type, i, name)
		return type .. ': required parameter punisher['..i..'].' .. name .. ' not found'
	end
	
	local punishersByType = {}
	for _, type in ipairs({
		'standing', 'crouching', 'backTurnedOpponent', 'groundedOpponent'
	}) do
		local punishersEncoded = frame:getParent().args[type]
		if punishersEncoded then
			local punishers = {}
			for i, v in ipairs(O.decode(punishersEncoded)) do
				local decoded = O.decode(v)
				assert(decoded.moveId, notFoundMsg(type, i, 'moveId'))
				
				if decoded.enemy then
					decoded.enemy = tonumber(decoded.enemy)
				end
				decoded.hard = decoded.hard == 'true'
				
				table.insert(punishers, decoded)
			end
			punishersByType[type] = punishers
		end
	end
	return p._punisherTable(frame, punishersByType)
end

p._punisherTable = function(frame, punishersByTypeUnsorted)
	local function queryMoveInfo(punishers)
		local function getFirstStartupFrame(s)
			assert(type(s) == 'string', 'actual type is ' .. type(s))
			
			local startup = s:match('i(%d+)')
			assert(startup, 'startup is invalid: ' .. s)
			
			return assert(tonumber(startup), 'not a number: ' .. startup)
		end
		
		for _, punisher in ipairs(punishers) do
			local startup = nil
			local input = ''
			local damage = 0
			local hit = nil
			local moveId = punisher.moveId
			forEachMove(moveId, {'startup','input','damage','hit'}, function(move)
				startup = move['startup']
				
				input = move['input'] .. input
				for k, v in string.gmatch(move['damage'], "(%d+)") do
					damage = damage + tonumber(k)
				end
				if not hit then
					hit = move['hit']
				end
			end)
			
			if not punisher.enemy then
				punisher.enemy = -getFirstStartupFrame(startup)
			end
			if not punisher.move then
				punisher.move = input
			end
			if not punisher.damage then
				punisher.damage = damage
			end
			if not punisher.frames then
				punisher.frames = hit
			end
		end
	end
	local function store(punishers, type, frame)
		for _, punisher in ipairs(punishers) do
			frame:callParserFunction{ name = '#cargo_store', args = {
				'_table=Punisher7',
		        type = type,
				enemy = punisher.enemy,
				damage = punisher.damage,
				combo = maxOrNil(punisher.staple, punisher.mini),
				wallCombo = punisher.wall,
				rageCombo = punisher.rage,
				hard = boolToCargoBool(punisher.hard),
			} }
		end
	end

	local function isSorted(t, pred)
		for i = 1, #t - 1, 1 do
			if not pred(t[i], t[i + 1]) then
				return false
			end
		end
		return true
	end
	local punishersByType = {}
	for type, punishers in pairs(punishersByTypeUnsorted) do
		queryMoveInfo(punishers)
		store(punishers, type, frame)
		
		if not isSorted(punishers, function(l, r) return l.enemy >= r.enemy end) then
			table.sort(punishers, function(l, r) return l.enemy > r.enemy end)
		end
		table.insert(punishersByType, { type = type, values = punishers })
	end
	
	local displayArgs = {}
	for _, punishers in ipairs(punishersByType) do
		local rowspansByDisadvantage = {}
		for _, punisher in ipairs(punishers.values) do
			local count = rowspansByDisadvantage[punisher.enemy]
			rowspansByDisadvantage[punisher.enemy] = (count or 0) + 1
		end
		
		local rows = ''
		for _, punisher in ipairs(punishers.values) do
			local row = mw.html.create('tr')
			
			local rowspan = rowspansByDisadvantage[punisher.enemy]
			if rowspan then
				row:tag('td'):attr('rowspan', rowspan)
					:wikitext(punisher.enemy, punisher.enemyNote)
				rowspansByDisadvantage[punisher.enemy] = nil
			end
			
			local moveCell = row:tag('td'):wikitext(punisher.move)
			if punisher.hard then
				moveCell:tag('sup'):wikitext('[hard]')
			end
			moveCell:wikitext(punisher.moveNote)
			
			if punishers.type == 'backTurnedOpponent' then
				fillDamageOnBackturned(row:tag('td'), punisher)
			else
				fillDamage(row:tag('td'), punisher)
			end
			
			row:tag('td'):wikitext(punisher.frames, punisher.framesNote)
			
			rows = rows .. tostring(row)
		end
		
		displayArgs[punishers.type] = rows
	end
	
	return frame:expandTemplate{
		title = 'PunisherTableDisplay7',
		args = displayArgs
	}
end

p.punishmentChart = function(frame)
	local character = frame:getParent().args[1]
		
	--[[
		Returns punishers by type. In each type punishers are sorted from fastest to slowest.
		Table structure: 
		{
			Standing = {
				{ enemy, damages = {
					regular = int,
					wall    = int,
					rage    = int,
					hard    = int,
				} },
				...
			},
			Crouching = {
				...
			},
		}
	--]]
	local function getCharacterPunishersByType(character)
		local punishers = cargo.query(
			'Punisher7',
			'type,enemy,damage,combo,wallCombo,rageCombo,hard',
			{ where =
				"Punisher7._pageName='"..character.." punishers'"
				.. " AND (type = 'standing' OR type = 'crouching')"
			}
		)
		
		local punishersByType = {}
		for _, p in ipairs(punishers) do
			local type = p.type
			local enemy = -tonumber(p.enemy)
			local hard = cargoBoolToBool(p.hard)
			
			local damages = {
				wall = tonumber(p.wallCombo),
				rage = tonumber(p.rageCombo),
			}
			local regular = maxOrNil(p.damage, p.combo)
			if hard then
				damages.hard = regular
			else
				damages.regular = regular
			end
			
			if not punishersByType[type] then
				punishersByType[type] = {}
			end
			
			local sameDisadvantagePunisherFound = false
			for _, p in ipairs(punishersByType[type]) do
				if p.enemy == enemy then
					sameDisadvantagePunisherFound = true
					
					for dmgType, dmg in pairs(damages) do
						p.damages[dmgType] = maxOrNil(p.damages[dmgType], dmg)
					end
				end
			end
			
			if not sameDisadvantagePunisherFound then
				table.insert(punishersByType[type], { enemy = enemy, damages = damages })
			end
		end
		for _, punishers in pairs(punishersByType) do
			local currentDisadvantages = {}
			for _, punisher in ipairs(punishers) do
				currentDisadvantages[punisher.enemy] = true
			end
			
			-- punishment chart must contain all punishers from -10 to -15
			for i = 10, 15, 1 do
				if not currentDisadvantages[i] then
					table.insert(punishers, { enemy = i, damages = {} })
				end
			end
			
			table.sort(punishers, function(l, r) return l.enemy < r.enemy end)
		end
		
		return punishersByType
	end
	
	local punishersByType = getCharacterPunishersByType(character)
	
	local span = 80
	for _, punishers in pairs(punishersByType) do
		for _, punisher in pairs(punishers) do
			for _, dmg in pairs(punisher.damages) do
				span = math.max(span, dmg or 0)
			end
		end
	end
	if span % 5 ~= 0 then
		span = span + 5 - (span % 5)
	end
	
	local scale = mw.html.create('div'):addClass('punishment-scale')
	for i = 0, span, 5 do
		local pip = mw.html.create('div')
			:addClass('punishment-scalepip')
			:css("width", string.format("%.2f%%", 100 * i / span))
			:node(mw.html.create('div')
				:addClass('punishment-piplabel')
				:wikitext(i))
		if i % 50 == 0 then
			pip:addClass('major')
		elseif i % 25 == 0 then
			pip:addClass('minor')
		end
		scale:node(pip)
	end
	
	-- Estimating this for now
	local median = {
		standing = {
			[10] = 24,
			[11] = 27,
			[12] = 30,
			[13] = 33,
			[14] = 36,
			[15] = 65,
			[23] = 77,
		},
		crouching = {
			[10] = 5,
			[11] = 20,
			[12] = 25,
			[13] = 30,
			[14] = 35,
			[15] = 65,
			[23] = 77,
		},
	}
	local root = mw.html.create('div'):addClass('punishment')
	for type, punishers in pairs(punishersByType) do
		local group = mw.html.create('div'):addClass('punishment-group')
		root:node(group)
		group:node(mw.html.create("div")
			:addClass('punishment-label')
			:wikitext(type:gsub("^%l", string.upper) .. " punishment"))
		local grid = mw.html.create('div'):addClass('punishment-grid')
		group:node(grid)
		
		local dmgClasses = {
			regular = 'bg-blue',
			rage = 'bg-purple',
			wall = 'bg-orange',
			hard = 'bg-green',
		}
		
		best = {}
		for _, punisher in ipairs(punishers) do
			-- don't carry rage/meter punishment, makes viz. look funky
			best[dmgClasses.rage] = nil
			for dmgType, dmg in pairs(punisher.damages) do
				local dmgClass = dmgClasses[dmgType]
				if not best[dmgClass] or best[dmgClass] < dmg then
					best[dmgClass] = dmg
				end
			end
			if best[dmgClasses.regular] then
				for k, v in pairs(best) do
					if k ~= dmgClasses.regular and best[k] <= best[dmgClasses.regular] then
						best[k] = nil
					end
				end
			end
			
			bestSortedKeys = {}
			for k, _ in pairs(best) do
				table.insert(bestSortedKeys, k)
			end
			-- Larger bars first so that smaller bars are actually visible
			table.sort(bestSortedKeys, function(a, b) return best[a] > best[b] end)
			
			grid:node(mw.html.create('div')
				:addClass('punishment-startup')
				:wikitext("-" .. punisher.enemy))
			local bars = mw.html.create('div'):addClass('punishment-bars')
			grid:node(bars)
			for i, k in ipairs(bestSortedKeys) do
				local class = k
				bars:node(mw.html.create('div')
					:addClass('punishment-bar')
					:addClass(class)
					:css("width", string.format("%.2f%%", 100 * best[k] / span))
					:wikitext(best[k]))
			end
			local median = median[type][punisher.enemy]
			if median then
				bars:node(mw.html.create('div')
					:addClass('punishment-median')
					:css('width', string.format("%.2f%%", 100 * median / span)))
			end
		end
		grid:node(mw.clone(scale))
	end
	
	return root
end

p.whiffPunisherTable = function(frame)
	local function notFoundMsg(i, name)
		return 'required parameter punisher['..i..'].' .. name .. ' not found'
	end
	
	local punishers = {}
	for i, v in ipairs(frame:getParent().args) do
		local decoded = O.decode(v)
		assert(decoded.moveId, notFoundMsg(i, 'moveId'))
		
		table.insert(punishers, decoded)
	end
	return p._whiffPunisherTable(frame, punishers)
end

p._whiffPunisherTable = function(frame, punishers)
	local function queryMoveInfo(punishers)
		local function getFirstStartupFrame(s)
			assert(type(s) == 'string', 'actual type is ' .. type(s))
			
			local startup = s:match('i(%d+)')
			assert(startup, 'startup is invalid: ' .. s)
			
			return assert(tonumber(startup), 'not a number: ' .. startup)
		end
		
		for _, punisher in ipairs(punishers) do
			local startup = nil
			local range = ''
			local input = ''
			local damage = 0
			local block = nil
			local target = ''
			local moveId = punisher.moveId
			forEachMove(moveId, {'startup','reach','input','damage','block','target'}, function(move)
				startup = move['startup']
				range = move['reach']
				
				input = move['input'] .. input
				for k, v in string.gmatch(move['damage'], "(%d+)") do
					damage = damage + tonumber(k)
				end
				if not block then
					block = move['block']
				end
				
				target = move['target']
			end)
			
			if not punisher.move then
				punisher.move = input
			end
			if not punisher.speed then
				punisher.speed = startup
			end
			if not punisher.range then
				punisher.range = range
			end
			if not punisher.damage then
				punisher.damage = damage
			end
			if not punisher.risk then
				punisher.risk = block
			end
			if not punisher.hitbox then
				local t = assert(target:match('^([hHmMlLsS])'), 'invalid target: ' .. target)
					:lower()
				if target == 'h' then
					punisher.hitbox = 'High'
				elseif target == 'm' or target == 's' then
					punisher.hitbox = 'Mid'
				elseif target == 'l' then
					punisher.hitbox = 'Low'
				end
			end
		end
	end
	
	queryMoveInfo(punishers)
	
	local rows = ''
	for _, punisher in ipairs(punishers) do
		local row = mw.html.create('tr')
		row:tag('td'):wikitext(punisher.move, punisher.moveNote)
		row:tag('td'):wikitext(punisher.speed, punisher.speedNote)
		row:tag('td'):wikitext(punisher.range, punisher.rangeNote)
		fillDamage(row:tag('td'), punisher)
		row:tag('td'):wikitext(punisher.risk, punisher.riskNote)
		row:tag('td'):wikitext(punisher.hitbox, punisher.hitboxNote)
		
		rows = rows .. tostring(row)
	end
	return frame:expandTemplate{
		title = 'WhiffPunisherTableDisplay7',
		args = { rows }
	}
end

return p