Documentation for this module may be created at Module:Move/functions/doc
local O = require("Module:O")
local yesno = require("Module:Yesno")
local cargo = mw.ext.cargo
local p = {};
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 cargoBoolToBool(str)
assert(type(str) == 'string')
return str == 't'
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.
-- '#' 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)
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='..p.game.tables.Move}
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(p.game.tables.Move, args, { where = "id = '"..id.."'" })[1]
assert(result, moveNotFoundMsg(id))
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
--[[
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, p.game.getComboLink(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)
damageCell:wikitext(punisher.damage)
if punisher.staple then
local character = assert(punisher.moveId:match('^(.-)%-'), 'moveId is invalid: ' .. punisher.moveId)
damageCell:wikitext(' ('..p.game.getComboLink(character, 'Back-turned opponent', punisher.staple)..')')
end
damageCell:wikitext(punisher.damageNote)
end
p.store = function(frame)
storeMove(frame:getParent().args, frame)
end
p.query = function(frame)
local id = getUnnamedArg(frame, 1, '1st unnamed param must be move id')
local result = queryMove(
id,
'id,parent,name,input,target,damage,reach,tracksLeft,tracksRight,startup,recv,tot,crush,block,hit,ch,notes'
)
result['range'] = result['reach']
return frame:expandTemplate{ title = p.game.templates.MoveImpl, args = 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')
return p._get(id, field)
end
p._get = function(id, field)
if field == 'range' then
field = 'reach'
end
local result = queryMove(id, field)
return result[field]
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 = p.game.templates.MoveDisplay, args = frame:getParent().args }
end
local args = {}
appendFrom(args, frame:getParent().args)
appendFrom(args, leads)
return frame:expandTemplate{ title = p.game.templates.MoveDisplay, args = args }
end
p.inherit = function(frame)
local id = getUnnamedArg(frame, 1, '1st unnamed param must be move id')
local result = queryMove(
id,
'id,parent,name,input,target,damage,reach,tracksLeft,tracksRight,startup,recv,tot,crush,block,hit,ch,notes'
.. ','..p.game.tables.Move..'._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 = p.game.templates.MoveImpl, 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 = yesno(decoded.hard) or false
table.insert(punishers, decoded)
end
punishersByType[type] = punishers
end
end
return p._punisherTable(frame, punishersByType)
end
p._punisherTable = function(frame, punishersByTypeUnsorted)
-- 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'], "(%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)
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 = 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.getPunishersPage(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 = 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
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
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():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)
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