跳转到内容

模組:ACGaward

维基百科,自由的百科全书

require("strict")

local __all__ = {"score", "level", "ranking", "diff"}

local strf = mw.ustring.format

local getArgs = require("Module:Arguments").getArgs

--- @alias Username string
--- @alias Score number
--- @alias Level number
--- @alias Rank number
--- @alias RecordMapping table<Username, Score>
--- @alias Wikitext string

--- @class UserScore
--- @field user Username
--- @field score Score
--- @field rank Rank

--- @class GroupItem
--- @field text string
--- @field value? Rank

--- @class UserScoreDelta
--- @field user Username
--- @field delta Score
--- @field current Score

--- @class RankingGroup
--- @field kind string
--- @field lbound? Level
--- @field ubound? Level
--- @field smin? Score
--- @field smax? Score
--- @field title? string

--- @type string
local AWARD_NAME = "維基ACG專題獎"
--- @type RankingGroup[]
local RANKING_GROUPS = {
    -- 40级是因为[[:Category:ACG特別貢獻]]
    {kind = "user_score", lbound = 40, ubound = math.huge},
    -- 保持视觉效果平衡,因此没有细分
    {kind = "user_score", lbound = 10, ubound = 40},
    {kind = "user_score", lbound = 5, ubound = 10},
    {kind = "user_score", lbound = 4, ubound = 5},
    {kind = "user_score", lbound = 3, ubound = 4},
    {kind = "user_score", lbound = 2, ubound = 3},
    {kind = "user_score", lbound = 1, ubound = 2},
    {
        kind = "score_count",
        smin = 1,
        smax = 10,
        title = "'''未滿1級ACG專題獎'''",
    },
}
--- @type string
local CURRENT_RECORD_MODULE = "Module:ACGaward/list"
--- @type string
local OLD_RECORD_MODULE = "Module:ACGaward/list/old"

--- Return true when value is nil or an empty string.
---
--- @param value any Value to check.
--- @return boolean Result of the check.
local function is_empty(value)
    return value == nil or value == ""
end

--- Load a record map from the given module name.
---
--- @param module string | nil Module name, "OLD", or empty.
--- @return RecordMapping Loaded record map.
local function load_record_module(module)
    if is_empty(module) then
        return mw.loadData(CURRENT_RECORD_MODULE)
    end
    if module == "OLD" then
        return mw.loadData(OLD_RECORD_MODULE)
    end
    return mw.loadData(module)
end

--- Return a normalized username or current page base name.
---
--- @param user Username | nil Optional username.
--- @return Username Normalized username.
local function fetch_username(user)
    if is_empty(user) then
        return mw.title.getCurrentTitle().baseText
    end
    local username = user  -- TODO: Implement normalization
    return username
end

--- Return a username and its recorded score from a map.
---
--- @param mapping RecordMapping Name-to-score map.
--- @param user Username | nil Optional username to look up.
--- @return Username Username used for lookup.
--- @return Score Recorded score, or 0 if not found.
local function get_record(mapping, user)
    local name = fetch_username(user)
    return name, (mapping[name] or 0)
end

--- Convert an internal score to a public integer score.
---
--- @param score Score Score that may have decimals.
--- @return Score Public score.
local function calculate_public_score(score)
    return math.floor(score)
end

--- Convert a score to its ACG-award level.
---
--- @param score Score Score to convert.
--- @return Level Award level.
local function calculate_level(score)
    return math.floor(score / 10)
end

--- Return a user's public score from a record map.
---
--- @param mapping RecordMapping Name-to-score map.
--- @param user Username | nil Optional username to look up.
--- @return Score Public score.
local function get_score(mapping, user)
    local _, score = get_record(mapping, user)
    return calculate_public_score(score)
end

--- Return a user's award level from a record map.
---
--- @param mapping RecordMapping Name-to-score map.
--- @param user Username | nil Optional username to look up.
--- @return Level Award level.
local function get_level(mapping, user)
    local _, score = get_record(mapping, user)
    return calculate_level(score)
end

