Jump to content

Module:AchievementSystem

Documentation for this module may be created at Module:AchievementSystem/doc

-- Module:AchievementSystem
-- Loads data from MediaWiki:AchievementData.json and MediaWiki:AchievementList.json
-- AchievementList.json contains achievement type definitions
-- AchievementData.json contains user achievement assignments
-- All achievement styling is defined in CSS/Templates.css, not in the JSON. This module only assigns CSS classes based on achievement IDs in the format:
-- .person-template .template-title.achievement-{id}::after {}

local Achievements = {}

--------------------------------------------------------------------------------
-- JSON Handling
--------------------------------------------------------------------------------
-- Helper function to ensure we get an array
local function ensureArray(value)
    if type(value) ~= "table" then
        return {}
    end
    
    -- Check if it's an array-like table
    local isArray = true
    local count = 0
    for _ in pairs(value) do
        count = count + 1
    end
    
    -- If it has no numeric indices or is empty, return empty array
    if count == 0 then
        return {}
    end
    
    -- If it's a single string, wrap it in an array
    if count == 1 and type(value[1]) == "string" then
        return {value[1]}
    end
    
    -- If it has a single non-array value, try to convert it to an array
    if count == 1 and next(value) and type(next(value)) ~= "number" then
        local k, v = next(value)
        if type(v) == "string" then
            return {v}
        end
    end
    
    -- Return the original table if it seems to be an array
    return value
end

-- Use MediaWiki's built-in JSON functions directly
local function jsonDecode(jsonString)
    if not jsonString then return nil end
    
    if mw.text and mw.text.jsonDecode then
        local success, result = pcall(function()
            -- Use WITHOUT PRESERVE_KEYS flag to ensure proper array handling
            return mw.text.jsonDecode(jsonString)
        end)
        
        if success and result then
            return result
        end
    end
    
    return nil
end

-- Simple HTML encode fallback
local function htmlEncode(str)
    if mw.text and mw.text.htmlEncode then
        return mw.text.htmlEncode(str or '')
    else
        return (str or '')
            :gsub('&', '&')
            :gsub('<', '&lt;')
            :gsub('>', '&gt;')
            :gsub('"', '&quot;')
    end
end

--------------------------------------------------------------------------------
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_LIST_PAGE = 'MediaWiki:AchievementList.json'
local dataCache = nil
local typesCache = nil

local DEFAULT_DATA = {
    schema_version = 2,
    last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
    achievement_types = {},
    user_achievements = {},
}

--------------------------------------------------------------------------------
-- Load achievement types from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of achievement type definitions
--------------------------------------------------------------------------------
function Achievements.loadTypes(frame)
    -- Use the request-level cache if we already loaded data once
    if typesCache then
        return typesCache
    end

    local success, types = pcall(function()
        -- Get the JSON content using frame:preprocess if available
        local jsonText
        if frame and type(frame) == "table" and frame.preprocess then
            -- Make sure frame is valid and has preprocess method
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementList.json}}')
            end)
            
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
        end
        
        -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not jsonText then
            -- Try using mw.loadJsonData first (preferred method)
            if mw.loadJsonData then
                local loadJsonSuccess, jsonData = pcall(function()
                    return mw.loadJsonData(ACHIEVEMENT_LIST_PAGE)
                end)
                
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
            end
            
            -- Direct content loading approach as fallback
            local pageTitle = mw.title.new(ACHIEVEMENT_LIST_PAGE)
            if pageTitle and pageTitle.exists then
                -- Get raw content from the wiki page
                local contentSuccess, content = pcall(function()
                    return pageTitle:getContent()
                end)
                
                if contentSuccess and content and content ~= "" then
                    -- Remove any BOM or leading whitespace that might cause issues
                    content = content:gsub("^%s+", "")
                    if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                        content = content:sub(4)
                    end
                    
                    jsonText = content
                    
                    -- Try different JSON decode approaches
                    if jsonText and mw.text and mw.text.jsonDecode then
                        -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                        local jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText)
                        end)
                        
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                        
                        -- If that failed, try with JSON_TRY_FIXING flag
                        jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                        end)
                        
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                    end
                end
            end
            
            -- If we couldn't load from AchievementList.json, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
            if data and data.achievement_types then
                return data.achievement_types
            end
        else
            -- We have jsonText from frame:preprocess, try to decode it
            if jsonText and mw.text and mw.text.jsonDecode then
                -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                local jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText)
                end)
                
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
                
                -- If that failed, try with JSON_TRY_FIXING flag
                jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                end)
                
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
            end
            
            -- If we couldn't decode the JSON, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
            if data and data.achievement_types then
                return data.achievement_types
            end
        end
        
        -- As an absolute last resort, return an empty array
        return {}
    end)

    if not success or not types then
        -- If there was an error, fall back to AchievementData.json
        local data = Achievements.loadData(frame)
        if data and data.achievement_types then
            typesCache = data.achievement_types
            return typesCache
        end
        types = {}
    end

    typesCache = types
    return types
