Module:AchievementSystem

Revision as of 12:23, 1 April 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)

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

-- Module:AchievementSystem
-- Achievement system that loads data from MediaWiki:AchievementData.json.
-- STYLING NOTE: 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 {}
--
-- The module does not use any styling information from the JSON data structure.

local Achievements = {}

-- Debug configuration
local DEBUG_MODE = true
local function debugLog(message)
    if not DEBUG_MODE then return end
    pcall(function()
        mw.logObject({
            system = "achievement_simple",
            message = message,
            timestamp = os.date('%H:%M:%S')
        })
    end)
    mw.log("ACHIEVEMENT-DEBUG: " .. message)
end

--------------------------------------------------------------------------------
-- JSON Handling
--------------------------------------------------------------------------------
local json
local jsonLoaded = pcall(function()
    json = require('Module:JSON')
end)

if not jsonLoaded or not json then
    json = { decode = function() return nil end }
    debugLog('WARNING: Module:JSON not available, achievement features will be limited')
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 dataCache = nil

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

--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
-- This array maps legacy achievement IDs to standardized ones
local ACHIEVEMENT_TYPE_MAPPING = {
    ["title-test"] = "dev-role", -- dev-role is used for title achievements only
    ["jedi"]      = "ach1",      -- ach1, ach2, ach3 are used for badge achievements
    ["champion"]  = "ach2",
    ["sponsor"]   = "ach3"
}

-- Normalizes achievement type to handle variants or legacy types
local function normalizeAchievementType(achievementType)
    if not achievementType then return nil end
    
    -- If it's already a standard type, return it directly
    if achievementType == "dev-role" or  -- Title-only achievement
       achievementType == "ach1" or      -- Badge achievements
       achievementType == "ach2" or 
       achievementType == "ach3" then
        return achievementType
    end
    
    -- Otherwise check the mapping table
    return ACHIEVEMENT_TYPE_MAPPING[achievementType] or achievementType
end

