(Created page with "local p = require("Module:Move/functions") p.game = require("Module:Move/game").Tekken8 return p") Â |
RogerDodger (talk | contribs) (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/ | local O = require("Module:O") | ||
p.game = | 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.
-- '#' 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