Module:Move

From Wavu Wiki, the 🌊 wavy Tekken wiki
Revision as of 09:43, 2 March 2024 by RogerDodger (talk | contribs) (fix p.inherit not saving due to `result` having invalid keys)

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

local O = require("Module:O")
local yesno = require("Module:Yesno")
local cargo = mw.ext.cargo
local p = {};
p.game = require("Module:Game").Tekken8

local fields = { 
	"id","parent","name","input","alt","alias",
	"target","damage","reach",
	"tracksLeft","tracksRight",
	"startup","recv","tot","crush","block","hit","ch",
	"notes","image","video"
}

--[[
	This variable should be used extremely sparingly,
	with *all* uses of it documented below.
	
	Its purpose is causing sandbox to behave differently to main,
	which is a source of potential unexpected bugs when moving from sandbox to main.
	
	Current uses:
	
	* In storeMove(), skip namespace check
	* Directly below, edit p.game.templates and p.game.tables to use sandbox versions
]]--
local sandbox = mw:getCurrentFrame():getTitle() == "Module:Move/sandbox"

if sandbox then
	for k, v in pairs(p.game.templates) do
		p.game.templates[k] = v .. "/sandbox"
	end
	for k, v in pairs(p.game.tables) do
		p.game.tables[k] = v .. "Sandbox"
	end
end

local 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 boolToCargoBool(b)
	assert(type(b) == 'boolean', 'wrong type: '..type(b))
	
	if b then
		return '1'
	else
		return nil
	end
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.
	-- '#' is also converted to '#' by store, so can't use that either
	if id:find('#') then
		return id:gsub('#', '${justFrame}')
	else
		return id
	end
end

local function parseMove(args)
	if not args.id then
		error("id is required", 0)
	end
	
	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)
	local frame = mw:getCurrentFrame()
	local callerNs = frame:preprocess("{{NAMESPACE:{{FULLPAGENAME}}}}")
	if sandbox or (callerNs == "") then
		local args = {'_table='..p.game.tables.Move}
		appendFrom(args, parseMove(argsUnparsed))
		frame:callParserFunction{ name = '#cargo_store', args = args }
	end
end

local function queryMove(id, args)
	local id = parseMoveId(id)
	
	local result = cargo.query(p.game.tables.Move, args, { where = "id = '"..id.."'" })[1]
	if not result then
		local msg =
			"Move with id = '" .. id .. "' not found. " ..
			"If you've added '" .. id .. "' in this edit, try saving the page twice. " ..
			"There is a known issue when looking up a move made in the same edit."
		error(msg, 0)
	end
	return result
end

local function getUnnamedArg(frame, i, errorMessage)
	local arg = assert(frame:getParent().args[i], errorMessage)
	return mw.text.trim(arg) -- whitespace is stripped only from named params
end

local function getNamedArgUnchecked(frame, name)
	return frame:getParent().args[name]
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 a 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
		assert(not visitedParents[moveId], 'Found parent cycle: ' .. table.concat(visitedParents.sorted, ', ') .. ', ' .. moveId)
		
		visitedParents[moveId] = true
		table.insert(visitedParents.sorted, moveId)
		
		local move = queryMove(moveId, 'parent,'..table.concat(columns, ','))
		moveId = move['parent']
		
		f(move)
	end
end

