Module:Move: Difference between revisions

From Wavu Wiki, the 🌊 wavy Tekken wiki
(Created page with "local p = require("Module:Move/functions") p.game = require("Module:Move/game").Tekken8 return p")
 
(Add error handling and reword some error messages. Add ns check to storeMove. Use Module:Game instead of Module:Move/game. Remove need for Template:MoveImpl. Add check for if inherited move is a clone or not. See edit history at Module:Move/sandbox.)
Line 1: Line 1:
local p = require("Module:Move/functions")
local O = require("Module:O")
p.game = require("Module:Move/game").Tekken8
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","reach","range",
"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
if args["range"] then
result["reach"] = args["range"]
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
return p

Revision as of 14:50, 29 February 2024

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.
	-- '&num;' 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","reach","range",
		"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
	if args["range"] then
		result["reach"] = args["range"]
	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