--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
--------------------------------------------------------------------------------
function Achievements.loadData()
    mw.log("JSON-DEBUG: Starting to load achievement data")

    -- Use the request-level cache if we already loaded data once
    if dataCache then
        mw.log("JSON-DEBUG: Using request-level cached data")
        return dataCache
    end

    local success, data = pcall(function()
        -- Try using the specialized JSON data loader - this is the correct MediaWiki way
        -- to load JSON from wiki pages
        local loadJsonSuccess, jsonData = pcall(function()
            mw.log("JSON-DEBUG: Attempting to use mw.loadJsonData for " .. ACHIEVEMENT_DATA_PAGE)
            return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
        end)

        if loadJsonSuccess and jsonData then
            mw.log("JSON-DEBUG: Successfully loaded data with mw.loadJsonData")
            return jsonData
        else
            mw.log("JSON-DEBUG: mw.loadJsonData failed: " .. tostring(jsonData or 'unknown error'))
        end
        
        -- Try parser cache next
        local loadDataSuccess, cachedData = pcall(function()
            mw.log("JSON-DEBUG: Attempting mw.loadData fallback")
            return mw.loadData('Module:AchievementSystem')
        end)

        if loadDataSuccess and cachedData then
            mw.log("JSON-DEBUG: Using mw.loadData cached data")
            return cachedData
        else
            mw.log("JSON-DEBUG: mw.loadData failed, proceeding to direct page load")
        end

        -- Try direct page content as last resort
        local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
        if not pageTitle or not pageTitle.exists then
            mw.log("JSON-DEBUG: " .. ACHIEVEMENT_DATA_PAGE .. " does not exist or title creation failed")
            return DEFAULT_DATA
        end

        local content = pageTitle:getContent()
        if not content or content == '' then
            mw.log("JSON-DEBUG: Page content is empty")
            return DEFAULT_DATA
        end

        mw.log("JSON-DEBUG: Raw JSON content length: " .. #content)
        
        -- Try mw.text.jsonDecode if available (preferred MediaWiki method)
        if mw.text and mw.text.jsonDecode then
            local jsonDecodeSuccess, jsonData = pcall(function()
                mw.log("JSON-DEBUG: Using mw.text.jsonDecode")
                return mw.text.jsonDecode(content)
            end)
            
            if jsonDecodeSuccess and jsonData then
                mw.log("JSON-DEBUG: Successfully decoded with mw.text.jsonDecode")
                return jsonData
            else
                mw.log("JSON-DEBUG: mw.text.jsonDecode failed: " .. tostring(jsonData or 'unknown error'))
            end
        end
        
        -- Fall back to Module:JSON if all else fails
        local parseSuccess, parsedData = pcall(function()
            mw.log("JSON-DEBUG: Using json.decode fallback")
            return json.decode(content)
        end)

        if not parseSuccess or not parsedData then
            mw.log("JSON-DEBUG: JSON parse failed: " .. tostring(parsedData or 'unknown error'))
            return DEFAULT_DATA
        end

        mw.log("JSON-DEBUG: Successfully loaded achievement data via fallback method")
        return parsedData
    end)

    if not success or not data then
        mw.log("JSON-DEBUG: Critical error in load process: " .. tostring(data or 'unknown error'))
        data = DEFAULT_DATA
    end

    -- Show success source in log
    if data ~= DEFAULT_DATA then
        mw.log("JSON-DEBUG: Successfully loaded JSON data with " .. tostring(#(data.achievement_types or {})) .. " achievement types")
        
        -- Log actual achievement types if DEBUG_MODE
        if DEBUG_MODE and data.achievement_types then
            for i, type in ipairs(data.achievement_types) do
                mw.log("JSON-DEBUG: Type[" .. i .. "]: id=" .. (type.id or "nil") .. ", name=" .. (type.name or "nil"))
            end
        end
    end

    dataCache = data
    return data
end

--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
--------------------------------------------------------------------------------
function Achievements.hasAchievements(pageId)
    if not pageId or pageId == '' then
        return false
    end

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

    local key = tostring(pageId)
    if data.user_achievements[key] and #data.user_achievements[key] > 0 then
        return true
    end

    -- Check for legacy "n123" style
    if key:match("^%d+$") then
        local alt = "n" .. key
        if data.user_achievements[alt] and #data.user_achievements[alt] > 0 then
            return true
        end
    end

    -- We removed the forced "true" for test pages to avoid dev-role injection
    return false
end

--------------------------------------------------------------------------------
-- Get a user-friendly name for a given achievement type
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType)
    if not achievementType or achievementType == '' then
        debugLog("Empty achievement type provided to getAchievementName")
        mw.log("ACHIEVEMENT-NAME-ERROR: Received empty achievementType")
        return 'Unknown'
    end

    debugLog("Looking up achievement name for type: '" .. tostring(achievementType) .. "'")

    local data = Achievements.loadData()
    if not data or not data.achievement_types then
        mw.log("ACHIEVEMENT-NAME-ERROR: No achievement data or achievement_types missing")
        return achievementType
    end
    
    -- Display available achievement types in the log
    mw.log("ACHIEVEMENT-NAME-DEBUG: Searching achievement types for: " .. achievementType)
    mw.log("ACHIEVEMENT-NAME-DEBUG: Available achievement types:")
    for i, typeData in ipairs(data.achievement_types) do
        mw.log("  " .. i .. ": id=" .. (typeData.id or "nil") .. 
               ", name=" .. (typeData.name or "nil") ..
               ", tier=" .. (typeData.tier or "nil"))
    end

    -- Try to match achievement ID
    for i, typeData in ipairs(data.achievement_types) do
        if typeData.id == achievementType then
            if typeData.name and typeData.name ~= "" then
                mw.log("ACHIEVEMENT-NAME-SUCCESS: Found achievement: " .. typeData.id .. " with name: " .. typeData.name)
                return typeData.name
            else
                mw.log("ACHIEVEMENT-NAME-WARNING: '" .. typeData.id .. "' has no name; using ID")
                return achievementType
            end
        end
    end

    -- Special case for dev-role lookup - always show details
    if achievementType == "dev-role" then
        mw.log("ACHIEVEMENT-NAME-WARNING: Could not find dev-role in achievement_types!")
        mw.log("ACHIEVEMENT-NAME-DEBUG: Raw achievement_types:")
        
        -- Try to log the raw JSON for debugging
        pcall(function()
            if json and json.encode then
                mw.log(json.encode(data.achievement_types))
            end
        end)
    end

    mw.log("ACHIEVEMENT-NAME-ERROR: No achievement found with type '" .. achievementType .. "'; using ID fallback")
    return achievementType
end

--------------------------------------------------------------------------------
-- Find the top-tier achievement for the user (lowest tier number)
-- Return the CSS class and the readable achievement name
--------------------------------------------------------------------------------
function Achievements.getTitleClass(pageId)
    if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getTitleClass")
        return '', ''
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        debugLog("No achievement data available in getTitleClass")
        return '', ''
    end

    local key = tostring(pageId)
    debugLog("Looking up achievements for ID: " .. key)
    
    -- Debug logging to show available achievements in JSON
    if data.user_achievements then
        local availableIds = {}
        for k, _ in pairs(data.user_achievements) do
            table.insert(availableIds, k)
        end
        debugLog("Available user IDs in JSON: " .. table.concat(availableIds, ", "))
    end

    -- Try to fetch achievements for this pageId
    local userAchievements = data.user_achievements[key] or {}
    
    -- If no achievements found under normal ID, try alternative format
    if #userAchievements == 0 and key:match("^%d+$") then
        local altKey = "n" .. key
        debugLog("No achievements under ID '" .. key .. "', trying alternative ID: '" .. altKey .. "'")
        userAchievements = data.user_achievements[altKey] or {}
    end

    -- Special handling for test page ID 18451
    if key == "18451" then
        -- Verify if the dev-role achievement exists
        local devRoleFound = false
        for _, ach in ipairs(userAchievements) do
            if ach.type == "dev-role" then
                devRoleFound = true
                debugLog("FOUND DEV-ROLE for page 18451!")
                break
            end
        end
        
        -- If no achievements found for test page, this is concerning
        if #userAchievements == 0 then
            debugLog("WARNING: Test page ID 18451 has no achievements!")
        end
        
        -- If achievements exist but dev-role is missing, also concerning
        if #userAchievements > 0 and not devRoleFound then
            debugLog("WARNING: Test page ID 18451 has achievements but missing dev-role!")
        end
    end

    -- Log found achievements for debugging
    if #userAchievements > 0 then
        debugLog("Found " .. #userAchievements .. " achievements for user " .. key)
        for i, ach in ipairs(userAchievements) do
            debugLog("  Achievement " .. i .. ": type=" .. (ach.type or "nil"))
        end
    else
        debugLog("No achievements found for user " .. key)
        return '', ''
    end

    local highestTier = 999
    local highestAchievement = nil

    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        debugLog("Processing achievement type: " .. (achType or "nil"))
        
        for _, typeData in ipairs(data.achievement_types) do
            if typeData.id == achType then
                local tier = typeData.tier or 999
                debugLog("  Found type '" .. typeData.id .. "' with tier " .. tier .. " and name '" .. (typeData.name or "nil") .. "'")
                if tier < highestTier then
                    highestTier = tier
                    highestAchievement = typeData
                    debugLog("  New highest tier achievement: " .. typeData.id)
                end
            end
        end
    end

    if not highestAchievement or not highestAchievement.id then
        debugLog("No valid top-tier achievement found for user " .. key)
        return '', ''
    end

    local cssClass = "achievement-" .. highestAchievement.id
    local displayName = highestAchievement.name or highestAchievement.id or "Award"
    
    debugLog("Using top-tier achievement: " .. cssClass .. " with name: " .. displayName)
    
    return cssClass, displayName
end

--------------------------------------------------------------------------------
-- Renders a simple "box" with the top-tier achievement for the user
--------------------------------------------------------------------------------
function Achievements.renderAchievementBox(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 userAchievements = data.user_achievements[key]
    if (not userAchievements or #userAchievements == 0) and key:match("^%d+$") then
        userAchievements = data.user_achievements["n" .. key]
    end

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

    -- Look for the highest-tier 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

--------------------------------------------------------------------------------
-- Simple pass-through to track pages (for future expansions)
--------------------------------------------------------------------------------
function Achievements.trackPage(pageId, pageName)
    return true
end

--------------------------------------------------------------------------------
-- Get all badge achievements for a user (excluding dev-role)
--------------------------------------------------------------------------------
function Achievements.getBadgeAchievements(pageId)
    -- Mirror the robust ID handling from getTitleClass
    if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getBadgeAchievements")
        return {}
    end

    -- Use the same proven data loading approach
    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        debugLog("No achievement data available in getBadgeAchievements")
        return {}
    end

    -- Use identical ID handling approach as getTitleClass
    local key = tostring(pageId)
    debugLog("Looking up badge achievements for ID: " .. key)
    
    -- Debug logging to show available achievements in JSON
    if data.user_achievements then
        local availableIds = {}
        for k, _ in pairs(data.user_achievements) do
            table.insert(availableIds, k)
        end
        debugLog("Available user IDs in JSON: " .. table.concat(availableIds, ", "))
    end
    
    -- Try to fetch achievements with the same ID lookup strategy
    local userAchievements = data.user_achievements[key] or {}
    
    -- Use the same alternate ID format checking
    if #userAchievements == 0 and key:match("^%d+$") then
        local altKey = "n" .. key
        debugLog("No achievements under ID '" .. key .. "', trying alternative ID: '" .. altKey .. "'")
        userAchievements = data.user_achievements[altKey] or {}
        
        -- If still nothing, try other variations (page, user prefixes)
        if #userAchievements == 0 then
            local otherKeys = {"page" .. key, "user" .. key}
            for _, otherKey in ipairs(otherKeys) do
                if data.user_achievements[otherKey] and #data.user_achievements[otherKey] > 0 then
                    debugLog("Found achievements under variant key: " .. otherKey)
                    userAchievements = data.user_achievements[otherKey]
                    break
                end
            end
        end
    end

    -- Log found achievements for debugging
    if #userAchievements > 0 then
        debugLog("Found " .. #userAchievements .. " total achievements for user " .. key)
        for i, ach in ipairs(userAchievements) do
            debugLog("  Achievement " .. i .. ": type=" .. (ach.type or "nil"))
        end
    else
        debugLog("No achievements found for user " .. key)
        return {}
    end

    -- Filter for badge achievements only (exclude dev-role)
    local badgeAchievements = {}
    for _, achievement in ipairs(userAchievements) do
        if achievement.type and achievement.type ~= "dev-role" then
            debugLog("  Adding badge achievement: " .. achievement.type)
            table.insert(badgeAchievements, achievement)
        end
    end

    return badgeAchievements
end

--------------------------------------------------------------------------------
-- Enhanced diagnostic function for badge achievements
--------------------------------------------------------------------------------
function Achievements.debugBadgeData(pageId)
    local data = Achievements.loadData()
    if not data then
        mw.log("BADGE-DEBUG: No achievement data loaded")
        return false
    end
    
    mw.log("BADGE-DEBUG: JSON data schema version: " .. tostring(data.schema_version or "unknown"))
    
    if not data.user_achievements then
        mw.log("BADGE-DEBUG: No user_achievements section in JSON")
        return false
    end
    
    -- Log available user IDs
    local availableIds = {}
    for k, _ in pairs(data.user_achievements) do
        table.insert(availableIds, k)
    end
    mw.log("BADGE-DEBUG: Available user IDs: " .. table.concat(availableIds, ", "))
    
    -- Check for specific user
    local key = tostring(pageId)
    if not data.user_achievements[key] then
        mw.log("BADGE-DEBUG: No achievements found for key " .. key)
        
        -- Try alternate keys
        local altKeys = {"n" .. key, "page" .. key, "user" .. key}
        for _, altKey in ipairs(altKeys) do
            if data.user_achievements[altKey] and #data.user_achievements[altKey] > 0 then
                mw.log("BADGE-DEBUG: Found " .. #data.user_achievements[altKey] .. 
                       " achievements under alternate key: " .. altKey)
                
                -- Log the achievement types
                for i, ach in ipairs(data.user_achievements[altKey]) do
                    mw.log("BADGE-DEBUG: Achievement " .. i .. " type: " .. 
                           (ach.type or "nil") .. ", source: " .. (ach.source or "nil"))
                end
            end
        end
    else
        mw.log("BADGE-DEBUG: Found " .. #data.user_achievements[key] .. " achievements for key " .. key)
        
        -- Log the achievement types
        for i, ach in ipairs(data.user_achievements[key]) do
            mw.log("BADGE-DEBUG: Achievement " .. i .. " type: " .. 
                   (ach.type or "nil") .. ", source: " .. (ach.source or "nil"))
        end
    end
    
    return true
end

--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
--------------------------------------------------------------------------------
function Achievements.getSpecificAchievement(pageId, achievementType)
    debugLog("ACHIEVEMENT-DEBUG: Looking for '" .. tostring(achievementType) ..
             "' in page ID: " .. tostring(pageId))

    if not pageId or not achievementType or pageId == '' then
        debugLog("ACHIEVEMENT-DEBUG: Invalid arguments for getSpecificAchievement")
        return nil
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        debugLog("ACHIEVEMENT-DEBUG: No achievement data loaded")
        return nil
    end

    local key = tostring(pageId)
    local userAchievements = data.user_achievements[key] or {}
    
    -- If no achievements found under normal ID, try alternative format
    if #userAchievements == 0 and key:match("^%d+$") then
        local altKey = "n" .. key
        debugLog("No achievements under ID '" .. key .. "', trying alternative ID: '" .. altKey .. "'")
        userAchievements = data.user_achievements[altKey] or {}
    end
    
    -- Direct lookup for the requested achievement type
    for _, achievement in ipairs(userAchievements) do
        if achievement.type == achievementType then
            debugLog("FOUND ACHIEVEMENT: " .. achievementType .. " for user " .. key)
            return achievement
        end
    end

    debugLog("ACHIEVEMENT-DEBUG: No match found for achievement type: " .. achievementType)
    return nil
end

--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
--------------------------------------------------------------------------------
function Achievements.getAchievementDefinition(achievementType)
    if not achievementType or achievementType == '' then
        debugLog("ACHIEVEMENT-DEF: Empty achievement type")
        return nil
    end
    
    local data = Achievements.loadData()
    if not data or not data.achievement_types then
        debugLog("ACHIEVEMENT-DEF: No achievement data loaded")
        return nil
    end
    
    -- Direct lookup in achievement_types array
    for _, typeData in ipairs(data.achievement_types) do
        if typeData.id == achievementType then
            debugLog("ACHIEVEMENT-DEF: Found definition for " .. achievementType)
            return typeData
        end
    end
    
    debugLog("ACHIEVEMENT-DEF: No definition found for " .. achievementType)
    return nil
end

return Achievements