Jump to content

Module:AchievementSystem: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
Line 543: Line 543:


     local data = Achievements.loadData()
     local data = Achievements.loadData()
    debugLog("Looking up title achievements for ID: " .. tostring(pageId))
   
    -- Debug log all achievements for this user
    for i, achievement in ipairs(userAchievements) do
        local achType = achievement.type or "nil"
        debugLog("Achievement " .. i .. ": type=" .. achType)
    end
      
      
     -- Build a table of achievement definitions for quick lookup
     -- Build a table of achievement definitions for quick lookup
Line 555: Line 548:
     for _, typeData in ipairs(data.achievement_types) do
     for _, typeData in ipairs(data.achievement_types) do
         typeDefinitions[typeData.id] = typeData
         typeDefinitions[typeData.id] = typeData
        -- Debug log all achievement definitions
        debugLog("Definition: id=" .. typeData.id ..
                ", name=" .. (typeData.name or "nil") ..
                ", type=" .. (typeData.type or "nil"))
     end
     end


Line 565: Line 554:
     local titleAchievement = nil
     local titleAchievement = nil


     for i, achievement in ipairs(userAchievements) do
     for _, achievement in ipairs(userAchievements) do
         local achType = achievement.type
         local achType = achievement.type
         if achType then
         if achType then
             local typeData = typeDefinitions[achType]
             local typeData = typeDefinitions[achType]
             if typeData then
             if typeData and typeData.type == "title" then
                -- Check if it's a title type by examining "type" property
                local tier = typeData.tier or 999
                local achDefType = typeData.type
                if tier < highestTier then
               
                    highestTier = tier
                debugLog("Checking achievement " .. i .. ": type=" .. achType ..
                    titleAchievement = typeData
                        ", definition type=" .. (achDefType or "nil"))
               
                -- Only consider achievements with type="title"
                if achDefType == "title" then
                    debugLog("Found potential title achievement: " .. achType)
                    local tier = typeData.tier or 999
                    if tier < highestTier then
                        highestTier = tier
                        titleAchievement = typeData
                        debugLog("Using as highest tier: " .. typeData.id)
                    end
                 end
                 end
            else
                debugLog("No definition found for achievement type: " .. achType)
             end
             end
         end
         end
Line 593: Line 569:


     if not titleAchievement or not titleAchievement.id then
     if not titleAchievement or not titleAchievement.id then
         debugLog("No valid title achievement found for user " .. tostring(pageId))
         debugLog("No title achievement found for user " .. tostring(pageId))
         return '', '', ''
         return '', '', ''
     end
     end


    -- Return the ID directly (e.g., "title-developer") instead of "achievement-title-developer"
    -- This matches the CSS class structure in Achievements.css
    local displayName = titleAchievement.name or titleAchievement.id or "Award"
     local achievementId = titleAchievement.id
     local achievementId = titleAchievement.id
    local displayName = titleAchievement.name or achievementId
      
      
     debugLog("Using title achievement ID: " .. achievementId .. " with name: " .. displayName)
     debugLog("Found title achievement: " .. achievementId .. " with name: " .. displayName)
   
     return achievementId, displayName, achievementId
     return achievementId, displayName, achievementId
end
end

Revision as of 04:17, 2 April 2025

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
    -- Only use mw.log for console visibility
    mw.log("ACHIEVEMENT: " .. message)
end

--------------------------------------------------------------------------------
-- 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