local function fillDamage(damageCell, punisher, character)
	damageCell:wikitext(punisher.damage)
	if punisher.mini or punisher.staple or punisher.wall or punisher.rage then
		local comboLinks = {}
		local insertComboLink = function(section, damage)
			if damage then
				table.insert(comboLinks, p.game.comboLink(character, 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, character)
	damageCell:wikitext(punisher.damage)
	if punisher.staple then
		damageCell:wikitext(' ('..p.game.comboLink(character, 'Back-turned opponent', punisher.staple)..')')
	end
	damageCell:wikitext(punisher.damageNote)
end


p.store = function(frame)
	return p._store(frame:getParent().args)
end

p._store = function(args)
	local ok, err = pcall( storeMove, args )
	if ok == false then
		-- args may be a direct frame.args object which is read-only,
		-- so make a writable copy first
		local argsReadOnly = args
		args = {}
		appendFrom(args, argsReadOnly)
		args["error"] = (args["error"] or "") .. "Error storing move: " .. err .. "\n\n"
	end
	return p._display(args)
end

p.query = function(frame)
	local id = getUnnamedArg(frame, 1, '1st unnamed param must be move id')
	local result = queryMove(id,table.concat(fields,","))
	result['range'] = result['reach']
	return p._display(result)
end

p.get = function(frame)
	local id = getUnnamedArg(frame, 1, '1st unnamed param must be move id')
	local field = getUnnamedArg(frame, 2, '2nd unnamed param must be move field')
	local recursive = getNamedArgUnchecked(frame, 'recursive') == 'true'
	return p._get(id, field, recursive)
end

p._get = function(id, field, recursive)
	if field == 'range' then
		field = 'reach'
	end
	
	if recursive then
		local result = ''
		forEachMove(id, {field}, function(move)
			result = move[field] .. result
		end)
		return result
	else
		local result = queryMove(id, field)
		return result[field]
	end
end

p.display = function(frame)
	return p._display( frame:getParent().args )
end

p._display = function(args)
	local frame = mw.getCurrentFrame()
	local leads = {}
	local ok, err = pcall( forEachMove, 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 ok then
		leads["error"] = (args["error"] or "") .. "Error querying parent: " .. err .. "\n\n"
	end
	
	if not next(leads) then
		return frame:expandTemplate{ title = p.game.templates.MoveDisplay, args = args }
	end
	
	local argsWithLeads = {}
	appendFrom(argsWithLeads, args)
	appendFrom(argsWithLeads, leads)
	return frame:expandTemplate{ title = p.game.templates.MoveDisplay, args = argsWithLeads }
end

p.inherit = function(frame)
	local args = frame:getParent().args
	local id = getUnnamedArg(frame, 1, '1st unnamed param must be move id')
	local ok, result = pcall(
		queryMove, 
		id, 
		table.concat(fields,",")..','..p.game.tables.Move..'._pageName=page'
	)
	if not ok then
		result = {
			error = "Error inheriting: " .. result .. "\n\n",
			page = ""
		}
	end
	
	local cloneFailFields = {
		"target","damage",
		"tracksLeft","tracksRight",
		"startup","recv","tot","crush","block","hit","ch",
		"notes"
	}
	local clonedFrom = '[['..result['page']..'#'..id..'|#'..id..']]'
	for _,v in ipairs(cloneFailFields) do
		if args[v] then
			clonedFrom = nil
		end
	end
	
	for k,v in pairs(args) do
		result[k] = v
	end
	result[1] = nil
	if args["range"] then
		result["reach"] = args["range"]
		result["range"] = nil
	end
	
	-- only store if a new id was provided - we don't want dupe entries
	if args["id"] then
		storeMove(result)
	end
	
	result['range'] = result['reach']
	result["clonedFrom"] = clonedFrom
	return p._display(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 = yesno(decoded.hard) or false
				
				table.insert(punishers, decoded)
			end
			punishersByType[type] = punishers
		end
	end
	local character = frame:getParent().args["char"]
		or frame:getParent().args["character"]
		or frame:getParent().args["fighter"]
	return p._punisherTable(frame, punishersByType, character)
end

p._punisherTable = function(frame, punishersByTypeUnsorted, character)
	-- mostly for Rage Arts, since their damage scales with HP left
	local function isDamageRange(damage)
		return type(damage) == 'string' and damage:match('^(%d+[-–]%d+)$') ~= nil
	end
	local function getRangeEnd(damage)
		local result = damage:match('^%d+[-–](%d+)$')
		return assert(tonumber(result), 'is not a number: '..result)
	end
	
	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 isRageArt = false
			local moveId = punisher.moveId
			forEachMove(moveId, {'startup','input','damage','hit','name'}, function(move)
				startup = move['startup']
				
				input = move['input'] .. input
				if isDamageRange(move['damage']) then
					damage = move['damage']
				else
					for k, v in string.gmatch(move['damage'] or "-1", "(%d+)") do
						damage = damage + tonumber(k)
					end
				end
				if not hit then
					hit = move['hit']
				end
				if move['name'] == 'Rage Art' then
					isRageArt = true
				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
			punisher.isRageArt = isRageArt
		end
	end
	local function store(punishers, type, frame)
		for _, punisher in ipairs(punishers) do
			local damage = punisher.damage
			if isDamageRange(damage) then
				damage = getRangeEnd(damage)
			end
			
			local rageCombo = punisher.rageCombo
			if punisher.isRageArt then
				rageCombo = maxOrNil(rageCombo, damage)
				if rageCombo >= damage then
					damage = 0
				end
			end
			frame:callParserFunction{ name = '#cargo_store', args = {
				'_table='..p.game.tables.Punisher,
		        type = type,
				enemy = punisher.enemy,
				damage = damage,
				combo = maxOrNil(punisher.staple, punisher.mini),
				wallCombo = punisher.wall,
				rageCombo = rageCombo,
				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, character)
			else
				fillDamage(row:tag('td'), punisher, character)
			end
			
			row:tag('td'):wikitext(punisher.frames, punisher.framesNote)
			
			rows = rows .. tostring(row)
		end
		
		displayArgs[punishers.type] = rows
	end
	
	return frame:expandTemplate{
		title = p.game.templates.PunisherTableDisplay,
		args = displayArgs
	}
end

--[[
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 = {
		...
	},
}
--]]
p._getCharacterPunishersByType = function(character)
	local punishers = cargo.query(
		p.game.tables.Punisher,
		'type,enemy,damage,combo,wallCombo,rageCombo,hard',
		{ where =
			p.game.tables.Punisher.."._pageName='"..p.game.punishersPage(character).."'"
			.. " 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 = yesno(p.hard) or false
		
		local damages = {
			wall = tonumber(p.wallCombo),
			rage = tonumber(p.rageCombo),
		}
		local regular = maxOrNil(tonumber(p.damage), tonumber(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

p.punishmentChart = function(frame)
	local character = frame:getParent().args[1]
	local punishersByType = p._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
	local character = frame:getParent().args["char"]
		or frame:getParent().args["character"]
		or frame:getParent().args["fighter"]
	return p._whiffPunisherTable(frame, punishers, character)
end

p._whiffPunisherTable = function(frame, punishers, character)
	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'] or "-1", "(%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():sub(1,1)
				if t == 'h' then
					punisher.hitbox = 'High'
				elseif t == 'm' or t == 's' then
					punisher.hitbox = 'Mid'
				elseif t == '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, character)
		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 = p.game.templates.WhiffPunisherTableDisplay,
		args = { rows }
	}
end

return p

--[[
	local mock = mw:getCurrentFrame():newChild{ title = "Template:Move/sandbox", args = {"Generic-1", id = "Lidia-1", range="2.04"} }:newChild{ title = "Module:Move/sandbox" };
	mw.log( p.inherit(mock) )
]]