Module:Buildables

From Dune: Awakening Community Wiki
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|Observer 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 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 and the set name moves to the subtitle. Used when several sets are listed under one scope heading.
Defaults to false.
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; pass expanded=yes to override.
  • Generic column headers (Icon, Name, Category, Health, Cost, Source, Version) resolve via MessageBundle:Labels. 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.
  • 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,
    parentSet = 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.error("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

-- 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 = piece.source or defaults.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.error(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 displayed subtitle
local function buildSubtitle(scopeHeading, scopeSlug, setSlug, data)
    local setName, parentName

    if scopeHeading then
        setName = "'''" .. common.translate("Buildables", "set-" .. setSlug) .. "'''"
    end

    if data.parentSet then
        local parentSet = common.translate("Buildables", "set-" .. data.parentSet)
        local anchor = scopeSlug .. "-" .. data.parentSet
        parentName = "''" .. common.translate("Labels", "found_under") .. ": [[Buildables#" .. anchor .. "|" .. parentSet .. "]]''"
    end

    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

local function renderInfobox(bundleName, setSlug, piece)
    local englishName = resolvePiece(bundleName, setSlug, piece.id, "en")

    local displayCategory = common.translate(bundleName, "category-" .. piece.category)
    local englishCategory = common.translate(bundleName, "category-" .. piece.category, "en")

    local displaySet = common.translate("Buildables", "set-" .. setSlug)
    local englishSet = common.translate("Buildables", "set-" .. setSlug, "en")

    local out = {}
    out[#out + 1] = "{{BuildableInfobox"

    out[#out + 1] = "|title=" .. englishName
    out[#out + 1] = "|display_title=" .. piece.displayName
    out[#out + 1] = "|set=" .. englishSet
    out[#out + 1] = "|display_set=" .. displaySet
    out[#out + 1] = "|category=" .. englishCategory
    out[#out + 1] = "|display_category=" .. displayCategory
    out[#out + 1] = "|health=" .. common.formatNum(piece.health)
    out[#out + 1] = "|cost=" .. table.concat(formatCostItems(piece.cost), ",")
    out[#out + 1] = "|version=" .. common.renderVersion(piece.version)
    out[#out + 1] = "|source=" .. common.renderSource(piece.source)

    out[#out + 1] = "}}"
    return table.concat(out, "\n")
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 = common.trim(args.source)
    local sourceFilter = ""

    if rawSource ~= "" then
        sourceFilter = common.slugify(rawSource)
        if sourceFilter == "" then return common.error("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.error("No pieces matching source <code>%s</code>", rawSource) end

    local scopeHeading = common.isTruthy(args.scopeHeading)
    local title = scopeHeading
        and common.translate("Buildables", "scope-" .. scopeSlug, nil, true)
        or common.translate("Buildables", "set-" .. setSlug)

    local subtitle = buildSubtitle(scopeHeading, scopeSlug, setSlug, data)
    local anchorId = scopeSlug .. "-" .. setSlug

    local outer, content = common.newCollapsibleSection(title, #filtered, args.heading, args.expanded, subtitle, anchorId)

    local showSource = sourceFilter == ""
    local table_ = renderTable(filtered, bundleName, scopeSlug, setSlug, showSource)

    content:node(table_)

    return tostring(outer)
end

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

    local scopeSlug, bundleName, setSlug, data, categories, err = resolveScope(args)
    if err then return err end

    local byId, _ = flattenSet(data, categories, bundleName, setSlug)

    local rawName = common.trim(args[3])
    if rawName == "" then return common.error("No piece specified") end

    local piece = byId[common.slugify(rawName)]
    if not piece then return common.error("Unknown piece: <code>%s</code>", rawName) end

    return frame:preprocess(renderInfobox(bundleName, setSlug, piece))
end

return p