--- Expand one template with the current frame.
---
--- @param frame table Scribunto frame object.
--- @param title string Template title.
--- @param args table | nil Template arguments.
--- @return string Expanded template output.
local function expand_template(frame, title, args)
    return frame:expandTemplate {
        title = title,
        args = args or {},
    }
end

--- Return the title text for one ranking group.
---
--- @param group RankingGroup Group config.
--- @return string Group title.
local function make_group_title(group)
    if not is_empty(group.title) then
        return group.title
    end

    local lbound = group.lbound
    local ubound = group.ubound

    if ubound == math.huge then
        return strf("'''%d+級%s'''", lbound, AWARD_NAME)
    end
    if ubound - lbound == 1 then
        return strf("'''%d級%s'''", lbound, AWARD_NAME)
    end
    return strf("'''%d&ndash;%d級%s'''", lbound, ubound - 1, AWARD_NAME)
end

--- Return all ranked users with their public scores.
---
--- @param mapping RecordMapping Name-to-score map.
--- @return UserScore[] Ranked user entries.
local function collect_ranked_users(mapping)
    local list = {}

    for user, score in pairs(mapping) do
        table.insert(list, {
            user = user,
            score = calculate_public_score(score),
            rank = 0,
        })
    end

    table.sort(list, function(a, b)
        if a.score == b.score then
            return a.user < b.user
        end
        return a.score > b.score
    end)

    local current_rank = 0
    local previous_score = nil

    for index, item in ipairs(list) do
        if item.score ~= previous_score then
            current_rank = index
            previous_score = item.score
        end
        item.rank = current_rank
    end

    return list
end

--- Return users that belong to one user-score group.
---
--- @param ranked_users UserScore[] Globally ranked user entries.
--- @param group RankingGroup Group config.
--- @return UserScore[] Group entries.
local function collect_user_score_group(ranked_users, group)
    local list = {}

    for _, item in ipairs(ranked_users) do
        local level = calculate_level(item.score)
        if level >= group.lbound and level < group.ubound then
            table.insert(list, item)
        end
    end

    return list
end

--- Return score counts that belong to one score-count group.
---
--- @param mapping RecordMapping Name-to-score map.
--- @param group RankingGroup Group config.
--- @return GroupItem[] Group entries.
local function collect_score_count_group(mapping, group)
    local counts = {}
    local list = {}

    for _, score in pairs(mapping) do
        local public_score = calculate_public_score(score)
        if public_score >= group.smin and public_score < group.smax then
            counts[public_score] = (counts[public_score] or 0) + 1
        end
    end

    for score = group.smax - 1, group.smin, -1 do
        local count = counts[score]
        if count and count > 0 then
            table.insert(list, {
                text = strf("%d分:%d名維基人", score, count),
            })
        end
    end

    if #list == 0 then
        table.insert(list, {text = "無"})
    end

    return list
end

--- Return rendered list items for one ranking group.
---
--- @param mapping RecordMapping Name-to-score map.
--- @param ranked_users UserScore[] Globally ranked user entries.
--- @param group RankingGroup Group config.
--- @return GroupItem[] Rendered group entries.
local function collect_group_items(mapping, ranked_users, group)
    if group.kind == "user_score" then
        local users = collect_user_score_group(ranked_users, group)
        local list = {}

        for _, item in ipairs(users) do
            table.insert(list, {
                text = strf(
                    "[[User:%s|%s]]:%d分",
                    item.user,
                    item.user,
                    item.score
                ),
                value = item.rank,
            })
        end

        return list
    end

    if group.kind == "score_count" then
        return collect_score_count_group(mapping, group)
    end

    error("Unknown ranking group kind: " .. tostring(group.kind))
end

--- Build wikicode for one ranking group.
---
--- @param frame table Scribunto frame object.
--- @param group RankingGroup Group config.
--- @param items GroupItem[] Group entries.
--- @return Wikitext? Group wikicode, or nil if empty.
local function create_group_ranking(frame, group, items)
    if #items == 0 then
        return nil
    end

    local lines = {make_group_title(group)}
    table.insert(
        lines,
        expand_template(frame, "div col", {colwidth = "18em"})
    )

    if group.kind == "score_count" then
        for _, item in ipairs(items) do
            table.insert(lines, strf("* %s", item.text))
        end
        table.insert(lines, expand_template(frame, "div col end"))
        return table.concat(lines, "\n")
    end

    table.insert(lines, "<ol>")

    for _, item in ipairs(items) do
        table.insert(
            lines,
            strf('<li value="%d">%s</li>', item.value, item.text)
        )
    end

    table.insert(lines, "</ol>")
    table.insert(lines, expand_template(frame, "div col end"))

    return table.concat(lines, "\n")
