Jump to content
Toggle menu
  • 69 articles
  • 104 files
  • 3 users
  • 353 edits
The Ninja Warriors Again RX Wiki
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Module:GameData

From The Ninja Warriors Again RX Wiki

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( "''", '&#39;&#39;' ) )
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