Module:Elderfield/Item
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