end

--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Table containing the full achievement data
--------------------------------------------------------------------------------
function Achievements.loadData(frame)
    -- Use the request-level cache if we already loaded data once
    if dataCache then
        return dataCache
    end

    local success, data = pcall(function()
        -- Get the JSON content using frame:preprocess if available
        local jsonText
        if frame and type(frame) == "table" and frame.preprocess then
            -- Make sure frame is valid and has preprocess method
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementData.json}}')
            end)
            
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
        end
        
        -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not jsonText then
            -- Try using mw.loadJsonData first (preferred method)
            if mw.loadJsonData then
                local loadJsonSuccess, jsonData = pcall(function()
                    return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
                end)
                
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
                    return jsonData
                end
            end
            
            -- Direct content loading approach as fallback
            local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
            if not pageTitle or not pageTitle.exists then
                return DEFAULT_DATA
            end
            
            -- Get raw content from the wiki page
            local contentSuccess, content = pcall(function()
                return pageTitle:getContent()
            end)
            
            if contentSuccess and content and content ~= "" then
                -- Remove any BOM or leading whitespace that might cause issues
                content = content:gsub("^%s+", "")
                if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                    content = content:sub(4)
                end
                
                jsonText = content
            else
                return DEFAULT_DATA
            end
        end
        
        -- Try different JSON decode approaches
        if jsonText and mw.text and mw.text.jsonDecode then
            -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
            local jsonDecodeSuccess, jsonData = pcall(function()
                return mw.text.jsonDecode(jsonText)
            end)
            
            if jsonDecodeSuccess and jsonData then
                return jsonData
            end
            
            -- If that failed, try with JSON_TRY_FIXING flag
            jsonDecodeSuccess, jsonData = pcall(function()
                return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
            end)
            
            if jsonDecodeSuccess and jsonData then
                return jsonData
            end
        end
        -- As an absolute last resort, use local default data
        return DEFAULT_DATA
    end)

    if not success or not data then
        data = DEFAULT_DATA
    end

    dataCache = data
    return data
end

--------------------------------------------------------------------------------
-- Get user achievements
-- @param pageId - The page ID to get achievements for
-- @return Array of achievement objects for the specified page
--------------------------------------------------------------------------------
local userAchievementsCache = {}

function Achievements.getUserAchievements(pageId)
    if not pageId or pageId == '' then
        return {}
    end
    
    -- Check cache first
    local cacheKey = tostring(pageId)
    if userAchievementsCache[cacheKey] then
        return userAchievementsCache[cacheKey]
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        return {}
    end

    local key = cacheKey
    local userEntry = data.user_achievements[key]
    
    -- If found with string key, return achievements
    if userEntry and userEntry.achievements then
        local achievements = ensureArray(userEntry.achievements)
        userAchievementsCache[cacheKey] = achievements
        return achievements
    end
    
    -- Try numeric key as fallback
    local numKey = tonumber(key)
    if numKey then
        userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.achievements then
            local achievements = ensureArray(userEntry.achievements)
            userAchievementsCache[cacheKey] = achievements
            return achievements
        end
    end
    
    -- Cache empty result to avoid repeated lookups
    userAchievementsCache[cacheKey] = {}
    return {}
end

--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
-- @param pageId - The page ID to check
-- @return Boolean indicating if the page has any achievements
--------------------------------------------------------------------------------
function Achievements.hasAchievements(pageId)
    if not pageId or pageId == '' then
        return false
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    return #userAchievements > 0
end

--------------------------------------------------------------------------------
-- Get a user-friendly name for a given achievement type
-- @param achievementType - The achievement type ID
-- @param frame - The Scribunto frame object for preprocessing
-- @return String containing the user-friendly name
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType, frame)
    if not achievementType or achievementType == '' then
        return 'Unknown'
    end

    local types = Achievements.loadTypes(frame)
    
    -- Try to match achievement ID
    for _, typeData in ipairs(types) do
        if typeData.id == achievementType then
            if typeData.name and typeData.name ~= "" then
                return typeData.name
            else
                return achievementType
            end
        end
    end

    return achievementType
end

--------------------------------------------------------------------------------
-- Find the top-tier Title achievement for the user (lowest tier number)
-- Return the CSS class and the readable achievement name
-- @param pageId - The page ID to get the title achievement for
-- @param frame - The Scribunto frame object for preprocessing
-- @return CSS class, display name
--------------------------------------------------------------------------------
function Achievements.getTitleClass(pageId, frame)
    if not pageId or pageId == '' then
        return '', ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return '', ''
    end

    local types = Achievements.loadTypes(frame)
    local highestTier = 999
    local highestAchievement = nil

    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        
        for _, typeData in ipairs(types) do
            if typeData.id == achType then
                local tier = typeData.tier or 999
                if tier < highestTier then
                    highestTier = tier
                    highestAchievement = typeData
                end
            end
        end
    end

    if not highestAchievement or not highestAchievement.id then
        return '', ''
    end

    local cssClass = "achievement-" .. highestAchievement.id
    local displayName = highestAchievement.name or highestAchievement.id or "Award"
    
    return cssClass, displayName
