Jump to content

Module:ModuleView

From The Perfect Tower II
Revision as of 21:59, 6 October 2026 by Speedybrain (talk | contribs) ({{Module row}})
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Shows the game's modules from the game's own data, for {{Module}} and {{Module row}}. Used on Modules and the region pages.

The data comes live from the game's browser build on the game's CDN. The server reads it, and Lua gets it through the library mw.ext.gameData: getVersion(), getModule(key) (by id or by name), getModules(category) and getCategories(). It updates with each game build, without edits on the wiki. Text from the game is escaped. The styles are in Template:Module/styles.css (TemplateStyles).

Entry points

  • main(frame), for {{Module}}: id or name. text replaces the shown name. Names link to the module database on perfecttower2.com, or with link to a wiki page. view is one of:
    • icon (default): the name in the text, with a category swatch and a tooltip (category, type, max tier);
    • compact: a card with the short description;
    • full: a panel with the description, max tier, levels per tier, prices, target, tags and game version (numbers through Module:FormatNumber).
  • row(frame), for {{Module row}}: one table row. layout picks the columns: standard (default; with location, condition, formula), infinity (adds maxtier, gridlocation, gridcoordinates, unlockcost) or region (type, name and condition). note adds the wiki's note under the description.
  • _view(m, args) and _row(m, args, key, available): the same from a module table, for tests.

Values the game fills in at runtime show in brackets, such as [chance]; a parameter with that name (chance=20%) puts the wiki's value in its place. The templates' own parameter names (id, view, formula and so on) don't work for this.

An id or name the game doesn't have shows "Unknown module" and adds Category:Pages with unknown modules, except in the Template and Module namespaces. When the game data can't be read, the id or name shows as plain text.



--[[----------------------------------------------------------------------------
    Module:ModuleView
        The game's modules for {{Module|id=…}}, from the browser build's data on
        gamescdn (mw.ext.gameData, read by the server). They update with each game
        build, without edits here.
        Views: icon (the name with its category color, inline), compact (a card)
        and full (a panel with description, numbers and tags).
        Text from the game is escaped. Names link to the module database on
        perfecttower2.com, or to a wiki page with link=.
----------------------------------------------------------------------------]]--
local p = {}

local STYLES = 'Template:Module/styles.css'
local UNKNOWN_CATEGORY = 'Pages with unknown modules'

local function esc(text)
    return mw.text.nowiki(text or '')
end

local function nonEmpty(value)
    if value and mw.text.trim(value) ~= '' then
        return mw.text.trim(value)
    end
end

-- "Offensive · Passive · Max tier 250"
local function metaLine(m)
    return string.format('%s · %s · %s', m.categoryName, m.type,
        m.maxTier and ('Max tier ' .. m.maxTier) or 'No upgrades')
end

local function nameLink(m, args)
    local label = esc(nonEmpty(args.text) or m.name)
    local page = nonEmpty(args.link)
    if page then
        return string.format('[[%s|%s]]', page, label)
    end
    return string.format('[%s %s]', m.url, label)
end

local function swatch()
    return mw.html.create('span'):addClass('tpt-module-swatch'):attr('aria-hidden', 'true')
end

-- Parameters of the templates themselves; never taken as values for the game's placeholders.
local RESERVED = {
    id = true, name = true, view = true, text = true, link = true, layout = true, note = true,
    location = true, condition = true, formula = true,
    maxtier = true, gridlocation = true, gridcoordinates = true, unlockcost = true,
}

