Module:Elderfield/Item

From Welcome to Elderfield Wiki
Jump to navigation Jump to search
-- Module:Elderfield/Item
--
-- Renders item facts from Module:Elderfield/Data/{Items,Weapons,Armor}.
-- Used by Template:Item data and the item index pages.

local u = require('Module:Elderfield/Utility')
local p = {}

local DOMAINS = { item = 'Items', weapon = 'Weapons', armor = 'Armor' }

--- Finds a record across all three item domains.
-- @return record, domainName
function p.find(id, kind)
	if kind and DOMAINS[kind] then
		return u.get(DOMAINS[kind], id), DOMAINS[kind]
	end
	for _, domain in pairs(DOMAINS) do
		local r = u.get(domain, id)
		if r then return r, domain end
	end
	return nil, nil
end

--- {{#invoke:Elderfield/Item|field|id=123|field=price}}
function p.field(frame)
	local id = u.arg(frame, 'id')
	local field = u.arg(frame, 'field', 'name')
	local rec = p.find(id, u.arg(frame, 'kind'))
	if not rec then return u.missing('Item', id) end
	local v = rec[field]
	if v == nil then return '' end
	if type(v) == 'table' then return u.join(v) end
	return u.text(frame, v)
end

--- {{#invoke:Elderfield/Item|infobox|id=123}} from {{Infobox food|id=123}}: the item's
-- infobox, built from the `infobox` fields on its record. A field written on the page wins,
-- so an editor can still correct one value. Each value is expanded before it is handed on:
-- expandTemplate passes arguments as they are, and the fields hold templates ({{Price|75}}).
-- Weapons and armor call it from their own templates with kind=weapon or kind=armor. They
-- are drawn by the /box copies of those templates, never the templates themselves: MediaWiki
-- stops a template expanding inside its own expansion ("template loop detected"). The titles
-- are written out so the publisher can see them.
local function drawInfobox(frame, kind, fields)
	if kind == 'weapon' then return frame:expandTemplate{ title = 'Infobox weapon/box', args = fields } end
	if kind == 'armor' then return frame:expandTemplate{ title = 'Infobox armor/box', args = fields } end
	return frame:expandTemplate{ title = 'Infobox item', args = fields }
end

function p.infobox(frame)
	local parent = frame:getParent()
	local args = parent and parent.args or {}
	local id = u.arg(frame, 'id') or args.id
	local kind = u.arg(frame, 'kind') or 'item'
	local rec = p.find(id, kind)
	if not rec or not rec.infobox then return u.missing('Infobox', id) end
	local fields = {}
	for k, v in pairs(rec.infobox) do fields[k] = v end
	for k, v in pairs(args) do
		if k ~= 'id' and mw.text.trim(v) ~= '' then fields[k] = v end
	end
	for k, v in pairs(fields) do fields[k] = frame:preprocess(v) end
	return drawInfobox(frame, kind, fields)
end

--- {{#invoke:Elderfield/Item|link|id=123}}  -> icon + linked name
function p.link(frame)
	local id = u.arg(frame, 'id')
	local rec = p.find(id, u.arg(frame, 'kind'))
	if not rec then return u.missing('Item', id) end
	local icon = u.arg(frame, 'icon', 'yes') ~= 'no' and u.icon(rec.icon) or ''
	return icon .. ' ' .. u.link(rec.title, rec.name)
end

--- {{#invoke:Elderfield/Item|tiers|id=123}}  -> the quality ladder table
function p.tiers(frame)
	local id = u.arg(frame, 'id')
	local rec = p.find(id, u.arg(frame, 'kind'))
	if not rec then return u.missing('Item', id) end
	if not rec.tiers or u.count(rec.tiers) < 2 then return '' end
	local rows = {}
	for _, t in ipairs(rec.tiers) do
		-- price is the database price; the game sells at half, as it does everywhere.
		rows[#rows + 1] = { u.icon(t.icon) .. ' ' .. (t.name or ('Quality ' .. t.tier)),
			u.gold(math.floor(t.price / 2)) }
	end
	return u.table(
		{ { label = 'Quality' }, { label = 'Sell value', align = 'right' } },
		rows, 'elderfield-tiers')
end

-- "the Slicer and the Blast Kiln": u.join is a comma list, and this sentence needs the
-- article and the conjunction to read as one.
local function energyItem(name)
	if not name then return '' end
	local entry = u.data('Index')[mw.ustring.lower(name)]
	local rec = entry and p.find(entry.id, entry.kind)
	if rec then return u.icon(rec.icon) .. ' ' .. u.link(rec.title, rec.name) end
	return name
end

local function andThe(list)
	local out = {}
	for _, n in ipairs(list or {}) do out[#out + 1] = 'the ' .. energyItem(n) end
	if #out == 0 then return '' end
	if #out == 1 then return out[1] end
	return table.concat(out, ', ', 1, #out - 1) .. ' and ' .. out[#out]
end

local function upperFirst(text)
	return text:sub(1, 1):upper() .. text:sub(2)
end

--- {{#invoke:Elderfield/Item|energy|id=2021}}  -> its place in the farm's energy system
-- One sentence rather than a table: the record is three or four facts and every one of them
-- reads better in a line of prose than in a two-column box.
function p.energy(frame)
	local id = u.arg(frame, 'id')
	local rec = p.find(id, u.arg(frame, 'kind'))
	if not rec then return u.missing('Item', id) end
	local e = rec.energy
	if not e then return '' end

	if e.role == 'producer' then
		local out
		if e.lasts == 'day' then
			out = 'Burns one ' .. (e.fuel and energyItem(e.fuel) or 'unit of fuel') .. ' for ' .. e.produces ..
				' Energy, which the farm keeps until the next morning.'
		else
			out = 'Produces ' .. e.produces .. ' Energy for as long as it stands.'
		end
		if e.needs and u.count(e.needs) > 0 then
			out = out .. ' Energy is what ' .. andThe(e.needs) .. ' check before they will run.'
		end
		return out
	end

	if e.role == 'source' then
		local out = 'Produces ' .. e.amount .. ' ' .. energyItem(e.makes) ..
			(e.per and (' after every ' .. e.per) or '') .. '.'
		local b = (e.burnedBy or {})[1]
		if b then
			out = out .. ' A ' .. energyItem(b.name) .. ' burns one for ' .. b.produces .. ' Energy.'
		end
		return out
	end

	if e.role == 'fuel' then
		local parts = {}
		for _, f in ipairs(e.producedBy or {}) do
			parts[#parts + 1] = 'a ' .. energyItem(f.name) .. ' gives ' .. f.amount .. ' ' .. (rec.name and energyItem(rec.name) or 'one') ..
				(f.per and (' after every ' .. f.per) or '')
		end
		for _, b in ipairs(e.burnedBy or {}) do
			parts[#parts + 1] = 'a ' .. energyItem(b.name) .. ' burns one for ' .. b.produces .. ' Energy' ..
				(b.lasts == 'day' and ', until the next morning' or '')
		end
		return upperFirst(u.join(parts, '; ')) .. '.'
	end
	return ''
end

-- The eight equipment parameters, in the engine's order, with the short names a stat table
-- wants. Spelled out rather than taken from the record so an item missing one still gets a
-- column in line with everything else.
local STAT_KEYS = { 'mhp', 'mmp', 'atk', 'def', 'mat', 'mdf', 'agi', 'luk' }
local STAT_LABEL = { mhp = 'HP', mmp = 'MP', atk = 'ATK', def = 'DEF',
	mat = 'MAT', mdf = 'MDF', agi = 'AGI', luk = 'LUK' }


-- Shared core comparison columns; weapons also display their attack element separately.
-- Less common traits
-- stay together in Other modifiers and effects; percentages never mix with flat stats.
local RATE_KEYS = { ['Hit Rate'] = 'hit', ['Critical Rate'] = 'crit', ['Recovery Effect'] = 'rec' }
local NUM_STYLE = 'text-align:center;white-space:nowrap;width:1%;vertical-align:top;padding:0.25em 0.35em'
local HEAD_STYLE = NUM_STYLE .. ';padding-right:20px'
local TEXT_STYLE = 'vertical-align:top;padding:0.25em 0.35em'
local function numberCell(value, percent)
    local n = tonumber(value) or 0
    local text = n == 0 and '—' or ((n > 0 and '+' or '') .. tostring(n) .. (percent and '%' or ''))
    return { text = text, sortKey = tostring(n) }
end
local function numericHeader(label, full)
    return { label = '<abbr title="' .. full .. '">' .. label .. '</abbr>',
        sortType = 'number', style = HEAD_STYLE, cellStyle = NUM_STYLE }
end
local function equipmentIndex(frame, list, domain)
    local withElement = domain == 'Weapons'
    local headers = { { label = 'Item', style = TEXT_STYLE, cellStyle = TEXT_STYLE },
        numericHeader('Lv', 'Item level') }
    local full = { mhp = 'Maximum HP: flat bonus', mmp = 'Maximum MP: flat bonus',
        atk = 'Attack: flat bonus', def = 'Defense: flat bonus', mat = 'Magic Attack: flat bonus',
        mdf = 'Magic Defense: flat bonus', agi = 'Agility: flat bonus', luk = 'Luck: flat bonus' }
    for _, k in ipairs(STAT_KEYS) do headers[#headers + 1] = numericHeader(STAT_LABEL[k], full[k]) end
    headers[#headers + 1] = numericHeader('Hit', 'Hit Rate modifier')
    headers[#headers + 1] = numericHeader('Crit', 'Critical Rate modifier')
    headers[#headers + 1] = numericHeader('REC', 'Recovery Effect modifier')
    if withElement then
        headers[#headers + 1] = { label = 'Element', style = HEAD_STYLE, cellStyle = NUM_STYLE }
    end
    headers[#headers + 1] = { label = 'Other modifiers and effects', style = TEXT_STYLE .. ';text-align:center', cellStyle = TEXT_STYLE .. ';text-align:center' }
    headers[#headers + 1] = { label = 'Source', style = TEXT_STYLE, cellStyle = TEXT_STYLE }
    local rows = {}
    for _, rec in ipairs(list) do
        local rates, counts, others, elements = {}, {}, {}, {}
        for _, effect in ipairs(rec.effects or {}) do
            if RATE_KEYS[effect.label] then counts[effect.label] = (counts[effect.label] or 0) + 1 end
        end
        for _, effect in ipairs(rec.effects or {}) do
            local key = RATE_KEYS[effect.label]
            local value = tostring(effect.value or '')
            local n = tonumber(value:match('^([+-]?%d+%.?%d*)%%$'))
            if withElement and effect.label == 'Attacks deal' then
                elements[#elements + 1] = value
            elseif key and n and counts[effect.label] == 1 then
                rates[key] = n
            else
                local label = effect.label
                if label == 'Physical Damage' then label = 'Physical damage taken' end
                if label == 'Magical Damage' then label = 'Magical damage taken' end
                others[#others + 1] = label .. ': ' .. value
            end
        end
        local description = u.text(frame, rec.description):gsub('\r\n', '\n'):gsub('\n', '<br>')
        local name = u.icon(rec.icon) .. ' ' .. u.link(rec.title, rec.name)
        local item = '<div style="min-width:10rem;max-width:18rem">' .. name
        if description ~= '' then
            item = item .. '<div style="display:block;font-size:0.9em;line-height:1.35;margin-top:0.2em">' .. description .. '</div>'
        end
        item = item .. '</div>'
        local row = { { text = item, sortKey = rec.name or '' },
            { text = tostring(rec.level or 0), sortKey = tostring(rec.level or 0) } }
        for _, k in ipairs(STAT_KEYS) do row[#row + 1] = numberCell((rec.stats or {})[k]) end
        row[#row + 1] = numberCell(rates.hit, true)
        row[#row + 1] = numberCell(rates.crit, true)
        row[#row + 1] = numberCell(rates.rec, true)
        if withElement then
            local element = #elements > 0 and table.concat(elements, ', ') or (rec.infobox or {}).attacks_deal or '—'
            row[#row + 1] = { text = u.text(frame, element), sortKey = element }
        end
        row[#row + 1] = '<div style="min-width:10rem;max-width:16rem;margin:0 auto">' ..
            (#others > 0 and u.text(frame, table.concat(others, '<br>')) or '—') .. '</div>'
        row[#row + 1] = '<div style="min-width:7rem;max-width:14rem">' .. p.routeList(rec.routes) .. '</div>'
        rows[#rows + 1] = row
    end
    local legend = '<p style="font-size:0.9em;margin:0.25em 0">Core stat columns show flat bonuses; Hit, Crit and REC show percentage modifiers. '
        .. '— means no modifier. Percentage bonuses to core stats appear under Other modifiers and effects.</p>'
    return legend .. '<div class="elderfield-equipment-scroll" role="region" aria-label="Equipment comparison table" tabindex="0" style="max-width:100%;overflow-x:auto">\n'
        .. u.table(headers, rows, 'elderfield-index elderfield-equipment-index', { style = 'width:auto;table-layout:auto;display:table;max-width:none' }) .. '\n</div>'
end

--- {{#invoke:Elderfield/Item|index|category=food}}  -> every item in a category
--
-- Also takes `slot=Head` to narrow an equipment domain to one equipment slot.
--
-- Two of the columns appear only when they have something to say, which is decided from the
-- rows rather than from the caller:
--
--   the stat columns, when anything in the list carries stats. Weapons and armour are chosen
--   by what they do to the wearer, and a list of them that does not say is a list of names.
--
--   the sell column, when anything in the list can be sold for more than nothing. Every
--   weapon and every piece of armour in this game is priced at zero, so on those two pages
--   the column was five hundred rows of "0g" — and if the game ever prices them, it comes
--   back by itself.
function p.index(frame)
	local category = u.arg(frame, 'category')
	local slot = u.arg(frame, 'slot')
	local domain = u.arg(frame, 'domain', 'Items')
	local data = u.data(domain)
	local list = {}
	for _, rec in pairs(data) do
		local keep = true
		if category then
			keep = false
			for _, c in ipairs(rec.categories or {}) do
				if c == category then keep = true break end
			end
		end
		if slot and rec.equipSlot ~= slot then keep = false end
		if keep and rec.status == 'player-facing' then list[#list + 1] = rec end
	end
	local withStats, withSell = false, false
	for _, rec in ipairs(list) do
		for _, k in ipairs(STAT_KEYS) do
			if (rec.stats or {})[k] then withStats = true end
		end
		if rec.effects then withStats = true end
		if (rec.sell or 0) > 0 then withSell = true end
	end

	-- Equipment reads as a progression, so it is ordered like one: item level first, and
	-- alphabetically within a level. Everything else stays in name order, which is the only
	-- order a list of food or furniture has.
	if withStats then
		table.sort(list, function(a, b)
			local la, lb = a.level or 0, b.level or 0
			if la ~= lb then return la < lb end
			return (a.name or '') < (b.name or '')
		end)
	else
		table.sort(list, function(a, b) return (a.name or '') < (b.name or '') end)
	end

	if domain == 'Weapons' or domain == 'Armor' then return equipmentIndex(frame, list, domain) end

	-- Other stats-bearing item lists retain their existing presentation.
	-- Equipment uses the common comparison layout above.
	--
	local headers = { { label = 'Item' }, { label = 'Description' } }
	if withStats then
		headers = {
			{ label = 'Item', width = '20%' },
			{ label = 'Lv', align = 'right', width = '5%' },
			{ label = 'Description', width = '33%' },
			{ label = 'Stats and effects', width = '21%' },
			{ label = 'Source', width = '21%' },
		}
	end
	if withSell then headers[#headers + 1] = { label = 'Sell value', align = 'right' } end

	local rows = {}
	for _, rec in ipairs(list) do
		local row
		if withStats then
			row = {
				u.icon(rec.icon) .. ' ' .. u.link(rec.title, rec.name),
				tostring(rec.level or 0),
				u.text(frame, rec.description),
				u.text(frame, p.statList(rec.stats, rec.effects)),
				p.routeList(rec.routes),
			}
		else
			row = { u.icon(rec.icon) .. ' ' .. u.link(rec.title, rec.name), u.text(frame, rec.description) }
		end
		if withSell then row[#row + 1] = rec.sellable == false and "''Cannot be sold''" or (rec.sell and u.gold(rec.sell) or '') end
		rows[#rows + 1] = row
	end
	return u.table(headers, rows, 'elderfield-index')
end

--- The stats a piece of equipment changes, one per line, in the engine's order, then what
-- else it does (hit rate, element, damage taken, skills), as its infobox's Effects says it.
-- Only the ones it changes: the game stores nothing for the rest, and a line saying a sword
-- adds zero luck is a line a reader has to read before discarding.
function p.statList(stats, effects)
	local out = {}
	for _, k in ipairs(STAT_KEYS) do
		local v = (stats or {})[k]
		if v then
			out[#out + 1] = STAT_LABEL[k] .. ' (' .. (v > 0 and '+' or '') .. tostring(v) .. ')'
		end
	end
	for _, x in ipairs(effects or {}) do
		-- A number reads like the stats above it; a word or a skill is said after a colon.
		if tostring(x.value):match('^[+-]?%d') then
			out[#out + 1] = x.label .. ' (' .. x.value .. ')'
		else
			out[#out + 1] = x.label .. ': ' .. x.value
		end
	end
	if #out == 0 then return 'No parameter or trait changes' end
	return table.concat(out, '<br>')
end

--- A short answer to where a piece of equipment comes from.
-- The full answer is on the item's own page; this is the three routes the hub has room for.
local function routeEntity(route)
	local label = route.title and u.link(route.title, route.name) or route.name
	local entry = route.title and u.data('Index')[mw.ustring.lower(route.title)]
	if entry and DOMAINS[entry.kind] then
		local rec = p.find(entry.id, entry.kind)
		if rec then return u.icon(rec.icon) .. ' ' .. label end
	end
	return label
end

function p.routeList(routes)
	local out = {}
	for _, r in ipairs(routes or {}) do
		if r.how == 'open' then out[#out + 1] = 'Open ' .. routeEntity(r)
		elseif r.how == 'craft' then out[#out + 1] = 'Craft at ' .. routeEntity(r)
		elseif r.how == 'found' then out[#out + 1] = 'Found in ' .. routeEntity(r)
		elseif r.how == 'event' then
			out[#out + 1] = 'From ' .. routeEntity(r) .. (r.once and " ''(once)''" or '')
		elseif r.how == 'loot' then out[#out + 1] = 'Drops from ' .. routeEntity(r)
		elseif r.how == 'mail' then out[#out + 1] = 'Mailed: ' .. routeEntity(r)
		elseif r.how == 'more' then out[#out + 1] = "''and " .. tostring(r.count) .. " more''"
		end
	end
	if #out == 0 then return 'See the individual item page for acquisition details.' end
	return table.concat(out, '<br>')
end

-- The item-level bands the game's own checks use, in the order a player meets them. `Any`
-- is last and is not a band: it is the Clear Gem, which takes an upgrade back off.
local BAND_ORDER = { '0-5', '5-10', '0-10', '10-15', 'Any' }
local BAND_LABEL = {
	['0-5'] = 'Item level 0–5',
	['5-10'] = 'Item level 5–10',
	['0-10'] = 'Item level 0–10',
	['10-15'] = 'Item level 10–15',
	['Any'] = 'Any item level',
}

local function bandRank(level)
	for i, b in ipairs(BAND_ORDER) do
		if b == level then return i end
	end
	return 99
end

--- {{#invoke:Elderfield/Item|upgrades|for=Weapon}}  -> the gems that fit that gear
--
-- Grouped by band rather than by tier word, because the tier words are not a ladder:
-- Chipped and Lesser both work on item levels 0-5, Greater reaches 0-10, and nothing in the
-- game converts one gem into another. What separates them is the size of the bonus.
--
-- The three facts each row needs are read off the item's own description when the data
-- module is built, so a gem whose numbers change says so by itself and a gem the game stops
-- calling an upgrade material leaves the table without anyone editing a list.
function p.upgrades(frame)
	local want = string.lower(u.arg(frame, 'for', 'Weapon'))
	local bands, levels = {}, {}
	for _, rec in pairs(u.data('Items')) do
		local up = rec.upgrade
		if up and rec.status == 'player-facing' then
			local slot = string.lower(up.slot or '')
			local keep
			if want == 'weapon' then
				keep = string.find(slot, 'weapon', 1, true) ~= nil
			else
				keep = slot ~= 'weapon'
			end
			if keep then
				local level = up.level or '?'
				if not bands[level] then
					bands[level] = {}
					levels[#levels + 1] = level
				end
				bands[level][#bands[level] + 1] = rec
			end
		end
	end
	if #levels == 0 then return '' end

	table.sort(levels, function(a, b)
		local ra, rb = bandRank(a), bandRank(b)
		if ra ~= rb then return ra < rb end
		return a < b
	end)

	local out = {}
	for _, level in ipairs(levels) do
		local list = bands[level]
		table.sort(list, function(a, b) return (a.name or '') < (b.name or '') end)
		local rows = {}
		for _, rec in ipairs(list) do
			rows[#rows + 1] = {
				u.icon(rec.icon) .. ' ' .. u.link(rec.title, rec.name),
				rec.upgrade.adds or '',
				rec.upgrade.slot or '',
			}
		end
		-- Widths declared so the bands line up with one another. Each band is its own table,
		-- and sized to their contents they staggered: the band holding one gem drew a narrow
		-- table beside a band holding fifteen.
		out[#out + 1] = '=== ' .. (BAND_LABEL[level] or ('Item level ' .. level)) .. ' ===\n' ..
			u.table({ { label = 'Gem', width = '30%' }, { label = 'Adds', width = '40%' },
				{ label = 'Fits', width = '30%' } }, rows, 'elderfield-upgrades')
	end
	return table.concat(out, '\n\n')
end

return p