Module:GameData
From The Ninja Warriors Again RX Wiki
More actions
Documentation for this module may be created at Module:GameData/doc
-- Renders LongJump game data stored in Cargo: infoboxes, stat lines, lists of
-- related objects, and the record table shown on Data: pages. Which fields each
-- type has, and how they display, comes from Module:GameData/schema, which the
-- generator writes from the game's types file.
local p = {}
local schema = mw.loadData( 'Module:GameData/schema' )
-- The game's keyword colors, loaded on first use.
local keywordIndex
local function isWordChar( c )
return c ~= '' and c:match( '[%w_]' ) ~= nil
end
-- Colors keywords the way the game does (TextService.ColorizeText): one pass, longest
-- keyword first, whole words only where the keyword starts or ends with a word
-- character, written with the keyword's own capitalisation.
local function colorize( text )
if not keywordIndex then
keywordIndex = {}
for _, entry in ipairs( mw.loadData( 'Module:GameData/keywords' ) ) do
local first = entry[1]:sub( 1, 1 )
keywordIndex[first] = keywordIndex[first] or {}
table.insert( keywordIndex[first], entry )
end
end
-- Byte-wise lower case keeps positions aligned with the original text.
local lower = text:lower()
local out, i = {}, 1
while i <= #text do
local matched
for _, entry in ipairs( keywordIndex[lower:sub( i, i )] or {} ) do
local key = entry[1]
if lower:sub( i, i + #key - 1 ) == key
and not ( isWordChar( key:sub( 1, 1 ) ) and isWordChar( text:sub( i - 1, i - 1 ) ) )
and not ( isWordChar( key:sub( -1 ) ) and isWordChar( text:sub( i + #key, i + #key ) ) ) then
matched = entry
break
end
end
if matched then
out[#out + 1] = '<span class="kw" style="--kw:#' .. matched[3] .. '">' .. matched[2] .. '</span>'
i = i + #matched[1]
else
out[#out + 1] = text:sub( i, i )
i = i + 1
end
end
return table.concat( out )
end
-- A name as text. Page names can hold wiki markup: two apostrophes in a row
-- ("Kunoichi''") would start italics, so they are written as character references.
-- Names without them come back unchanged.
local function nameText( name )
return ( tostring( name ):gsub( "''", '''' ) )
end
-- A name inside an infobox tag. PortableInfobox decodes character references before
-- parsing its content, so the apostrophes are kept in nowiki instead.
local function infoboxText( name )
return ( tostring( name ):gsub( "''", "<nowiki>''</nowiki>" ) )
end
-- A link to a page, labelled with its name shown literally.
local function pageLink( title )
if tostring( title ):find( "''", 1, true ) then
return '[[' .. title .. '|' .. nameText( title ) .. ']]'
end
return '[[' .. title .. ']]'
end
local function quote( value )
return "'" .. ( tostring( value ):gsub( "'", "''" ) ) .. "'"
end
local function trim( s )
return ( s:gsub( '^%s+', '' ):gsub( '%s+$', '' ) )
end
local function split( value )
local items = {}
for item in tostring( value ):gmatch( '[^,]+' ) do
item = trim( item )
if item ~= '' then
items[#items + 1] = item
end
end
return items
end
local function fieldNames( t )
local names = {}
for _, f in ipairs( t.fields ) do
names[#names + 1] = f.name
end
return table.concat( names, ',' )
end
local function currentPage( frame )
local page = frame.args.page
if page and page ~= '' then
return page
end
return mw.title.getCurrentTitle().text
end
-- Formats one stored value for display, or returns nil when there is nothing to show.
local function formatValue( f, value, row )
if value == nil or value == '' then
return nil
end
if f.type == 'Boolean' then
-- Yes/no fields only show when true.
return ( value == '1' or value == 1 or value == true ) and 'Yes' or nil
end
-- Wording with a slot for the value ("+{0}% to All Resistances") shows "#" there.
value = ( tostring( value ):gsub( '{0}', '#' ) )
local items = f.list and split( value ) or { tostring( value ) }
local levels = f.levels and row and row[f.levels] and split( row[f.levels] ) or {}
for i, item in ipairs( items ) do
-- References to things that have no page in the wiki stay plain text.
if f.link and mw.title.new( item ) and mw.title.new( item ).exists then
item = pageLink( item )
end
if levels[i] then
item = item .. ' (level ' .. levels[i] .. ')'
end
items[i] = item .. ( f.suffix or '' )
end
return table.concat( items, ', ' )
end
-- Every query passes orderBy: Cargo 3.7's default order names list fields with the
-- full table name while the query uses a short alias, which MariaDB rejects.
local function fetchRow( t, page )
local rows = mw.ext.cargo.query( t.table, fieldNames( t ), {
where = 'wiki_page=' .. quote( page ),
orderBy = 'wiki_page',
limit = 1,
} )
return rows[1]
end
-- Reads the object's Data: page so MediaWiki records it as embedded in the current
-- page: when the export rewrites the Data: page, MediaWiki re-renders this page.
-- (Cargo records that dependency only for #cargo_query, not for Lua queries.)
local function dependOnData( t, page )
local title = mw.title.new( 'Data:' .. t.label .. '/' .. page )
if title then
title:getContent()
end
end
-- {{#invoke:GameData|infobox|type=Skill}} on an article.
function p.infobox( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local row = fetchRow( t, page )
if not row then
return "<div class='gamedata-missing'>No game data for this page.</div>"
end
local out = { '<infobox>', '<title><default>' .. infoboxText( row.name or page ) .. '</default></title>' }
if row.icon and row.icon ~= '' then
out[#out + 1] = '<image><default>' .. row.icon .. '</default></image>'
end
for _, f in ipairs( t.fields ) do
if f.label then
local value = formatValue( f, row[f.name], row )
if value then
out[#out + 1] = '<data><label>' .. f.label .. '</label><default>' .. value .. '</default></data>'
end
end
end
if row.game_version and row.game_version ~= '' then
out[#out + 1] = '<data><label>Game data from</label><default>' .. row.game_version .. '</default></data>'
end
out[#out + 1] = '</infobox>'
return frame:preprocess( table.concat( out ) ) .. '[[Category:' .. t.category .. ']]'
end
-- {{#invoke:GameData|statlines|type=Skill}}: the game's own effect lines for this page,
-- each linking to every other source of the same stat, under an "Effects" heading.
function p.statlines( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local rows = mw.ext.cargo.query( 'StatLine', 'stat,text,value,scaling,ordinal', {
where = 'wiki_page=' .. quote( page ),
orderBy = 'ordinal',
} )
if #rows == 0 then
return ''
end
local groups, order = {}, {}
for _, row in ipairs( rows ) do
local scaling = row.scaling or ''
if not groups[scaling] then
groups[scaling] = {}
order[#order + 1] = scaling
end
local value = tostring( tonumber( row.value ) or row.value ):gsub( '%.0$', '' )
local line = colorize( ( row.text:gsub( '{0}', ( value:gsub( '%%', '%%%%' ) ) ) ) )
-- Some of the game's lines are fixed text with no slot for the value. Show the
-- text as players see it, and the data's own value beside it unless it's a switch.
if not row.text:find( '{0}', 1, true ) and value ~= '1' then
line = line .. " <small>(game data value: " .. value .. ")</small>"
end
local url = mw.title.new( 'Special:Drilldown/StatLine' ):fullUrl( { stat = row.stat } )
table.insert( groups[scaling], '* <span class="plainlinks">[' .. url .. ' ' .. line .. ']</span>' )
end
-- Like the game's own skill tooltips: one-time effects first, then the per-point ones.
table.sort( order, function ( a, b )
return ( a == 'per point' and 1 or 0 ) < ( b == 'per point' and 1 or 0 )
end )
local out = { '== Effects ==' }
for _, scaling in ipairs( order ) do
if scaling ~= 'flat' and scaling ~= '' then
-- "Per point invested:" as in the game's skill tooltips; "Per pick:", "Per stack:".
local header = scaling:gsub( '^%l', string.upper ) .. ( scaling:find( 'point' ) and ' invested' or '' )
out[#out + 1] = "''" .. header .. ":''"
end
out[#out + 1] = table.concat( groups[scaling], '\n' )
end
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{#invoke:GameData|related|type=SkillTree}}: lists of other objects that refer
-- to this one, as declared in the schema ("Skills" on a skill tree page).
function p.related( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
local out = {}
for _, rel in ipairs( t.related or {} ) do
local other = schema.types[rel.type]
local field
for _, f in ipairs( other.fields ) do
if f.name == rel.field then
field = f
end
end
local rows
if field.list then
-- List values live in a helper table (<table>__<field>); HOLDS has the same
-- alias problem as the default order, so join the helper table directly.
local helper = other.table .. '__' .. rel.field
rows = mw.ext.cargo.query( other.table .. ',' .. helper, other.table .. '.wiki_page=wiki_page', {
join = other.table .. '._ID=' .. helper .. '._rowID',
where = helper .. '._value=' .. quote( page ),
orderBy = other.table .. '.wiki_page',
limit = 500,
} )
else
rows = mw.ext.cargo.query( other.table, 'wiki_page', {
where = rel.field .. '=' .. quote( page ),
orderBy = 'wiki_page',
limit = 500,
} )
end
if #rows > 0 then
out[#out + 1] = '== ' .. rel.label .. ' =='
for _, row in ipairs( rows ) do
out[#out + 1] = '* ' .. pageLink( row.wiki_page )
end
end
end
-- List fields shown as their own section rather than in the infobox.
local own
for _, f in ipairs( t.fields ) do
if f.section then
own = own or fetchRow( t, page )
local items = own and own[f.name] and own[f.name] ~= '' and split( own[f.name] ) or {}
if #items > 0 then
out[#out + 1] = '== ' .. f.section .. ' =='
for _, item in ipairs( items ) do
out[#out + 1] = '* ' .. ( f.link and pageLink( item ) or item )
end
end
end
end
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{#invoke:GameData|text|type=Ability|field=description}}: text written in the
-- game itself, quoted as players see it.
function p.text( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local row = fetchRow( t, page )
local value = row and row[frame.args.field or 'description']
if not value or value == '' then
return ''
end
local label = frame.args.label or t.text_label or 'In-game text'
return frame:preprocess( '<blockquote class="game-text">' .. colorize( value ) .. "<br><small>''" .. label .. "''</small></blockquote>" )
end
-- {{#invoke:GameData|intro|type=Skill}}: the type's intro sentence from the schema,
-- with {field} placeholders filled from the current data.
function p.intro( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local row = fetchRow( t, page )
if not row or not t.intro then
return ''
end
local fields = {}
for _, f in ipairs( t.fields ) do
fields[f.name] = f
end
local text = t.intro:gsub( '{([%w_]+)}', function ( name )
local value = fields[name] and formatValue( fields[name], row[name], row )
if value and name == 'name' then
value = nameText( value )
end
return value and ( value:gsub( '%%', '%%%%' ) ) or ''
end )
return frame:preprocess( text )
end
-- {{#invoke:GameData|gallery|type=Class}}: every object of a type as a grid of icons.
function p.gallery( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local rows = mw.ext.cargo.query( t.table, 'wiki_page,icon', { orderBy = 'wiki_page', limit = 500 } )
local out = { '<div class="gd-gallery">' }
for _, row in ipairs( rows ) do
local image = ( row.icon and row.icon ~= '' ) and ( '[[File:' .. row.icon .. '|96px|link=' .. row.wiki_page .. ']]<br>' ) or ''
out[#out + 1] = '<div class="gd-gallery-item">' .. image .. pageLink( row.wiki_page ) .. '</div>'
end
out[#out + 1] = '</div>'
return frame:preprocess( table.concat( out, '\n' ) )
end
local function rowsFor( tableName, fields, page )
return mw.ext.cargo.query( tableName, fields, {
where = 'wiki_page=' .. quote( page ),
orderBy = 'ordinal',
limit = 500,
} )
end
-- Puts a value range into a modifier's wording: "+{0}% to All Resistances" with "4-6"
-- gives "+(4-6)% to All Resistances".
local function withValue( text, value )
value = tostring( value or '' )
local shown = value:find( '-', 2, true ) and ( '(' .. value .. ')' ) or value
return ( text:gsub( '{0}', ( shown:gsub( '%%', '%%%%' ) ) ) )
end
-- {{Modifier tiers|type=Modifier}}: the modifier's tiers for each slot or crystal color,
-- as the in-game Mod Dictionary shows them (T1 is the best).
function p.tiers( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local row = fetchRow( t, page )
local tiers = rowsFor( 'ModifierTier', 'applies_to,tier,value,level,weight', page )
if not row or #tiers == 0 then
return ''
end
local out = { '== Tiers ==', '{| class="wikitable gd-tiers"', '! Tier !! Effect !! Required item level' }
local current
for _, tier in ipairs( tiers ) do
if tier.applies_to ~= current then
current = tier.applies_to
out[#out + 1] = '|-\n! colspan="3" | ' .. current .. ' (weight ' .. ( tier.weight or '' ) .. ')'
end
out[#out + 1] = '|-\n| T' .. tier.tier .. ' || ' .. colorize( withValue( row.text or '', tier.value ) ) .. ' || ' .. ( tier.level or '' )
end
out[#out + 1] = '|}'
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{Unique modifiers|type=UniqueItem}}: a unique's fixed modifiers, with their value
-- range on each difficulty.
function p.uniquemods( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local mods = rowsFor( 'UniqueModifier', 'text,implicit,mild,spicy,inferno,nightmare', page )
if #mods == 0 then
return ''
end
local out = { '== Modifiers ==', '{| class="wikitable gd-unique-mods"', '! Modifier !! Mild !! Spicy !! Inferno !! Nightmare' }
for _, mod in ipairs( mods ) do
local text = colorize( withValue( mod.text or '', '#' ) )
if mod.implicit == '1' then
text = text .. " <small>''(implicit)''</small>"
end
out[#out + 1] = '|-\n| ' .. text .. ' || ' .. ( mod.mild or '' ) .. ' || ' .. ( mod.spicy or '' )
.. ' || ' .. ( mod.inferno or '' ) .. ' || ' .. ( mod.nightmare or '' )
end
out[#out + 1] = '|}'
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{Skill item modifiers|type=Skill}}: the item modifiers that add levels to this skill.
function p.skillmods( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local mods = rowsFor( 'SkillItemModifier', 'slot,affix,tiers,weight', page )
if #mods == 0 then
return ''
end
local out = { '== Rolls on items ==', '{| class="wikitable"', '! Slot !! Affix !! Skill levels by tier !! Weight' }
for _, mod in ipairs( mods ) do
out[#out + 1] = '|-\n| ' .. ( mod.slot or '' ) .. ' || ' .. ( mod.affix or '' ) .. ' || ' .. ( mod.tiers or '' ) .. ' || ' .. ( mod.weight or '' )
end
out[#out + 1] = '|}'
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{Craft offers|type=Craft}}: every place that offers a craft, with its base costs.
function p.craftoffers( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local offers = rowsFor( 'CraftOffer',
'offered_by,location,description,gold,essence,crafting_tokens,ultimate_tokens,chaos_added,success_rate,rare_offer', page )
if #offers == 0 then
return ''
end
local out = { '== Where to craft it ==',
"''Base costs: the game raises them with the item's Chaos and level.''",
'{| class="wikitable"', '! Offered by !! Where !! Cost !! Chaos added !! Success rate !! Effect' }
for _, offer in ipairs( offers ) do
local cost = {}
for _, part in ipairs( { { 'gold', 'Gold' }, { 'essence', 'Nightmare Essence' },
{ 'crafting_tokens', 'Crafting Tokens' }, { 'ultimate_tokens', 'Ultimate Tokens' } } ) do
if offer[part[1]] and offer[part[1]] ~= '' and offer[part[1]] ~= '0' then
cost[#cost + 1] = offer[part[1]] .. ' ' .. part[2]
end
end
local who = offer.offered_by .. ( offer.rare_offer == '1' and " <small>''(rare offer)''</small>" or '' )
out[#out + 1] = '|-\n| ' .. who .. ' || ' .. ( offer.location or '' ) .. ' || ' .. ( #cost > 0 and table.concat( cost, ', ' ) or 'Free' )
.. ' || ' .. ( offer.chaos_added or '0' ) .. ' || ' .. ( offer.success_rate or '' ) .. '% || ' .. colorize( offer.description or '' )
end
out[#out + 1] = '|}'
return frame:preprocess( table.concat( out, '\n' ) )
end
-- One cell of a table view: links to pages that exist, game keyword colors on request.
local function cell( column, def, value )
if value == nil or value == '' then
return ''
end
if def and def.type == 'Boolean' then
return ( value == '1' or value == 1 ) and 'Yes' or ''
end
value = tostring( value )
if column.colorize then
value = colorize( value )
end
local link = column.link or ( def and def.link )
if ( link or column.field == 'wiki_page' ) and mw.title.new( value ) and mw.title.new( value ).exists then
value = pageLink( value )
end
return value .. ( column.suffix or '' )
end
-- {{Data table|type=Monster|view=MonsterSpawn}}: a row table as declared in the type's
-- blocks. A view shows the page's own rows, or with "match" the rows of other pages
-- whose given field names this page. "group" starts a header row whenever its value changes.
function p.datatable( frame )
local t = schema.types[frame.args.type]
local v = t and t.views and t.views[frame.args.view]
if not v then
return ''
end
local page = currentPage( frame )
dependOnData( t, page )
local defs = {}
for _, f in ipairs( schema.tables[v.table] or {} ) do
defs[f.name] = f
end
local names, seen = {}, {}
local function want( name )
if name and not seen[name] then
seen[name] = true
names[#names + 1] = name
end
end
for _, c in ipairs( v.columns ) do
want( c.field )
end
want( v.group )
local match = v.match or 'wiki_page'
local rows = mw.ext.cargo.query( v.table, table.concat( names, ',' ), {
where = match .. '=' .. quote( page ),
orderBy = v.order or ( match == 'wiki_page' and 'ordinal' or 'wiki_page,ordinal' ),
limit = 5000,
} )
if #rows == 0 then
return ''
end
local out = { '== ' .. v.heading .. ' ==' }
if v.note then
out[#out + 1] = "''" .. v.note .. "''"
end
local header = {}
for _, c in ipairs( v.columns ) do
header[#header + 1] = c.label
end
out[#out + 1] = '{| class="wikitable' .. ( v.group and '' or ' sortable' ) .. '"'
out[#out + 1] = '! ' .. table.concat( header, ' !! ' )
local current
for _, row in ipairs( rows ) do
if v.group and row[v.group] ~= current then
current = row[v.group]
out[#out + 1] = '|-\n! colspan="' .. #v.columns .. '" | ' .. cell( { field = v.group }, defs[v.group], current )
end
local cells = {}
for _, c in ipairs( v.columns ) do
cells[#cells + 1] = cell( c, defs[c.field], row[c.field] )
end
out[#out + 1] = '|-\n| ' .. table.concat( cells, ' || ' )
end
out[#out + 1] = '|}'
return frame:preprocess( table.concat( out, '\n' ) )
end
-- {{#invoke:GameData|skillmods_all}}: every "+N to <skill>" item modifier, by slot.
function p.skillmods_all( frame )
local mods = mw.ext.cargo.query( 'SkillItemModifier', 'wiki_page,slot,affix,tiers', {
orderBy = 'slot,wiki_page',
limit = 5000,
} )
local out, current = {}, nil
for _, mod in ipairs( mods ) do
if mod.slot ~= current then
if current then
out[#out + 1] = '|}'
end
current = mod.slot
out[#out + 1] = '== ' .. current .. ' =='
out[#out + 1] = '{| class="wikitable sortable"\n! Skill !! Affix !! Skill levels by tier'
end
out[#out + 1] = '|-\n| ' .. pageLink( mod.wiki_page ) .. ' || ' .. ( mod.affix or '' ) .. ' || ' .. ( mod.tiers or '' )
end
if current then
out[#out + 1] = '|}'
end
return frame:preprocess( table.concat( out, '\n' ) )
end
-- Used by the "<Label> data" templates: shows the stored record on its Data: page.
function p.record( frame )
local t = schema.types[frame.args.type]
if not t then
return ''
end
local args = frame:getParent().args
local out = {
"''Game data owned by the LongJump export. Edit the game, not this page.''",
'{| class="wikitable"',
}
for _, f in ipairs( t.fields ) do
local value = args[f.name]
if value and value ~= '' then
out[#out + 1] = '|-\n! ' .. f.name .. '\n| ' .. ( formatValue( f, value, args ) or '' )
end
end
out[#out + 1] = '|}'
if args.wiki_page and args.wiki_page ~= '' then
out[#out + 1] = 'Article: ' .. pageLink( args.wiki_page )
end
return frame:preprocess( table.concat( out, '\n' ) )
end
return p