end

--- Build grouped ranking wikicode for all users.
---
--- @param frame table Scribunto frame object.
--- @param mapping RecordMapping Name-to-score map.
--- @return Wikitext Full ranking wikicode.
local function build_ranking(frame, mapping)
    local parts = {}
    local ranked_users = collect_ranked_users(mapping)

    for _, group in ipairs(RANKING_GROUPS) do
        local items = collect_group_items(mapping, ranked_users, group)
        local section = create_group_ranking(frame, group, items)
        if section then
            table.insert(parts, section)
        end
    end

    return table.concat(parts, "\n\n")
end

--- Build a report of score changes between two maps.
---
--- @param mapping1 RecordMapping Old record map.
--- @param mapping2 RecordMapping New record map.
--- @return Wikitext Difference report wikicode.
local function build_difference(mapping1, mapping2)
    local users = {}

    for user, _ in pairs(mapping1) do
        table.insert(users, user)
    end
    for user, _ in pairs(mapping2) do
        if mapping1[user] == nil then
            table.insert(users, user)
        end
    end

    local diff_score_list = {}
    for _, user in ipairs(users) do
        local score1 = get_score(mapping1, user)
        local score2 = get_score(mapping2, user)
        local score_delta = score2 - score1
        if score_delta ~= 0 then
            table.insert(diff_score_list, {
                user = user,
                delta = score_delta,
                current = score2,
            })
        end
    end

    table.sort(diff_score_list, function(a, b)
        if a.delta == b.delta then
            return a.current > b.current
        end
        return a.delta > b.delta
    end)

    local lines = {}
    local ptn = "# <code%s>* &#91;[User:%s|%s]]:共得%d分"
        .. "&#60;small>(累計分數%d分)&#60;/small>%s</code>"

    for _, item in ipairs(diff_score_list) do
        local style = ""
        local tag = ""

        if item.delta < 0 then
            style = ' style="color: red;"'
        elseif item.delta == item.current then
            style = ' style="color: green;"'
            tag = " {{color|green|N}}"
        end

        table.insert(lines, strf(
            ptn,
            style,
            item.user,
            item.user,
            item.delta,
            item.current,
            tag
        ))
    end

    return table.concat(lines, "\n")
end

local p = {}

--- Return the public score for one user.
---
--- @param args table Invoke arguments.
--- @return string Public score as text.
function p._score(args)
    local mapping = load_record_module(args.module)
    return tostring(get_score(mapping, args.user))
end

--- Return the award level for one user.
---
--- @param args table Invoke arguments.
--- @return string Award level as text.
function p._level(args)
    local mapping = load_record_module(args.module)
    return tostring(get_level(mapping, args.user))
end

--- Return grouped ranking wikicode.
---
--- @param args table Invoke arguments.
--- @param frame table Scribunto frame object.
--- @return Wikitext Ranking wikicode.
function p._ranking(args, frame)
    local mapping = load_record_module(args.module)
    return build_ranking(frame, mapping)
end

--- Return score differences between two record maps.
---
--- @param args table Invoke arguments.
--- @return Wikitext Difference report wikicode.
function p._diff(args)
    local module1 = args[1] or "OLD"
    local module2 = args[2]
    local mapping1 = load_record_module(module1)
    local mapping2 = load_record_module(module2)
    return build_difference(mapping1, mapping2)
end

--- Build an invoke wrapper for a private function.
---
--- @param func_name string Private function name.
--- @return function Invoke wrapper.
local function makeInvokeFunc(func_name)
    return function(frame)
        local args = getArgs(frame)
        return p[func_name](args, frame)
    end
end

for _, func_name in ipairs(__all__) do
    p[func_name] = makeInvokeFunc("_" .. func_name)
end

return p