-- Game text, escaped. Values the game fills in at runtime show as [chance], [duration] and so on;
-- a parameter of that name (chance=20%) puts the wiki's value in their place.
local function withValues(text, args)
    local out, pos = {}, 1
    for from, name, to in mw.ustring.gmatch(text or '', '()%[([%a_][%w_.]*)%]()') do
        out[#out + 1] = esc(mw.ustring.sub(text, pos, from - 1))
        out[#out + 1] = (not RESERVED[name] and nonEmpty(args[name])) or esc('[' .. name .. ']')
        pos = to
    end
    out[#out + 1] = esc(mw.ustring.sub(text or '', pos))
    return table.concat(out)
end

-- The description's main text: lines with superscript kept apart.
local function descriptionText(description, args)
    local lines = {}
    for _, line in ipairs(description.lines or {}) do
        local parts = {}
        for _, part in ipairs(line) do
            local text = withValues(part.text, args)
            parts[#parts + 1] = part.sup and ('<sup>' .. text .. '</sup>') or text
        end
        lines[#lines + 1] = table.concat(parts)
    end
    return table.concat(lines, '<br>')
end

local function iconView(m, args)
    return mw.html.create('span')
        :addClass('wiki-module wiki-module--icon tpt-module-' .. m.category)
        :attr('title', metaLine(m))
        :node(swatch())
        :wikitext(' ' .. nameLink(m, args))
end

local function compactView(m, args)
    local card = mw.html.create('div')
        :addClass('wiki-module wiki-module--compact tpt-module-' .. m.category)
    card:tag('div'):addClass('wiki-module__head')
        :node(swatch())
        :tag('span'):addClass('wiki-module__name'):wikitext(nameLink(m, args))
    card:tag('div'):addClass('wiki-module__meta'):wikitext(esc(metaLine(m)))
    if m.description.short ~= '' then
        card:tag('div'):addClass('wiki-module__desc'):wikitext(withValues(m.description.short, args))
    end
    return card
end

local function stat(parent, label, value)
    local cell = parent:tag('div'):addClass('wiki-module__stat')
    cell:tag('span'):addClass('tpt-label'):wikitext(label)
    cell:tag('span'):addClass('wiki-module__value'):wikitext(value)
end

local function fullView(m, args)
    local number = require('Module:FormatNumber').format
    local panel = mw.html.create('div')
        :addClass('wiki-module wiki-module--full tpt-module-' .. m.category)
    local top = panel:tag('div'):addClass('wiki-module__top')
    top:node(swatch())
    top:tag('span'):addClass('wiki-module__name'):wikitext(esc(nonEmpty(args.text) or m.name))
    top:tag('code'):addClass('wiki-module__id'):wikitext(esc(m.id))
    local tags = top:tag('span'):addClass('wiki-module__tags')
    tags:tag('span'):addClass('tpt-tag'):wikitext(esc(m.categoryName))
    tags:tag('span'):addClass('tpt-tag'):wikitext(esc(m.type))

    local body = panel:tag('div'):addClass('wiki-module__body')
    local desc = body:tag('div'):addClass('wiki-module__desc'):wikitext(descriptionText(m.description, args))
    if m.description.warning ~= '' then
        desc:tag('span'):addClass('wiki-module__warning'):wikitext(withValues(m.description.warning, args))
    end
    if m.description.note ~= '' then
        desc:tag('span'):addClass('wiki-module__credit'):wikitext(withValues(m.description.note, args))
    end

    local stats = body:tag('div'):addClass('wiki-module__stats')
    stat(stats, 'Max tier', m.maxTier and number(m.maxTier) or 'No upgrades')
    stat(stats, 'Levels per tier', m.levelsPerTier and number(m.levelsPerTier) or '—')
    stat(stats, 'Base price', number(m.basePrice))
    stat(stats, 'Price per level', number(m.pricePerLevel))
    if m.target then
        stat(stats, 'Target', esc(m.target))
    end

    if m.tags and #m.tags > 0 then
        local chips = body:tag('div'):addClass('wiki-module__chips')
        for _, tag in ipairs(m.tags) do
            chips:tag('span'):addClass('tpt-tag'):wikitext(esc(tag))
        end
    end

    panel:tag('div'):addClass('wiki-module__links')
        :wikitext(string.format('[%s Open in the module database]', m.url))
        :tag('span'):addClass('wiki-module__version'):wikitext('Game ' .. esc(m.version or ''))
    return panel
end

local VIEWS = { icon = iconView, compact = compactView, full = fullView }

-- Renders a module table from mw.ext.gameData.getModule (also used by tests).
function p._view(m, args)
    local view = VIEWS[nonEmpty(args.view) or 'icon'] or iconView
    return tostring(view(m, args))
end

-- A key the game doesn't know: a visible note, and a maintenance category on content pages.
local function unknown(key, available)
    if not available then
        -- The game data can't be read right now; show the key as text.
        return esc(key)
    end
    local out = '<span class="wiki-module wiki-module--missing">Unknown module: ' .. esc(key) .. '</span>'
    local ns = mw.title.getCurrentTitle().namespace
    if ns ~= 10 and ns ~= 828 then
        out = out .. '[[Category:' .. UNKNOWN_CATEGORY .. ']]'
    end
    return out
end

function p.main(frame)
    local args = frame:getParent().args
    local key = nonEmpty(args.id) or nonEmpty(args.name) or ''
    local gameData = require('mw.ext.gameData')
    local styles = frame:extensionTag('templatestyles', '', { src = STYLES })
    local m = key ~= '' and gameData.getModule(key) or nil
    if not m then
        return styles .. unknown(key, #gameData.getCategories() > 0)
    end
    return styles .. p._view(m, args)
end

--[[ {{Module row}}: one row of a module table. The game supplies the category icon, name, active,
description and tags; the wiki supplies what the game data doesn't know, as parameters.
    standard layout: Type | Name | Active | Unlock location | Unlock condition | Description | Tags | Formula
                     (location=, condition=, formula=)
    infinity layout: Type | Name | Max. tier | Active | Grid location | Grid coordinates | Unlock cost |
                     Description | Tags | Formula
                     (maxtier= overrides the game's max tier; gridlocation=, gridcoordinates=, unlockcost=,
                     formula=)
    region layout:   Type | Name | Unlock condition (condition=), for the module table on a region's page
    note= adds the wiki's own note under the description. ]]
local function cell(attrs, content)
    return (attrs and (attrs .. ' | ') or '') .. (content or '')
end

function p._row(m, args, key, available)
    local param = function (name) return nonEmpty(args[name]) or '' end
    local typeCell, nameCell, active, maxTier, description, tags
    if m then
        typeCell = cell(string.format('class="wiki-module-row__type" data-sort-value="%s"', esc(m.categoryName)),
            string.format('[[File:%s Module.png|%s|x30px|link=|alt=%s module]]', m.categoryName, m.categoryName, m.categoryName))
        nameCell = cell(string.format('class="wiki-module-row__name" data-sort-value="%s"', esc(m.name)),
            "'''" .. nameLink(m, args) .. "'''")
        active = m.type == 'Passive' and '' or (m.type == 'Toggle' and 'Toggle' or 'Yes')
        maxTier = nonEmpty(args.maxtier) or (m.maxTier and tostring(m.maxTier)) or '—'
        description = descriptionText(m.description, args)
        if m.description.warning ~= '' then
            description = description .. '<span class="wiki-module__warning">' .. withValues(m.description.warning, args) .. '</span>'
        end
        if m.description.note ~= '' then
            description = description .. '<span class="wiki-module__credit">' .. withValues(m.description.note, args) .. '</span>'
        end
        if nonEmpty(args.note) then
            description = description .. '<span class="wiki-module__note">Note: ' .. nonEmpty(args.note) .. '</span>'
        end
        tags = esc(mw.ustring.upper(table.concat(m.tags or {}, ' ')))
    else
        typeCell, active, description, tags = '', '', '', ''
        maxTier = param('maxtier')
        nameCell = unknown(key, available ~= false)
    end
    local cells
    local layout = nonEmpty(args.layout)
    if layout == 'region' then
        cells = { typeCell, nameCell, param('condition') }
    elseif layout == 'infinity' then
        cells = { typeCell, nameCell, maxTier, active, param('gridlocation'), param('gridcoordinates'),
            param('unlockcost'), description, tags, param('formula') }
    else
        cells = { typeCell, nameCell, active, param('location'), param('condition'), description, tags,
            param('formula') }
    end
    return '|-\n| ' .. table.concat(cells, '\n| ')
end

function p.row(frame)
    local args = frame:getParent().args
    local key = nonEmpty(args.id) or nonEmpty(args.name) or ''
    local gameData = require('mw.ext.gameData')
    local m = key ~= '' and gameData.getModule(key) or nil
    local available = m ~= nil or #gameData.getCategories() > 0
    -- The styles end up in the row's last cell; MediaWiki writes them once per page.
    return p._row(m, args, key, available) .. frame:extensionTag('templatestyles', '', { src = STYLES })
end

return p