end

--------------------------------------------------------------------------------
-- Renders a box with the top-tier achievement for the user
-- @param pageId - The page ID to render the achievement box for
-- @param frame - The Scribunto frame object for preprocessing
-- @return HTML string containing the achievement box
--------------------------------------------------------------------------------
function Achievements.renderAchievementBox(pageId, frame)
    if not pageId or pageId == '' then
        return ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return ''
    end
    
    local types = Achievements.loadTypes(frame)
    
    -- Build a lookup table for achievement type definitions
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        if typeData.id and typeData.name then
            typeDefinitions[typeData.id] = {
                name = typeData.name,
                tier = typeData.tier or 999
            }
        end
    end

    -- Look for the highest-tier Title achievement (lowest tier number)
    local highestTier = 999
    local topAchType = nil

    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        if typeDefinitions[achType] and typeDefinitions[achType].tier < highestTier then
            highestTier = typeDefinitions[achType].tier
            topAchType = achType
        end
    end

    -- If we found an achievement, render it
    if topAchType and typeDefinitions[topAchType] then
        local achName = typeDefinitions[topAchType].name or topAchType
        
        return string.format(
            '<div class="achievement-box-simple" data-achievement-type="%s">%s</div>',
            topAchType,
            htmlEncode(achName)
        )
    end

    return ''
end

--------------------------------------------------------------------------------
-- Get page name for a given page ID
-- @param pageId - The page ID to get the name for
-- @return String containing the page name
--------------------------------------------------------------------------------
function Achievements.getPageName(pageId)
    if not pageId or pageId == '' then
        return ''
    end
    
    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        return ''
    end
    
    local key = tostring(pageId)
    local userEntry = data.user_achievements[key]
    
    -- Check if entry exists with string key
    if userEntry and userEntry.page_name then
        return userEntry.page_name
    end
    
    -- Try numeric key as fallback
    local numKey = tonumber(key)
    if numKey then
        userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.page_name then
            return userEntry.page_name
        end
    end
    
    return ''
end

--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
-- @param pageId - The page ID to get the achievement for
-- @param achievementType - The achievement type ID to look for
-- @return Achievement object or nil if not found
--------------------------------------------------------------------------------
function Achievements.getSpecificAchievement(pageId, achievementType)
    if not pageId or not achievementType or pageId == '' then
        return nil
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    
    -- Direct lookup for the requested achievement type
    for _, achievement in ipairs(userAchievements) do
        if achievement.type == achievementType then
            return achievement
        end
    end

    return nil
end

--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
-- @param achievementType - The achievement type ID to get the definition for
-- @param frame - The Scribunto frame object for preprocessing
-- @return Achievement type definition or nil if not found
--------------------------------------------------------------------------------
function Achievements.getAchievementDefinition(achievementType, frame)
    if not achievementType or achievementType == '' then
        return nil
    end
    
    local types = Achievements.loadTypes(frame)
    
    -- Direct lookup in achievement_types array
    for _, typeData in ipairs(types) do
        if typeData.id == achievementType then
            return typeData
        end
    end
    
    return nil
end

--------------------------------------------------------------------------------
-- Find and return title achievement for the user if one exists
-- This specifically looks for achievements with type="title"
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
-- @param pageId - The page ID to get the title achievement for
-- @param frame - The Scribunto frame object for preprocessing
-- @return achievementId, displayName, achievementId
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(pageId, frame)
    if not pageId or pageId == '' then
        return '', '', ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return '', '', ''
    end

    local types = Achievements.loadTypes(frame)
    
    -- Build a table of achievement definitions for quick lookup
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end

    -- Find title achievements only
    local highestTier = 999
    local titleAchievement = nil
    
    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        if achType then
            local typeData = typeDefinitions[achType]
            if typeData and typeData.type == "title" then
                local tier = typeData.tier or 999
                if tier < highestTier then
                    highestTier = tier
                    titleAchievement = typeData
                end
            end
        end
    end

    if not titleAchievement or not titleAchievement.id then
        return '', '', ''
    end

    local achievementId = titleAchievement.id
    local displayName = titleAchievement.name or achievementId
    
    return achievementId, displayName, achievementId
end

-- Renders a title block with achievement integration
function Achievements.renderTitleBlockWithAchievement(args, titleClass, titleText, achievementClass, achievementId, achievementName)
    titleClass = titleClass or "template-title"
    
    -- Only add achievement attributes if they exist
    if achievementClass and achievementClass ~= "" and achievementId and achievementId ~= "" then
        return string.format(
            '|-\n! colspan="2" class="%s %s" data-achievement-id="%s" data-achievement-name="%s" | %s',
            titleClass, achievementClass, achievementId, achievementName, titleText
        )
    else
        -- Clean row with no achievement data
        return string.format('|-\n! colspan="2" class="%s" | %s', titleClass, titleText)
    end
end

return Achievements