Module:Buildables
Jump to navigation
Jump to search
Module:Buildables
Renders listing tables and infoboxes for buildable pieces, across three scopes: structures, decorations, and placeables.
Datasets live at Module:Buildables/data/<SCOPE>/<SET_SLUG> - see Module:Buildables/data/doc for the schema.
Usage
List all pieces in a set
Renders a sortable wikitable of every piece in the specified set, wrapped in a collapsible section.
{{#invoke:Buildables|list|structures|CHOAM Facility}}
Filter to pieces from a single source:
{{#invoke:Buildables|list|decorations|Miscellaneous|source=Lost Harvest}}
Get a single piece
Renders the infobox for one piece, resolved by its display name.
{{#invoke:Buildables|get|structures|CHOAM Facility|Foundation}}
Parameters
list
- 1
- string
- The scope. One of
placeables,structures, ordecorations.
- 2
- string
- The set name. Slugified to locate both the dataset and its MessageBundle subpage.
- source (optional)
- string
- Restricts the table to pieces matching this source. Matching ignores the bundle prefix, so the plain source name is sufficient. When specified, the Source column is hidden, since every row would show the same value.
- scopeHeading (optional)
- boolean
- If true, the section heading shows the scope name (e.g. "Structures"), and the set name moves to the subtitle.
- Set to false to have the heading show the set name itself instead. It also links to the corresponding page, unless the dataset has
pageLinkspecified asfalse. - Defaults to true.
- heading (optional)
- number
- Heading level for the section title, 1 to 6. Specify 0 to omit the heading entirely.
- Defaults to 3.
- expanded (optional)
- boolean
- Overrides the automatic collapse behaviour.
get
- 1
- string
- The scope, as above.
- 2
- string
- The set name, as above.
- 3
- string
- The piece name. Slugified and matched against the piece IDs in the dataset.
Notes
- Output is wrapped in a shared collapsible section (see Module:Common). A count marker (
Total: <n>) appears between the heading and the content and stays visible regardless of collapse state. The section auto-collapses by default once the item count exceeds 6; passexpanded=yesto override. - Scope and set titles resolve via MessageBundle:Buildables; piece and category names resolve via the scope's own bundle.
- Displayed columns vary by scope: the Health column is hidden for decorations, and the Source and Version columns are hidden for placeables.
- For the
placeablesscope, the Name column links each piece to its own page. Structures and Decorations pieces are not individually linked in the list view. - The section heading's label and link target ordinarily come from the set's own key in MessageBundle:Buildables, but a dataset may override this via
setNameand/orpageLink. See Module:Buildables/data/doc for specifics. - Sets embedded within another set in-game declare a
parentSet, which renders a "Found under" link to the parent's section in the subtitle. - Dataset errors (duplicate piece IDs, unrecognised category keys) throw rather than degrade, since a corrupt set has nothing left to render.
local p = {}
local common = require("Module:Common")
-- ---------- Data ----------
-- define valid scopes and corresponding default order of categories when listing pieces in wikitables
local SCOPES = {
placeables = { "utility", "fabricator", "refinery", "storage" },
structures = { "structural", "wall", "wedge_wall", "roof", "incline", "special" },
decorations = { "lighting", "furniture", "deco", "wall_deco", "ornamental", "misc" },
}
-- structural keys to exclude when loading a set's data
local RESERVED_KEYS = {
defaults = true,
pageLink = true,
parentSet = true,
setName = true,
}
-- retrieve display name for a specified piece, checking the shared scope bundle first with fallback to the set-specific bundle
local function translatePiece(bundleName, setSlug, id, lang, silent)
local key = id .. "-name"
return common.translate(bundleName, key, lang, false, true)
or common.translate(bundleName .. "/" .. setSlug, key, lang, false, silent)
end
-- resolves display name for a specified piece, accounting for possible trailing numbers in the IDs
local function resolvePiece(bundleName, setSlug, id, lang, silent)
local baseId, number = common.splitTrailingNumber(id)
if baseId then
local baseName = translatePiece(bundleName, setSlug, baseId, lang, true)
if baseName then
return baseName .. " " .. number
end
end
local name = translatePiece(bundleName, setSlug, id, lang, true)
if name then return name end
if silent then return nil end
return common.inputError("Unknown piece: <code>%s</code>", id)
end
-- returns a sorted array of "Name (xN)" strings for a piece's building cost
local function formatCostItems(cost)
local items = {}
for material, quantity in pairs(cost) do
items[#items + 1] = string.format("%s (x%s)", material, common.formatNum(quantity))
end
table.sort(items)
return items
end
-- combines set default and piece-specific sources into a single array
local function mergeSources(defaultSource, pieceSource)
local merged = {}
local function add(source)
if not source then return end
if type(source) ~= "table" then source = { source } end
for _, token in ipairs(source) do
merged[#merged + 1] = token
end
end
add(defaultSource)
add(pieceSource)
return merged
end
-- resolves display label and link target for a set's heading
-- by default, the dataset's name is used to derive the MessageBundle key for both the display label and link target
-- if setName is specified, it points the heading at an existing key elsewhere, e.g. DLC:caladan_palace
-- if pageLink is specified, it overrides the heading target, e.g. CHOAM Decorations
local function resolveSet(setSlug, data, lang)
local bundleName, key = "Buildables", "set-" .. setSlug
if data.setName then
bundleName, key = common.parseSourceToken(data.setName)
if not bundleName then
error(string.format("Malformed setName token `%s` in set `%s`", data.setName, setSlug))
end
key = key .. "-name"
end
local label = common.translate(bundleName, key, lang)
local target
if data.pageLink ~= false then
target = common.resolveLangSubpage(data.pageLink or common.translate(bundleName, key, "en"), lang)
end
return label, target
end
-- loads dataset for the specified scope/set
local function loadSet(scopeSlug, setSlug, categoryOrder)
local ok, data = pcall(require, "Module:Buildables/data/" .. scopeSlug .. "/" .. setSlug)
if not ok then return nil end
local rank = {}
for i, slug in ipairs(categoryOrder) do
rank[slug] = i
end
local categories = {}
for categorySlug in pairs(data) do
if not RESERVED_KEYS[categorySlug] then
-- flag unknown keys in dataset
if not rank[categorySlug] then
error(string.format("Unrecognised category `%s` in set `%s`", categorySlug, setSlug))
end
categories[#categories + 1] = categorySlug
end
end
table.sort(categories, function(a, b)
return rank[a] < rank[b]
end)
return data, categories
end
-- flattens a two-level table keyed by category
-- returns a table keyed by id, and a parallel table preserving piece order within categories, as defined in the dataset
-- resolves some attributes against the set's `defaults` block where the piece doesn't specify its own
local function flattenSet(data, categories, bundleName, setSlug)
local byId = {}
local ordered = {}
local defaults = data.defaults or {}
for _, categorySlug in ipairs(categories) do
for _, piece in ipairs(data[categorySlug]) do
-- validate the dataset by flagging any duplicate IDs within the same file
if byId[piece.id] then
error(string.format("Duplicate piece ID `%s` in set `%s`", piece.id, setSlug))
end
piece.category = categorySlug
piece.displayName = resolvePiece(bundleName, setSlug, piece.id)
piece.health = piece.health or defaults.health
piece.source = mergeSources(defaults.source, piece.source)
piece.version = piece.version or defaults.version
byId[piece.id] = piece
ordered[#ordered + 1] = piece
end
end
return byId, ordered
end
-- resolves and validates specified scope and set from the invocation frame
local function resolveScope(args)
-- create local function to consolidate the repeated nil output
local function fail(msg, ...) return nil, nil, nil, nil, nil, common.inputError(msg, ...) end
local rawScope = common.trim(args[1])
if rawScope == "" then return fail("No scope specified") end
local scopeSlug = common.slugify(rawScope)
local categoryOrder = SCOPES[scopeSlug]
if not categoryOrder then return fail("Unknown scope: <code>%s</code>", rawScope) end
-- capitalise first letter of scopeSlug to derive the bundle names
-- simple solution because there are only 3 valid scopes, all one word
local bundleName = scopeSlug:sub(1, 1):upper() .. scopeSlug:sub(2)
local rawSet = common.trim(args[2])
if rawSet == "" then return fail("No set specified") end
local setSlug = common.slugify(rawSet)
local data, categories = loadSet(scopeSlug, setSlug, categoryOrder)
if not data then return fail("Unknown set: <code>%s</code>", rawSet) end
return scopeSlug, bundleName, setSlug, data, categories
end
-- ---------- Rendering ----------
local function renderCost(cost)
return common.renderBulletList(formatCostItems(cost))
end
-- builds the link to a set's parent, as an anchor when both are listed on the same page, otherwise as a page link
local function buildParentLink(scopeSlug, setSlug, data)
if not data.parentSet then return nil end
local parentData = loadSet(scopeSlug, data.parentSet, SCOPES[scopeSlug])
if not parentData then
error(string.format("Unknown parent set `%s` in set `%s`", data.parentSet, setSlug))
end
local parentLabel, parentTarget = resolveSet(data.parentSet, parentData)
if mw.title.getCurrentTitle().baseText == "Buildables" then
return string.format("[[#%s-%s|%s]]", scopeSlug, data.parentSet, parentLabel)
end
return string.format("[[%s|%s]]", parentTarget, parentLabel)
end
-- builds the displayed subtitle
-- parentLink is pre-resolved, since building it requires loading the parent's dataset
local function buildSubtitle(setLabel, parentLink)
local setName = setLabel and "'''" .. setLabel .. "'''"
local parentName = parentLink and "''" .. common.translate("Labels", "found_under") .. ": " .. parentLink .. "''"
if setName and parentName then
return string.format("%s<br>%s", setName, parentName)
end
return setName or parentName
end
local function renderTable(list, bundleName, scopeSlug, setSlug, showSource)
local table_ = common.newSortableTable()
local headerRow = table_:tag("tr")
headerRow:tag("th"):addClass("unsortable"):wikitext(common.translate("Labels", "icon"))
headerRow:tag("th"):wikitext(common.translate("Labels", "name"))
headerRow:tag("th"):wikitext(common.translate("Labels", "category"))
-- hide health column for decorations
if scopeSlug ~= "decorations" then
headerRow:tag("th"):wikitext(common.translate("Labels", "health"))
end
headerRow:tag("th"):addClass("unsortable"):wikitext(common.translate("Labels", "cost"))
-- hide source and version column for placeables
if scopeSlug ~= "placeables" then
if showSource then
headerRow:tag("th"):wikitext(common.translate("Labels", "source"))
end
headerRow:tag("th"):wikitext(common.translate("Labels", "version"))
end
for _, piece in ipairs(list) do
local row = table_:tag("tr")
row:tag("td"):css("min-width", "128px"):wikitext(string.format("[[File:%s|128px]]", piece.image))
local displayName = piece.displayName
if scopeSlug == "placeables" then
local englishName = resolvePiece(bundleName, setSlug, piece.id, "en", true)
if englishName then
displayName = "[[" .. englishName .. "|" .. displayName .. "]]"
end
end
row:tag("td"):css("font-weight", "bold"):wikitext(displayName)
local displayCategory = common.translate(bundleName, "category-" .. piece.category)
row:tag("td"):wikitext(displayCategory)
if scopeSlug ~= "decorations" then
row:tag("td")
:attr("data-sort-value", tostring(piece.health))
:wikitext(common.formatNum(piece.health))
end
row:tag("td"):css("text-align", "left"):node(renderCost(piece.cost))
if scopeSlug ~= "placeables" then
if showSource then
row:tag("td"):wikitext(common.renderSource(piece.source))
end
row:tag("td"):wikitext(common.renderVersion(piece.version))
end
end
return table_
end
-- ---------- Interface ----------
function p.list(frame)
local args = frame.args
local scopeSlug, bundleName, setSlug, data, categories, err = resolveScope(args)
if err then return err end
local rawSource = args.source or ""
local sourceFilter = ""
if rawSource ~= "" then
sourceFilter = common.slugify(rawSource)
if sourceFilter == "" then return common.inputError("Invalid source: <code>%s</code>", rawSource) end
end
local _, ordered = flattenSet(data, categories, bundleName, setSlug)
if #ordered == 0 then error(string.format("No pieces in dataset: %s", setSlug)) end
local filtered = {}
for _, piece in ipairs(ordered) do
local sourceMatch = sourceFilter == "" or common.sourceMatches(piece.source, sourceFilter)
if sourceMatch then filtered[#filtered + 1] = piece end
end
if #filtered == 0 then return common.inputError("No pieces matching source <code>%s</code>", rawSource) end
-- set scopeHeading to true by default
local scopeHeading = common.resolveBooleanInput(args.scopeHeading) ~= false
local setLabel, setTarget = resolveSet(setSlug, data)
local title
if scopeHeading then
title = common.translate("Buildables", "scope-" .. scopeSlug, nil, true)
elseif setTarget then
title = string.format("[[%s|%s]]", setTarget, setLabel)
else
title = setLabel
end
local subtitle = buildSubtitle(scopeHeading and setLabel or nil, buildParentLink(scopeSlug, setSlug, data))
local anchorId = scopeSlug .. "-" .. setSlug
local outer, content = common.newCollapsibleSection(title, #filtered, args.heading, args.expanded, subtitle, anchorId)
-- when a source filter is applied, only show the source column if at least one piece is multi-source
local showSource = sourceFilter == ""
if not showSource then
for _, piece in ipairs(filtered) do
if #piece.source > 1 then
showSource = true
break
end
end
end
local table_ = renderTable(filtered, bundleName, scopeSlug, setSlug, showSource)
content:node(table_)
return tostring(outer)
end
return p