Module:Outfits
Appearance
Documentation for this module may be created at Module:Outfits/doc
require( 'strict' )
--- The Outfits gallery and acquisition table.
---
--- Outfits are the one item type that does not get an article each. There are
--- 127 of them, they differ only by gender, colour or headwear, and a stub per
--- variant said nothing a single table does not say better. So they live here,
--- on one page, and Items links its Outfits section here.
---
--- The data page is generated by .claude/tools/outfitsource.py, which sweeps
--- every MapData scene for vendors carrying an outfit and resolves the price
--- against the item table. Do not hand edit it; regenerate it.
---
--- Rarity and cost are deliberately NOT on the data page. They are joined from
--- the wiki's own copy of the item table, so there is one source of truth and
--- no second copy to drift.
---
--- **An outfit is not an item, and this page is about outfits.** The item table
--- counts wearable pieces, and it counts each of them twice, once Female and
--- once Male. Three things fold that back into outfits:
---
--- * a matching hair and suit are one outfit, so Builder Hair and Builder Suit
--- are the builder outfit and share a row and a picture, worn together;
--- * the Female and the Male item are one outfit with two looks, so they share
--- a row and get a column each;
--- * the slot word, the version number and the colour come out of the name and
--- into the Slot and Variation columns, so twelve bandanas read as one outfit
--- twelve ways rather than as twelve outfits.
---
--- A row is therefore `g` plus `v`, not `g` alone: `g` repeats. 224 items make
--- 127 rows. The gallery folds the colours in as well, through `p`, which is 61
--- cells.
local tableLua = require( 'Module:TableLua' )
local getItemThumbnail = require( 'Module:Item' ).getThumbnail
local getRarityBadge = require( 'Module:Rarity' )._main
local getRaritySortKey = require( 'Module:Rarity' ).getSortKey
local p = {}
local DATA = 'Module:Outfits/Data.json'
local EC2_ITEMS = 'Module:GameData/EvoCreo2/ItemTable.json'
local STYLES = 'Module:Outfits/styles.css'
--- How each source key from the data page reads.
---
--- The repeated wording lives here rather than on all ninety rows that share
--- it. `vendor` is the exception: its detail varies per outfit and is carried
--- on the row itself.
local SOURCES = {
vendor = { label = 'Vendor' },
shop = { label = 'In-game shop', note = 'Sold per colour' },
halloween = { label = 'Halloween event', note = 'Available for a limited time' },
winter = { label = 'Winter event', note = 'Available for a limited time' },
summer = { label = 'Summer event', note = 'Available for a limited time' },
dlc = { label = 'Alphite DLC', note = 'Sold as downloadable content' },
unknown = { label = 'Unconfirmed' }
}
--- The site wide "not applicable" marker.
local ABSENT = '—'
--- Which gender each data page code means.
local GENDERS = { f = 'female', m = 'male' }
--- Built once per page; the gallery and the table both want the same join.
local cache
--- @return table
local function getData()
if cache then
return cache
end
local items = {}
for _, item in ipairs( mw.loadJsonData( EC2_ITEMS ) or {} ) do
if item.name then
items[item.name] = item
end
end
cache = { rows = mw.loadJsonData( DATA ), items = items }
return cache
end
--- @return string
local function getStyles()
return mw.getCurrentFrame():extensionTag {
name = 'templatestyles', args = { src = STYLES }
}
end
--- Sentence case, leaving the rest of the name alone.
---
--- The game's own casing is irregular and the wiki follows it rather than
--- tidying it, so "azurite" stays lowercase past its first letter. Only that
--- letter is raised, because the name starts a cell.
---
--- @param name string
--- @return string
local function getLabel( name )
return mw.language.getContentLanguage():ucfirst( name )
end
--- Values in first seen order, without repeats.
---
--- @param values string[]
--- @return string[]
local function distinct( values )
local seen, out = {}, {}
for _, value in ipairs( values ) do
if value and not seen[value] then
seen[value] = true
table.insert( out, value )
end
end
return out
end
--- One entry per outfit variation, holding the rows of each gender.
---
--- Keyed on the outfit *and* its variation, because the outfit alone repeats:
--- Bandana is twelve rows, one per version and colour.
---
--- @return table[]
local function getGroups()
local groups, order = {}, {}
for _, row in ipairs( getData().rows ) do
local key = row.g .. '\1' .. ( row.v or '' )
if not groups[key] then
groups[key] = { name = row.g, variation = row.v, f = {}, m = {} }
table.insert( order, key )
end
table.insert( groups[key][row.x], row )
end
table.sort( order, function( a, b )
return string.lower( a ) < string.lower( b )
end )
local sorted = {}
for _, key in ipairs( order ) do
table.insert( sorted, groups[key] )
end
return sorted
end
--- One cell built from the female and the male side of an outfit.
---
--- Both sides are lists, because an outfit can be two pieces: the builder
--- outfit is a hat and a set of overalls, bought from two different shops, so
--- its Source cell has two lines per gender. What the two genders share is
--- stated once; where they differ, both are given and labelled. A shop stocks
--- its male and female sides from two different NPC nodes, so the vendor line
--- differs for most outfits even where the price does not.
---
--- Values are compared before they are rendered, so an identical pair costs one
--- render rather than two. That matters for the rarity badges.
---
--- @param female string[]
--- @param male string[]
--- @param render function|nil
--- @param separator string|nil
--- @return string
local function getPairCell( female, male, render, separator )
render = render or function( value ) return value end
separator = separator or '; '
local function join( values )
if #values == 0 then
return nil
end
local out = {}
for _, value in ipairs( values ) do
table.insert( out, render( value ) )
end
return table.concat( out, separator )
end
if #female > 0 and #male > 0
and table.concat( female, '\1' ) ~= table.concat( male, '\1' ) then
return string.format( '(F) %s<br>(M) %s', join( female ), join( male ) )
end
return join( female ) or join( male ) or ABSENT
end
--- The outfit as worn, at the icon's own size.
---
--- A one piece outfit is illustrated by its own item icon, the same file the
--- item tables and {{ItemLink}} use. An outfit whose hair and suit are worn
--- together cannot be: each of its item icons shows that piece over a default,
--- so half the outfit would be missing. Those get a composite of the whole
--- thing instead, flagged `c` on the data page and built by
--- `outfiticons.py seticons`.
---
--- The icons are cropped to the character rather than padded onto a shared
--- canvas, so they are asked for at native size: any width would either upscale
--- art MediaWiki refuses to upscale or resample pixel art that should not be.
---
--- @param group table
--- @param code string 'f' or 'm'
--- @return string
local function getIconCell( group, code )
local rows = group[code]
if #rows == 0 then
return ABSENT
end
local file
if rows[1].c then
file = string.format( '%s %s - outfit - evocreo2.png',
GENDERS[code], string.lower( group.name ) )
elseif #rows == 1 and rows[1].i then
file = getItemThumbnail( rows[1].n )
end
if not file then
return ABSENT
end
return string.format(
'<span class="cp-outfit-icon cp-image-pixelated">[[File:%s|link=]]</span>',
file )
end
--- @param rows table[]
--- @return string[]
local function getSlots( rows )
local slots = {}
for _, row in ipairs( rows ) do
table.insert( slots, row.s )
end
return distinct( slots )
end
--- @param rows table[]
--- @return string[]
local function getRarities( rows )
local items, names = getData().items, {}
for _, row in ipairs( rows ) do
local item = items[row.n]
if item and item.rarity then
table.insert( names, item.rarity.name )
end
end
return distinct( names )
end
--- The rarity cell, ordered by the tier rather than by the badge's name.
---
--- A badge states its own place in the scale, which is all a cell holding one
--- badge needs. This cell can hold two, a female badge and a male one with a
--- label in front of each, and a sorter left to read the cell would read those
--- labels too. So it states the key itself, which the sorter takes first.
---
--- Where the two genders differ, the lower tier is used. It is the one the cell
--- leads with, and no outfit's two halves are more than a tier apart.
---
--- @param group table
--- @return TableCellProps
local function getRarityCell( group )
local female, male = getRarities( group.f ), getRarities( group.m )
local sortValue
for _, names in ipairs( { female, male } ) do
for _, name in ipairs( names ) do
local key = getRaritySortKey( name )
if key and ( not sortValue or key < sortValue ) then
sortValue = key
end
end
end
return {
value = getPairCell( female, male, getRarityBadge, ' ' ),
sortValue = sortValue
}
end
--- What the whole outfit costs, as one number.
---
--- The item table prices a piece. A two piece outfit is only worn once both
--- halves are owned, so the row adds them: the builder outfit is the hat plus
--- the overalls. A one piece outfit is unchanged by this.
---
--- @param rows table[]
--- @return string[]
local function getCost( rows )
local items, total = getData().items, nil
for _, row in ipairs( rows ) do
local item = items[row.n]
if item and item.cost then
total = ( total or 0 ) + item.cost
end
end
return total and { tostring( total ) } or {}
end
--- @param rows table[]
--- @return string[]
local function getAvailability( rows )
local labels = {}
for _, row in ipairs( rows ) do
table.insert( labels, ( SOURCES[row.f] or SOURCES.unknown ).label )
end
return distinct( labels )
end
--- @param rows table[]
--- @return string[]
local function getSourceDetail( rows )
local details = {}
for _, row in ipairs( rows ) do
local source = SOURCES[row.f] or SOURCES.unknown
table.insert( details, row.d or source.note )
end
return distinct( details )
end
--- @return TableColumn[]
local function getColumns()
return {
{ id = 'outfit', label = 'Outfit' },
{ id = 'variation', label = 'Variation' },
{ id = 'male', label = 'Male' },
{ id = 'female', label = 'Female' },
{ id = 'slot', label = 'Slot' },
{ id = 'rarity', label = 'Rarity' },
{ id = 'cost', label = 'Base cost', textAlign = 'right' },
{ id = 'availability', label = 'Availability' },
{ id = 'source', label = 'Source' }
}
end
--- @return TableRow[]
local function getRows()
local rows = {}
for _, group in ipairs( getGroups() ) do
local female, male = group.f, group.m
table.insert( rows, {
getLabel( group.name ),
group.variation or ABSENT,
getIconCell( group, 'm' ),
getIconCell( group, 'f' ),
getPairCell( getSlots( female ), getSlots( male ), nil, ', ' ),
getRarityCell( group ),
getPairCell( getCost( female ), getCost( male ) ),
getPairCell( getAvailability( female ), getAvailability( male ),
nil, ', ' ),
getPairCell( getSourceDetail( female ), getSourceDetail( male ) )
} )
end
return rows
end
--- Every outfit family that has gallery art, smallest picture first.
---
--- The pictures are eleven different sizes, from one narrow figure at 43x81 to
--- a ten colour sheet at 540x156, and they are laid out in a wrapping flex row.
--- Alphabetical order alone puts a 540 wide sheet between two 43 wide ones and
--- leaves the row it breaks half empty. Ordering by area packs them: the single
--- figures, then the pairs, then the colour strips, then the big sheets, each
--- block alphabetical within itself.
---
--- `b` and `t` are the image's width and height, measured off the built files
--- by `outfiticons.gallery_sizes()`. `b` doubles as the has-art flag, because
--- an image that exists is never zero wide.
---
--- @return table[]
local function getFamilies()
local seen, families = {}, {}
for _, row in ipairs( getData().rows ) do
local family = row.p or row.g
if row.b and not seen[family] then
seen[family] = true
table.insert( families, {
name = family,
area = row.b * ( row.t or 1 )
} )
end
end
table.sort( families, function( a, b )
if a.area ~= b.area then
return a.area < b.area
end
return string.lower( a.name ) < string.lower( b.name )
end )
return families
end
--- The acquisition table
---
--- @return string
function p._table()
return getStyles() .. tableLua.render( {
caption = 'Outfits',
hideCaption = true, -- the section heading already labels the table
columns = getColumns(),
data = getRows(),
class = 'sortable'
} )
end
--- Every outfit that has art, as battle sprites
---
--- One cell per outfit: the male and the female figure stand side by side in a
--- single image, wearing the whole outfit, captioned with its name. Where an
--- outfit comes in colours the family is one image instead, a female row above
--- a male row, so the colours line up against each other.
---
--- This is a flex row rather than a <gallery>. See Module:Outfits/styles.css:
--- a gallery tag scales every cell to one height, and these images are not one
--- height, so most of them would be stretched by some fraction and pixel art
--- does not survive that. Here each draws at its own size, smallest first.
---
--- The markup is built without newlines on purpose. A blank line inside a div
--- would start a paragraph and break the layout out of its flex container.
---
--- @return string
function p._gallery()
local cells = {}
for _, family in ipairs( getFamilies() ) do
table.insert( cells, string.format(
'<div class="cp-outfit">[[File:%s - outfit - evocreo2.png|link=]]'
.. '<div class="cp-outfit-name">%s</div></div>',
string.lower( family.name ), getLabel( family.name ) ) )
end
return getStyles() .. '<div class="cp-outfit-gallery">'
.. table.concat( cells ) .. '</div>'
end
--- Count the data page, for checking a regenerated copy
---
--- `outfitsource.py data` prints the same breakdown as it writes the file. The
--- data page is transferred by hand, so rendering this and comparing the two
--- catches a row lost or altered on the way in. It also reports how many rows
--- failed to join the item table, which should always be zero.
---
--- @return string
function p.audit()
local data = getData()
local total, art, unjoined, counts, keys = 0, 0, 0, {}, {}
for _, row in ipairs( data.rows ) do
total = total + 1
if row.i then
art = art + 1
end
if not data.items[row.n] then
unjoined = unjoined + 1
end
if not counts[row.f] then
counts[row.f] = 0
table.insert( keys, row.f )
end
counts[row.f] = counts[row.f] + 1
end
table.sort( keys )
local parts = {}
for _, key in ipairs( keys ) do
table.insert( parts, string.format( '%s %d', key, counts[key] ) )
end
local both, single, worn, varied = 0, 0, 0, 0
for _, group in ipairs( getGroups() ) do
if #group.f > 0 and #group.m > 0 then
both = both + 1
else
single = single + 1
end
if #group.f > 1 or #group.m > 1 then
worn = worn + 1
end
if group.variation then
varied = varied + 1
end
end
local families = getFamilies()
local sizes = {}
for _, family in ipairs( families ) do
sizes[family.area] = true
end
local distinctSizes = 0
for _ in pairs( sizes ) do
distinctSizes = distinctSizes + 1
end
return string.format(
'%d items, %d with art, %d not in the item table: %s. '
.. '%d table rows, %d with both genders, %d with one, '
.. '%d a hair and suit worn together, %d with a variation. '
.. '%d gallery cells in %d sizes.',
total, art, unjoined, table.concat( parts, ', ' ),
both + single, both, single, worn, varied,
#families, distinctSizes )
end
--- Wikitext entry point for the table
---
--- @return string
function p.table()
return p._table()
end
--- Wikitext entry point for the gallery
---
--- @return string
function p.gallery()
return p._gallery()
end
return p