Module:Buildables

From Dune: Awakening Community Wiki
Jump to navigation Jump to search

Module:Buildables

Renders listing tables and infoboxes for buildable sets and 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=Shaddam IV Portraits}}

Usage on a Buildable Set page:

{{#invoke:Buildables|list|Decorations|Miscellaneous|expanded=yes}}

Usage on a DLC page:

{{#invoke:Buildables|list|Structures|Water Shipper|scopeHeading=yes|source={{BASEPAGENAME}}}}

List all sets in a scope

Renders a grid of set infoboxes for every set discovered under the specified scope.

{{#invoke:Buildables|listSets|Structures}}

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, or decorations.
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 applied, the Source column is hidden unless at least one remaining piece has multiple sources.
scopeHeading (optional)
boolean
If true, the section heading shows the scope name (e.g. "Structures"), and the set name moves to the subtitle.
Defaults to false. The heading displays the set name and links to the corresponding set page.
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. Pass expanded=yes to keep the table open regardless of piece count, or expanded=no to force collapse.

listSets

1
string
The scope. One of placeables, structures, or decorations.

Set discovery is automatic via DPL — every subpage of Module:Buildables/data/<SCOPE>/ is treated as a set. Each set is rendered as an infobox showing its name, representative image, scope label, total piece count, and parent set link (if applicable).

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 from list 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.
  • Set infoboxes (in listSets) are rendered directly via Module:InfoboxLite in Lua, bypassing template expansion for performance.
  • Scope and set titles resolve via MessageBundle:Buildables; piece and category names resolve via the scope's own bundle.
  • For the placeables scope, 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 setName and/or pageLink. See Module:Buildables/data/doc for specifics.
  • Sets embedded within another set in-game declare a parentSet, which renders a "Found under" link in the subtitle. For the structures scope, sets without a parent still render a blank subtitle row to maintain consistent infobox height across the grid.

local p = {}

local common = require("Module:Common")
local infoboxLite = require("Module:InfoboxLite")

-- ---------- 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,
}

-- 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

-- 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.renderError("Unknown piece: <code>%s</code>", id)
end

-- ---------- Processing ----------

-- 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, common.renderError(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)

    return scopeSlug, bundleName, categoryOrder
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

-- 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 = common.resolveLangSubpage(data.pageLink or common.translate(bundleName, key, "en"), lang)

    return label, target
end

-- selects a representative image for a set to pass to Template:BuildableSetInfobox
local function resolveSetImage(data, categories, scopeSlug)
    local PREFERRED = {
        structures = { "foundation", "wall" },
        decorations = { "icon" },
    }

    local preferred = PREFERRED[scopeSlug]
    if preferred then
        for _, targetId in ipairs(preferred) do
            for _, cat in ipairs(categories) do
                for _, piece in ipairs(data[cat]) do
                    if piece.id == targetId then return piece.image end
                end
            end
        end
    end

    -- fall back to the first declared piece
    return data[categories[1]][1].image
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

-- builds the "Found under" subheader line with label and set icon
local function buildFoundUnder(scopeSlug, setSlug, data)
    local label, iconName
    local scopeLabel = common.translate("Labels", scopeSlug, "en")

    -- placeable and decoration sets all use their corresponding scope icon
    if scopeSlug ~= "structures" then
        label = common.translate("Labels", scopeSlug)
        iconName = scopeLabel

    elseif not data.parentSet then
        label = resolveSet(setSlug, data)
        iconName = scopeLabel .. " " .. resolveSet(setSlug, data, "en")

    else
        local parentData = loadSet(scopeSlug, data.parentSet, SCOPES[scopeSlug])
        if not parentData then
            return common.renderError("Unknown parent set `%s` in set `%s`", data.parentSet, setSlug)
        end

        local parentLabel, parentTarget = resolveSet(data.parentSet, parentData)
        iconName = scopeLabel .. " " .. resolveSet(data.parentSet, parentData, "en")

        if mw.title.getCurrentTitle().baseText == "Buildables" then
            label = string.format("[[#%s-%s|%s]]", scopeSlug, data.parentSet, parentLabel)
        else
            label = string.format("[[%s|%s]]", parentTarget, parentLabel)
        end
    end

    local icon = string.format("[[File:Icon - %s.png|class=inline-icon|link=]]", iconName)
    return "''" .. common.translate("Labels", "found_under") .. ": " .. label .. " " .. icon .. "''"
end

-- builds the displayed subtitle
local function buildSubtitle(secondaryLabel, foundUnder)
    local bolded = secondaryLabel and "'''" .. secondaryLabel .. "'''"

    if secondaryLabel and foundUnder then
        return secondaryLabel .. "<br>" .. foundUnder
    end

    return secondaryLabel or foundUnder
end

-- discovers all data sets for a given scope by querying subpages via DPL
local function discoverSets(frame, scopeSlug)
    local dpl_query = [[
        {{ #dpl: namespace = Module
            | titlematch = Buildables/data/%s/%%
            | nottitlematch = %%/doc
            | format = ,%%PAGE%%~
        }}
    ]]  -- extra % signs because need to escape for string.format()
    local dpl_results = frame:preprocess(string.format(dpl_query, scopeSlug))

    local sets = {}
    local prefix = "Module:Buildables/data/" .. scopeSlug .. "/"

    for page in mw.text.gsplit(dpl_results, "~") do
        page = common.trim(page)
        if page ~= "" then
            sets[#sets + 1] = common.slugify(page:sub(#prefix + 1))
        end
    end

    return sets
end

-- resolves data for a single set
local function resolveSetData(scopeSlug, setSlug, categoryOrder)
    local data, categories = loadSet(scopeSlug, setSlug, categoryOrder)
    if not data then return nil end

    local label, target = resolveSet(setSlug, data)
    local image = resolveSetImage(data, categories, scopeSlug)

    local count = 0
    for _, cat in ipairs(categories) do
        count = count + #data[cat]
    end

    return label, target, image, count, data
end

-- ---------- Rendering ----------

local function renderCost(cost)
    return common.renderBulletList(formatCostItems(cost))
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"))
    headerRow:tag("th"):wikitext(common.translate("Labels", "health"))
    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", "added_in_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("Buildables", "category-" .. piece.category)
        row:tag("td"):wikitext(displayCategory)

        row:tag("td"):attr("data-sort-value", piece.health):wikitext(common.formatNum(piece.health))
        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

local function renderSetInfobox(scopeSlug, setSlug, label, target, data, image, scopeLabel, count)
    local infoboxHTML = infoboxLite.infoboxTemplate({
        args = {
            above      = string.format("[[%s|%s]]", target, label),
            subheader  = buildFoundUnder(scopeSlug, setSlug, data),
            image1     = string.format("[[File:%s|link=%s]]", image, target),
            label1     = common.translate("Labels", "set_type"),
            data1      = scopeLabel,
            label2     = common.translate("Labels", "total_pieces"),
            data2      = "'''" .. common.formatNum(count) .. "'''",
        }
    })

    return mw.html.create("div")
        :addClass("infobox-wrapper")
        :attr("id", scopeSlug .. "-" .. setSlug)
        :wikitext(infoboxHTML)
end

-- ---------- Interface ----------

function p.list(frame)
    local args = frame.args

    local scopeSlug, bundleName, categoryOrder, err = resolveScope(args)
    if err then return err end

    local rawSet = common.trim(args[2])
    if rawSet == "" then return common.renderError("No set specified") end

    local setSlug = common.slugify(rawSet)

    local data, categories = loadSet(scopeSlug, setSlug, categoryOrder)
    if not data then return common.renderError("Unknown set: <code>%s</code>", rawSet) end

    local rawSource = args.source or ""
    local sourceFilter = ""

    if rawSource ~= "" then
        sourceFilter = common.slugify(rawSource)
        if sourceFilter == "" then return common.renderError("Invalid source: <code>%s</code>", rawSource) end
    end

    local _, ordered = flattenSet(data, categories, bundleName, setSlug)
    if #ordered == 0 then return common.renderError("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.renderError("No pieces matching source <code>%s</code>", rawSource) end

    local scopeHeading = common.resolveBooleanInput(args.scopeHeading) == true  -- set scopeHeading to false by default
    local setLabel, setTarget = resolveSet(setSlug, data)

    local title
    local subtitle
    if scopeHeading then
        title = common.translate("Labels", scopeSlug, nil, true)
        subtitle = buildSubtitle(setLabel, buildFoundUnder(scopeSlug, setSlug, data))
    else
        title = string.format("[[%s|%s]]", setTarget, setLabel)
        subtitle = buildSubtitle(common.translate("Labels", scopeSlug, nil, true), buildFoundUnder(scopeSlug, setSlug, data))
    end

    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

function p.listSets(frame)
    local args = frame.args

    local scopeSlug, bundleName, categoryOrder, err = resolveScope(args)
    if err then return err end

    local setList = discoverSets(frame, scopeSlug)
    if #setList == 0 then return common.renderError("No sets found for scope: <code>%s</code>", scopeSlug) end

    local scopeLabel = common.translate("Labels", scopeSlug)
    local wrapper = mw.html.create("div"):addClass("flex-container")

    for _, setSlug in ipairs(setList) do
        local label, target, image, count, parentLine = resolveSetData(scopeSlug, setSlug, categoryOrder)
        if not label then
            wrapper:wikitext(common.renderError("Unknown set: <code>%s</code>", setSlug))
        else
            wrapper:node(renderSetInfobox(scopeSlug, setSlug, label, target, parentLine, image, scopeLabel, count))
        end
    end

    return tostring(wrapper)
end

return p