-- We'll use MediaWiki's built-in JSON functions directly, no external module needed
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
        else
            debugLog('ERROR: JSON decode failed: ' .. tostring(result or 'unknown error'))
        end
    end
    
    debugLog('CRITICAL ERROR: mw.text.jsonDecode not available!')
    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('&', '&amp;')
            :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",
    ["jedi"]      = "ach1",
    ["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 
       achievementType == "ach1" or 
       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()
    debugLog("Starting to load achievement data")

    -- Use the request-level cache if we already loaded data once
    if dataCache then
        debugLog("Using request-level cached data")
        return dataCache
    end

    local success, data = pcall(function()
        -- Try using mw.loadJsonData first (preferred method)
        if mw.loadJsonData then
            debugLog("Attempting to use mw.loadJsonData for " .. ACHIEVEMENT_DATA_PAGE)
            
            local loadJsonSuccess, jsonData = pcall(function()
                return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
            end)
            
            if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
                debugLog("Successfully loaded data with mw.loadJsonData")
                return jsonData
            else
                debugLog("mw.loadJsonData failed: " .. tostring(jsonData or 'unknown error'))
            end
        else
            debugLog("mw.loadJsonData not available, falling back to direct content loading")
        end
        
        -- Direct content loading approach as fallback
        local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
        if not pageTitle or not pageTitle.exists then
            debugLog(ACHIEVEMENT_DATA_PAGE .. " does not exist")
            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
            debugLog("Successfully retrieved raw content, length: " .. #content)
            
            -- 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
                debugLog("Removing UTF-8 BOM from content")
                content = content:sub(4)
            end
            
            -- Use mw.text.jsonDecode for parsing WITHOUT PRESERVE_KEYS flag
            if mw.text and mw.text.jsonDecode then
                local jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(content)
                end)
                
                if jsonDecodeSuccess and jsonData then
                    debugLog("Successfully decoded content with mw.text.jsonDecode")
                    return jsonData
                else
                    debugLog("mw.text.jsonDecode failed: " .. tostring(jsonData or 'unknown error'))
                end
            else
                debugLog("mw.text.jsonDecode not available")
            end
        else
            debugLog("Failed to get content: " .. tostring(content or 'unknown error'))
        end
        
        -- As absolute last resort, use local default data
        debugLog("All JSON loading approaches failed, using default data")
        return DEFAULT_DATA
    end)

    if not success or not data then
        debugLog("Critical error in load process: " .. tostring(data or 'unknown error'))
        data = DEFAULT_DATA
    end

    -- Show success source in log
    if data ~= DEFAULT_DATA then
        debugLog("Successfully loaded JSON data with " .. tostring(#(data.achievement_types or {})) .. " achievement types")
    end

    dataCache = data
    return data
end

--------------------------------------------------------------------------------
-- Get user achievements with multiple lookup methods
--------------------------------------------------------------------------------
function Achievements.getUserAchievements(pageId)
    if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getUserAchievements")
        return {}
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        debugLog("No achievement data available in getUserAchievements")
        return {}
    end

    local key = tostring(pageId)
    debugLog("Looking up achievements for ID: " .. key)
    
    -- Try string key first
    local userAchievements = data.user_achievements[key] or {}
    if #userAchievements > 0 then
        debugLog("Found achievements using string key: " .. key)
        return ensureArray(userAchievements)
    end
    
    -- Try numeric key if string key didn't work
    local numKey = tonumber(key)
    if numKey and data.user_achievements[numKey] then
        debugLog("Found achievements using numeric key: " .. numKey)
        return ensureArray(data.user_achievements[numKey])
    end
    
    -- Try legacy "n123" style
    if key:match("^%d+$") then
        local alt = "n" .. key
        if data.user_achievements[alt] and #data.user_achievements[alt] > 0 then
            debugLog("Found achievements using legacy key: " .. alt)
            return ensureArray(data.user_achievements[alt])
        end
    end
    
    -- Try string comparison as last resort
    for userId, achievements in pairs(data.user_achievements) do
        if tostring(userId) == key then
            debugLog("Found achievements using string comparison with key type: " .. type(userId))
            return ensureArray(achievements)
        end
    end
    
    debugLog("No achievements found for user " .. key)
    return {}
end

--------------------------------------------------------------------------------
-- Check if a page/user 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
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType)
    if not achievementType or achievementType == '' then
        debugLog("Empty achievement type provided to getAchievementName")
        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
        debugLog("No achievement data or achievement_types missing")
        return achievementType
    end

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

    -- Special case for dev-role lookup
    if achievementType == "dev-role" then
        debugLog("Could not find dev-role in achievement_types!")
    end

    debugLog("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 userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        debugLog("No achievements found for user " .. tostring(pageId))
        return '', ''
    end

    local data = Achievements.loadData()
    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 " .. tostring(pageId))
        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 userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return ''
    end
    
    local data = Achievements.loadData()
    
    -- 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

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

    if not pageId or not achievementType or pageId == '' then
        debugLog("Invalid arguments for getSpecificAchievement")
        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
            debugLog("Found achievement: " .. achievementType .. " for user " .. tostring(pageId))
            return achievement
        end
    end

    debugLog("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

--------------------------------------------------------------------------------
-- Diagnostic Function: Log badges for a page to console
--------------------------------------------------------------------------------
function Achievements.debugBadgesForPage(pageId)
    if not pageId or pageId == '' then
        mw.log("ACHIEVEMENT-BADGES: No page ID provided")
        return "ERROR: No page ID provided"
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        mw.log("ACHIEVEMENT-BADGES: No achievements found for page ID " .. pageId)
        return "No achievements found for page ID " .. pageId
    end

    mw.log("ACHIEVEMENT-BADGES: Found " .. #userAchievements .. " achievements for page ID " .. pageId)
    
    -- Log each achievement to console
    for i, achievement in ipairs(userAchievements) do
        local achType = achievement.type or "nil"
        local typeDef = Achievements.getAchievementDefinition(achType)
        
        if typeDef then
            mw.log("ACHIEVEMENT-BADGES: [" .. i .. "] " .. achType .. 
                  " (Name: " .. (typeDef.name or "unnamed") .. 
                  ", Type: " .. (typeDef.type or "unspecified") .. 
                  ", Tier: " .. (typeDef.tier or "none") .. ")")
        else
            mw.log("ACHIEVEMENT-BADGES: [" .. i .. "] " .. achType .. " (WARNING: No definition found)")
        end
    end
    
    return "Found " .. #userAchievements .. " achievements for page ID " .. pageId
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)
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(pageId)
    if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getTitleAchievement")
        return '', '', ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        debugLog("No achievements found for user " .. tostring(pageId))
        return '', '', ''
    end

    local data = Achievements.loadData()
    
    -- Build a table of achievement definitions for quick lookup
    local typeDefinitions = {}
    for _, typeData in ipairs(data.achievement_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
        debugLog("No title achievement found for user " .. tostring(pageId))
        return '', '', ''
    end

    local achievementId = titleAchievement.id
    local displayName = titleAchievement.name or achievementId
    
    debugLog("Found title achievement: " .. achievementId .. " with name: " .. displayName)
    return achievementId, displayName, achievementId
end

--------------------------------------------------------------------------------
-- Simplified diagnostic function for JSON loading issues
--------------------------------------------------------------------------------
function Achievements.diagnoseJsonLoading()
    mw.log("ACHIEVEMENT-DIAG: Starting JSON diagnostics")
    
    -- Check MediaWiki capabilities
    if not mw.loadJsonData then
        mw.log("ACHIEVEMENT-DIAG: ERROR - mw.loadJsonData not available!")
    end
    
    if not (mw.text and mw.text.jsonDecode) then
        mw.log("ACHIEVEMENT-DIAG: CRITICAL ERROR - mw.text.jsonDecode not available!")
        return "JSON decoding not available"
    end
    
    -- Check page existence
    local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
    if not pageTitle or not pageTitle.exists then
        mw.log("ACHIEVEMENT-DIAG: ERROR - " .. ACHIEVEMENT_DATA_PAGE .. " does not exist")
        return "JSON data page does not exist"
    end
    
    -- Check content model
    if pageTitle.contentModel and pageTitle.contentModel ~= "json" then
        mw.log("ACHIEVEMENT-DIAG: ERROR - Incorrect content model: " .. pageTitle.contentModel)
        mw.log("ACHIEVEMENT-DIAG: Page must be set to 'json' content model")
        return "Incorrect content model: " .. pageTitle.contentModel
    end
    
    -- Try to load JSON data
    local loadJsonSuccess, loadJsonResult = pcall(function()
        return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
    end)
    
    if not loadJsonSuccess then
        mw.log("ACHIEVEMENT-DIAG: ERROR - Failed to load JSON: " .. tostring(loadJsonResult or "unknown error"))
        
        -- Try to get raw content for further diagnosis
        local contentSuccess, content = pcall(function()
            return pageTitle:getContent()
        end)
        
        if contentSuccess and content and content ~= "" then
            -- Check for common issues
            if content:match("^<!DOCTYPE") or content:match("^<[Hh][Tt][Mm][Ll]") then
                mw.log("ACHIEVEMENT-DIAG: CRITICAL ERROR - Content appears to be HTML, not JSON!")
            elseif not content:match("^%s*{") then
                mw.log("ACHIEVEMENT-DIAG: ERROR - Content does not start with {")
            end
            
            -- Try direct JSON decoding as fallback
            local jsonDecodeSuccess, _ = pcall(function()
                return mw.text.jsonDecode(content)
            end)
            
            if not jsonDecodeSuccess then
                mw.log("ACHIEVEMENT-DIAG: ERROR - JSON syntax error in content")
            end
        else
            mw.log("ACHIEVEMENT-DIAG: ERROR - Could not read page content")
        end
        
        return "JSON loading failed"
    end
    
    -- Verify data structure
    if type(loadJsonResult) ~= "table" then
        mw.log("ACHIEVEMENT-DIAG: ERROR - Loaded data is not a table")
        return "Invalid JSON structure"
    end
    
    if not loadJsonResult.achievement_types then
        mw.log("ACHIEVEMENT-DIAG: ERROR - Missing achievement_types array")
        return "Missing achievement_types in JSON"
    end
    
    if not loadJsonResult.user_achievements then
        mw.log("ACHIEVEMENT-DIAG: ERROR - Missing user_achievements object")
        return "Missing user_achievements in JSON"
    end
    
    -- Success
    mw.log("ACHIEVEMENT-DIAG: JSON loading successful")
    mw.log("ACHIEVEMENT-DIAG: Found " .. #loadJsonResult.achievement_types .. " achievement types")
    
    local userCount = 0
    for _, _ in pairs(loadJsonResult.user_achievements) do
        userCount = userCount + 1
    end
    mw.log("ACHIEVEMENT-DIAG: Found achievements for " .. userCount .. " users")
    
    return "JSON diagnostics complete - all checks passed"
end

return Achievements