Jump to content

Module:AchievementSystem: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
 
(74 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:AchievementSystem
--[[
-- Unified achievement module that combines data loading and achievement functionality.
* Name: AchievementSystem
-- This module: 1) Loads and caches achievement data from MediaWiki:AchievementData.json;
* Author: Mark W. Datysgeld
-- 2) Retrieves achievement information for users; 3) Renders achievement displays for
* Description: Comprehensive achievement system that manages user badges and titles throughout ICANNWiki, loading data from MediaWiki JSON files and providing rendering functions for Person templates
-- Person templates; 4) Tracks pages using achievements for cache invalidation.
* Notes: Loads from MediaWiki:AchievementData.json (user assignments) and MediaWiki:AchievementList.json (type definitions). CSS styling defined in Templates.css using achievement-{id} format. Includes caching and fallback mechanisms for robust JSON handling
]]
 
---@class UserAchievement
---@field type string
---@field date? string


local Achievements = {}
local Achievements = {}


-- Debug configuration
--------------------------------------------------------------------------------
local DEBUG_MODE = true
-- JSON Handling
local function debugLog(message)
--------------------------------------------------------------------------------
     if not DEBUG_MODE then return end
-- Helper function to ensure we get an array
local function ensureArray(value)
     if type(value) ~= "table" then
        return {}
    end
      
      
     -- Format the message for visibility
     -- Check if it's an array-like table
     local formattedMsg = "ACHIEVEMENT-DEBUG: " .. message
    local isArray = true
     local count = 0
    for _ in pairs(value) do
        count = count + 1
    end
      
      
     -- Log to MediaWiki's debug log
     -- If it has no numeric indices or is empty, return empty array
     mw.log(formattedMsg)
     if count == 0 then
        return {}
    end
      
      
     -- Also try to log to JavaScript console if possible
     -- If it's a single string, wrap it in an array
     pcall(function()
    if count == 1 and type(value[1]) == "string" then
         mw.logObject({debug="achievement", message=formattedMsg})
        return {value[1]}
     end)
    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
end


-- Try to load JSON module with error handling
-- Use MediaWiki's built-in JSON functions directly
local json
local function jsonDecode(jsonString)
local jsonLoaded = pcall(function()
    if not jsonString then return nil end
    json = require('Module:JSON')
   
end)
    if mw.text and mw.text.jsonDecode then
 
        local success, result = pcall(function()
-- If JSON module failed to load, create a minimal fallback
            -- Use WITHOUT PRESERVE_KEYS flag to ensure proper array handling
if not jsonLoaded or not json then
            return mw.text.jsonDecode(jsonString)
    json = {
        end)
         decode = function() return nil end
       
     }
        if success and result then
     debugLog('WARNING: Module:JSON not available, achievement features will be limited')
            return result
         end
     end
   
     return nil
end
end


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


-- Constants
--------------------------------------------------------------------------------
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local CACHE_VERSION_KEY = 'achievement_cache_version'
local ACHIEVEMENT_LIST_PAGE = 'MediaWiki:AchievementList.json'
local DEFAULT_CACHE_VERSION = 0
 
-- Cache for achievement data (within request)
local dataCache = nil
local dataCache = nil
local cacheVersion = nil
local typesCache = nil


-- Default data structure to use if loading fails
local DEFAULT_DATA = {
local DEFAULT_DATA = {  
     schema_version = 2,
     schema_version = 1,
     last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
     last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
     achievement_types = {},  
     achievement_types = {},
     user_achievements = {},
     user_achievements = {},
    cache_control = { version = DEFAULT_CACHE_VERSION }
}
}


-- Safely attempts to get page content, returns nil on error or if page doesn't exist
--------------------------------------------------------------------------------
local function safeGetPageContent(pageName)
-- Load achievement types from the JSON page
     local success, result = pcall(function()
-- @param frame - The Scribunto frame object for preprocessing
         local page = mw.title.new(pageName)
-- @return Array of achievement type definitions
         if not page or not page.exists then
--------------------------------------------------------------------------------
             debugLog("Page does not exist: " .. pageName)
function Achievements.loadTypes(frame)
             return nil
    -- 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
         end
          
          
         local content = page:getContent()
         -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not content or content == '' then
        if not jsonText then
             debugLog("Page exists but content is empty: " .. pageName)
            -- Try using mw.loadJsonData first (preferred method)
             return nil
            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
         end
          
          
         return content
         -- As an absolute last resort, return an empty array
        return {}
     end)
     end)
   
 
     if not success then
     if not success or not types then
         debugLog('Error getting page content: ' .. (result or 'unknown error'))
         -- If there was an error, fall back to AchievementData.json
         return nil
        local data = Achievements.loadData(frame)
         if data and data.achievement_types then
            typesCache = data.achievement_types
            return typesCache
        end
        types = {}
     end
     end
      
 
     return result
     typesCache = types
     return types
end
end


-- Safely attempts to parse JSON, returns nil on error
--------------------------------------------------------------------------------
local function safeParseJSON(jsonString)
-- Load achievement data from the JSON page
    if not jsonString then return nil end
-- @param frame - The Scribunto frame object for preprocessing
    if not json or not json.decode then return nil end
-- @return Table containing the full achievement data
   
--------------------------------------------------------------------------------
    local success, data = pcall(function() return json.decode(jsonString) end)
function Achievements.loadData(frame)
     if not success then
    -- Use the request-level cache if we already loaded data once
        debugLog('Error parsing JSON: ' .. (data or 'unknown error'))
     if dataCache then
         return nil
         return dataCache
     end
     end
   
    return data
end


--[[
     local success, data = pcall(function()
Loads achievement data from MediaWiki:AchievementData.json with intelligent caching
         -- Get the JSON content using frame:preprocess if available
This combines functionality from both the old AchievementData.lua and AchievementSystem.lua
        local jsonText
 
         if frame and type(frame) == "table" and frame.preprocess then
@param forceFresh boolean If true, bypasses all caching and loads directly from wiki
             -- Make sure frame is valid and has preprocess method
@return table The achievement data structure or default empty structure on failure
            local preprocessSuccess, preprocessResult = pcall(function()
]]
                return frame:preprocess('{{MediaWiki:AchievementData.json}}')
function Achievements.loadData(forceFresh)
            end)
     local success, result = pcall(function()
              
         -- Check if we can use the request-level cache
            if preprocessSuccess and preprocessResult then
         if dataCache and not forceFresh then
                jsonText = preprocessResult
             debugLog("Using request-level cached achievement data")
            end
             return dataCache
         end
         end
          
          
         -- Check if we should try to load from parser cache using mw.loadData
         -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not forceFresh then
        if not jsonText then
            local loadDataSuccess, cachedData = pcall(function()
            -- Try using mw.loadJsonData first (preferred method)
                 -- Use mw.loadData to get cached data if available
            if mw.loadJsonData then
                 return mw.loadData('Module:AchievementSystem')
                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)
             end)
              
              
             if loadDataSuccess and cachedData then
             if contentSuccess and content and content ~= "" then
                 debugLog("Using mw.loadData cached achievement data")
                 -- Remove any BOM or leading whitespace that might cause issues
                 dataCache = cachedData -- Update request cache
                content = content:gsub("^%s+", "")
                 return cachedData
                 if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                    content = content:sub(4)
                end
               
                 jsonText = content
             else
             else
                 debugLog("No mw.loadData cached data available or error loading it")
                 return DEFAULT_DATA
             end
             end
        else
            debugLog("CACHE BYPASS ENABLED: Loading directly from wiki page")
         end
         end
          
          
         -- Load data directly from the wiki
         -- Try different JSON decode approaches
         debugLog("Loading achievement data directly from " .. ACHIEVEMENT_DATA_PAGE)
         if jsonText and mw.text and mw.text.jsonDecode then
        local content = safeGetPageContent(ACHIEVEMENT_DATA_PAGE)
            -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
       
             local jsonDecodeSuccess, jsonData = pcall(function()
        -- Debug the raw JSON content
                return mw.text.jsonDecode(jsonText)
        if content then
            end)
             debugLog("Loaded raw JSON (" .. #content .. " bytes): " .. content:sub(1, 100) .. "...")
              
              
            -- Detailed debug logging in development mode
             if jsonDecodeSuccess and jsonData then
             if DEBUG_MODE then
                 return jsonData
                 debugLog("FULL RAW JSON (first 500 chars): " .. content:sub(1, 500))
                debugLog("FULL RAW JSON (last 500 chars): " .. content:sub(-500))
             end
             end
        else
            debugLog("ERROR: Could not load content from " .. ACHIEVEMENT_DATA_PAGE)
        end
       
        local data = safeParseJSON(content)
       
        -- If something went wrong, use default empty data
        if not data then
            debugLog("ERROR: Failed to parse JSON data - using default empty structure")
            data = DEFAULT_DATA
        else
            -- Debug the structure of the loaded JSON
            debugLog("JSON data structure validation:")
            debugLog("  - Has schema_version: " .. tostring(data.schema_version ~= nil))
            debugLog("  - Has achievement_types: " .. tostring(data.achievement_types ~= nil))
            debugLog("  - Has user_achievements: " .. tostring(data.user_achievements ~= nil))
              
              
             -- Debug user_achievements keys
             -- If that failed, try with JSON_TRY_FIXING flag
             if data.user_achievements then
             jsonDecodeSuccess, jsonData = pcall(function()
                debugLog("user_achievements keys found:")
                return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                local keyCount = 0
             end)
                for k, _ in pairs(data.user_achievements) do
                    keyCount = keyCount + 1
                    debugLog("  - Key: '" .. tostring(k) .. "' (type: " .. type(k) .. ")")
                    -- Limit to first 10 keys to avoid log flooding
                    if keyCount >= 10 then
                        debugLog("  (showing first 10 keys only)")
                        break
                    end
                end
             end
              
              
            -- Count the achievement entries for debug purposes
             if jsonDecodeSuccess and jsonData then
            local count = 0
                 return jsonData
             if DEBUG_MODE then
                 debugLog("Achievement entries:")
                for k, v in pairs(data.user_achievements or {}) do
                    count = count + 1
                    debugLog("User/Page ID found: " .. k .. " with " .. #v .. " achievements")
                end
            else
                -- Just count in production mode
                for k, v in pairs(data.user_achievements or {}) do count = count + 1 end
             end
             end
            debugLog("Loaded achievement data with " .. count .. " entries")
         end
         end
       
         -- As an absolute last resort, use local default data
         -- Update request cache so we don't need to reload within this page render
         return DEFAULT_DATA
        dataCache = data
       
         return data
     end)
     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 = {}
---@return UserAchievement[]
function Achievements.getUserAchievements(pageId)
    if not pageId or pageId == '' then
        return {}
    end
      
      
     if not success then
     -- Check cache first
        debugLog('Error in loadData: ' .. (result or 'unknown error'))
    local cacheKey = tostring(pageId)
         return DEFAULT_DATA
    if userAchievementsCache[cacheKey] then
         return userAchievementsCache[cacheKey]
     end
     end
   
    return result
end


--[[
    local data = Achievements.loadData()
Checks if a user has any achievements
    if not data or not data.user_achievements then
        return {}
    end


@param identifier string|number The page ID or username to check
    local key = cacheKey
@return boolean True if the user has any achievements, false otherwise
    local userEntry = data.user_achievements[key]
]]
   
function Achievements.hasAchievements(identifier)
    -- If found with string key, return achievements
    local success, result = pcall(function()
    if userEntry and userEntry.achievements then
         if not identifier or identifier == '' then return false end
        local achievements = ensureArray(userEntry.achievements)
       
         userAchievementsCache[cacheKey] = achievements
        local data = Achievements.loadData()
        return achievements
         if not data or not data.user_achievements then
    end
             return false
   
    -- 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
       
     end
        -- Handle both page IDs and usernames for backward compatibility
        local key = identifier
        if type(identifier) == 'string' and not tonumber(identifier) then
            -- This is a username, normalize it
            if not identifier:match('^User:') then
                key = 'User:' .. identifier
            end
        else
            -- This is a page ID, ensure it's treated as a string for table lookup
            key = tostring(identifier)
        end
       
        local userAchievements = data.user_achievements[key]
       
        return userAchievements and #userAchievements > 0
     end)
      
      
     if not success then
     -- Cache empty result to avoid repeated lookups
        mw.log('Error in hasAchievements: ' .. (result or 'unknown error'))
    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
         return false
     end
     end
      
 
     return result
     local userAchievements = Achievements.getUserAchievements(pageId)
     return #userAchievements > 0
end
end


--[[
--------------------------------------------------------------------------------
Gets the highest tier achievement for a user
-- Get all badge-type achievements for a user
-- @param pageId - The page ID to check
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of badge achievement objects
--------------------------------------------------------------------------------
function Achievements.getBadgeAchievements(pageId, frame)
    if not pageId or pageId == '' then
        return {}
    end
 
    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return {}
    end


@param identifier string|number The page ID or username to check
    local types = Achievements.loadTypes(frame)
@return table|nil The achievement type data or nil if user has no achievements
   
]]
    -- Build a lookup table for achievement types for efficient access
function Achievements.getHighestAchievement(identifier)
     local typeDefinitions = {}
     local success, result = pcall(function()
    for _, typeData in ipairs(types) do
         if not identifier or identifier == '' then  
         if typeData.id and typeData.type then
             debugLog("getHighestAchievement: Empty identifier provided")
             typeDefinitions[typeData.id] = typeData
            return nil
         end
         end
       
    end
        local data = Achievements.loadData()
   
         if not data or not data.user_achievements then
    local badgeAchievements = {}
             debugLog("getHighestAchievement: No achievement data available")
    -- Filter user achievements to only include badge types
            return nil
    for _, achievementTbl in ipairs(userAchievements) do
        local achType = achievementTbl['type']
         if achType and typeDefinitions[achType] and typeDefinitions[achType]['type'] == "badge" then
                local newAchievement = {
                    type = achType,
                    date = achievementTbl['date'] or '',
                    name = typeDefinitions[achType].name or achType,
                    category = typeDefinitions[achType].category
                }
             table.insert(badgeAchievements, newAchievement)
         end
         end
       
    end
        -- Handle both page IDs and usernames for backward compatibility
 
        local key = identifier
    return badgeAchievements
        if type(identifier) == 'string' and not tonumber(identifier) then
end
            -- This is a username, normalize it
 
            if not identifier:match('^User:') then
--------------------------------------------------------------------------------
                key = 'User:' .. identifier
-- Get a user-friendly name for a given achievement type
            end
-- @param achievementType - The achievement type ID
            debugLog("Searching for username: " .. key)
-- @param frame - The Scribunto frame object for preprocessing
        else
-- @return String containing the user-friendly name
            -- This is a page ID, ensure it's treated as a string for table lookup
--------------------------------------------------------------------------------
            key = tostring(identifier)
function Achievements.getAchievementName(achievementType, frame)
            debugLog("Searching for Page ID: " .. key .. " (type: " .. type(key) .. ")")
    if not achievementType or achievementType == '' then
        end
        return 'Unknown'
       
    end
        -- Debug the user_achievements structure
 
        debugLog("All available user_achievements keys:")
    local types = Achievements.loadTypes(frame)
        for k, _ in pairs(data.user_achievements or {}) do
   
            debugLog("  - " .. k .. " (type: " .. type(k) .. ")")
    -- Try to match achievement ID
        end
    for _, typeData in ipairs(types) do
       
        if typeData.id == achievementType then
        -- Direct key tests for specific IDs we know should exist
            if typeData.name and typeData.name ~= "" then
        debugLog("DIRECT KEY TESTS:")
                return typeData.name
        debugLog("  - Key '18451' exists: " .. tostring(data.user_achievements["18451"] ~= nil))
            else
        debugLog("  - Key 18451 (numeric) exists: " .. tostring(data.user_achievements[18451] ~= nil))
                return achievementType
        debugLog("  - Key 'n18451' exists: " .. tostring(data.user_achievements["n18451"] ~= nil))
       
        -- Dump the contents if found to verify structure
        if data.user_achievements["18451"] then
            local achievement = data.user_achievements["18451"][1]
            debugLog("  - '18451' first achievement type: " .. tostring(achievement.type))
        end
        if data.user_achievements["n18451"] then
            local achievement = data.user_achievements["n18451"][1]
            debugLog("  - 'n18451' first achievement type: " .. tostring(achievement.type))
        end
       
        -- Check if our key exists directly
        local keyExists = data.user_achievements[key] ~= nil
        debugLog("Key '" .. key .. "' exists in user_achievements: " .. tostring(keyExists))
       
        local userAchievements = data.user_achievements[key]
       
        if not userAchievements or #userAchievements == 0 then
            debugLog("No achievements found for identifier: " .. key)
           
            -- Try multiple fallback key formats to find achievements
            debugLog("TRYING ALTERNATIVE KEY FORMATS TO FIND ACHIEVEMENTS")
           
            -- Try additional formats for Page ID
            local fallbackKeys = {
                ["n" .. key] = "n-prefixed version",
                ["page-" .. key] = "page-prefixed version"
            }
           
            -- Try numeric form if it's a string with numbers
            if tonumber(key) then
                fallbackKeys[tonumber(key)] = "numeric conversion"
            end
           
            -- Try each alternative format
            for tryKey, keyDesc in pairs(fallbackKeys) do
                debugLog("Trying " .. keyDesc .. ": " .. tostring(tryKey))
                userAchievements = data.user_achievements[tryKey]
               
                if userAchievements and #userAchievements > 0 then
                    debugLog("SUCCESS! Found achievements using " .. keyDesc)
                    key = tryKey
                    break
                else
                    debugLog("No achievements found with " .. keyDesc)
                end
            end
           
            -- Still nothing found after trying all formats
            if not userAchievements or #userAchievements == 0 then
                debugLog("All fallback attempts failed, no achievements found")
               
                -- Last resort - direct key injection for testing
                if key == "18451" or key == 18451 then
                    debugLog("EMERGENCY OVERRIDE: Injecting test achievement for 18451")
                    -- Create a synthetic achievement entry for testing
                    userAchievements = {
                        {
                            type = "jedi",
                            granted_date = os.date('!%Y-%m-%dT%H:%M:%SZ'),
                            granted_by = "debug-injection",
                            source = "debug"
                        }
                    }
                else
                    -- Last resort - dump all data for inspection
                    debugLog("LAST RESORT: Dumping first achievement entry for debugging")
                    for k, v in pairs(data.user_achievements) do
                        if v and #v > 0 then
                            debugLog("Sample achievement found for key: " .. k ..
                                    " type: " .. type(k) ..
                                    " achievement type: " .. v[1].type)
                            break
                        end
                    end
                   
                    return nil
                end
             end
             end
         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"]
          
          
        debugLog("Found " .. #userAchievements .. " achievement(s) for identifier: " .. key)
         for _, typeData in ipairs(types) do
       
             if typeData.id == achType then
        -- Find achievement with lowest tier number (highest importance)
                local tier = typeData.tier or 999
        local highestAchievement = nil
                if tier < highestTier then
        local highestTier = 999
                    highestTier = tier
       
         for _, achievement in ipairs(userAchievements) do
             local achievementType = achievement.type
            debugLog("Checking achievement type: " .. achievementType)
            for _, typeData in ipairs(data.achievement_types or {}) do
                if typeData.id == achievementType and (typeData.tier or 999) < highestTier then
                     highestAchievement = typeData
                     highestAchievement = typeData
                    highestTier = typeData.tier or 999
                    debugLog("Found higher tier achievement: " .. typeData.id .. " (tier " .. (typeData.tier or "unknown") .. ")")
                 end
                 end
             end
             end
         end
         end
       
    end
        if highestAchievement then
 
            debugLog("Highest achievement for " .. key .. ": " .. highestAchievement.id)
    if not highestAchievement or not highestAchievement.id then
        else
        return '', ''
            debugLog("No matching achievement type found in achievement_types")
    end
        end
 
       
    local cssClass = "achievement-" .. highestAchievement.id
        return highestAchievement
    local displayName = highestAchievement.name or highestAchievement.id or "Award"
    end)
      
      
     if not success then
    return cssClass, displayName
         debugLog('Error in getHighestAchievement: ' .. (result or 'unknown error'))
end
         return nil
 
--------------------------------------------------------------------------------
-- 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
     end
      
      
     return result
     local types = Achievements.loadTypes(frame)
end
   
    -- 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)
Gets the CSS class for the highest achievement to be applied to the template title
    local highestTier = 999
    local topAchType = nil


@param identifier string|number The page ID or username to check
     for _, achievement in ipairs(userAchievements) do
@return string The CSS class name or empty string if no achievement
         local achType = achievement.type
]]
         if typeDefinitions[achType] and typeDefinitions[achType].tier < highestTier then
function Achievements.getTitleClass(identifier)
             highestTier = typeDefinitions[achType].tier
     local success, result = pcall(function()
             topAchType = achType
        debugLog("getTitleClass called with identifier: " .. tostring(identifier))
         local achievement = Achievements.getHighestAchievement(identifier)
         if not achievement or not achievement.id then  
             debugLog("No achievement found, returning empty class")
             return ''
         end
         end
    end
    -- If we found an achievement, render it
    if topAchType and typeDefinitions[topAchType] then
        local achName = typeDefinitions[topAchType].name or topAchType
          
          
         local className = 'achievement-' .. achievement.id
         return string.format(
         debugLog("Returning CSS class: " .. className)
            '<div class="achievement-box-simple" data-achievement-type="%s">%s</div>',
         return className
            topAchType,
     end)
            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
      
      
     if not success then
    local data = Achievements.loadData()
        debugLog('Error in getTitleClass: ' .. (result or 'unknown error'))
     if not data or not data.user_achievements then
         return ''
         return ''
     end
     end
      
      
     return result
     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
end


--[[
--------------------------------------------------------------------------------
Gets all achievements for a user, formatted for display
-- 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


@param identifier string|number The page ID or username to check
    local userAchievements = Achievements.getUserAchievements(pageId)
@return table Array of achievement data objects for display
      
]]
    -- Direct lookup for the requested achievement type
function Achievements.getUserAchievements(identifier)
    for _, achievementTbl in ipairs(userAchievements) do
     local success, result = pcall(function()
         if achievementTbl["type"] == achievementType then
         if not identifier or identifier == '' then return {} end
local def = Achievements.getAchievementDefinition(achievementType)
       
            return {
        local data = Achievements.loadData()
                type    = achievementTbl.type,
        if not data or not data.user_achievements then
                date    = achievementTbl.date or '',
             return {}
                name    = def and def.name or achievementType,
                category = def and def.category
             }
         end
         end
       
    end
        -- Handle both page IDs and usernames for backward compatibility
 
        local key = identifier
    return nil
        if type(identifier) == 'string' and not tonumber(identifier) then
end
            -- This is a username, normalize it
 
            if not identifier:match('^User:') then
--------------------------------------------------------------------------------
                key = 'User:' .. identifier
-- Get achievement definition directly from JSON data
            end
-- @param achievementType - The achievement type ID to get the definition for
        else
-- @param frame - The Scribunto frame object for preprocessing
            -- This is a page ID, ensure it's treated as a string for table lookup
-- @return Achievement type definition or nil if not found
            key = tostring(identifier)
--------------------------------------------------------------------------------
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
          
    end
        local userAchievements = data.user_achievements[key] or {}
   
        local results = {}
    return nil
          
end
        for _, achievement in ipairs(userAchievements) do
 
            if achievement and achievement.type then
--------------------------------------------------------------------------------
                local achievementType = achievement.type
-- Find and return title achievement for the user if one exists
                for _, typeData in ipairs(data.achievement_types or {}) do
-- This specifically looks for achievements with type="title"
                    if typeData.id == achievementType then
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
                        table.insert(results, {
-- @param pageId - The page ID to get the title achievement for
                            id = typeData.id,
-- @param frame - The Scribunto frame object for preprocessing
                            name = typeData.name,
-- @return achievementId, displayName, achievementId
                            description = typeData.description,
--------------------------------------------------------------------------------
                            icon = typeData.display and typeData.display.icon or '',
function Achievements.getTitleAchievement(pageId, frame)
                            color = typeData.display and typeData.display.color or '',
    if not pageId or pageId == '' then
                            background = typeData.display and typeData.display.background or '',
         return nil
                            granted_date = achievement.granted_date
    end
                        })
 
                     end
    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
         return nil
    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
         end
         end
       
    end
        -- Sort by tier
 
        table.sort(results, function(a, b)
    return titleAchievement
            local tierA = 999
end
            local tierB = 999
 
           
-- Renders a title block with achievement integration
            for _, typeData in ipairs(data.achievement_types or {}) do
function Achievements.renderTitleBlockWithAchievement(args, titleClass, titleText, achievementClass, achievementId, achievementName)
                if typeData.id == a.id then tierA = typeData.tier or 999 end
    titleClass = titleClass or "template-title"
                if typeData.id == b.id then tierB = typeData.tier or 999 end
            end
           
            return tierA < tierB
        end)
       
        return results
    end)
      
      
     if not success then
     -- Only add achievement attributes if they exist
         mw.log('Error in getUserAchievements: ' .. (result or 'unknown error'))
    if achievementClass and achievementClass ~= "" and achievementId and achievementId ~= "" then
        return {}
         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 result
end
end


--[[
--------------------------------------------------------------------------------
Renders HTML for an achievement box to display in templates
-- Generate wikitext category links for a given list of achievements
-- @param achievements - An array of user achievement objects
-- @param frame - The Scribunto frame object
-- @return A string of wikitext category links, e.g., "[[Category:Cat1]][[Category:Cat2]]"
--------------------------------------------------------------------------------
function Achievements.getCategoryLinks(achievements, frame)
    if not achievements or #achievements == 0 then
        return ""
    end
 
    local types = Achievements.loadTypes(frame)
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end
 
    local categoryLinks = {}
    local foundCategories = {} -- To prevent duplicate categories


@param identifier string|number The page ID or username to render achievements for
    for _, ach in ipairs(achievements) do
@return string HTML for the achievement box or empty string if no achievements
         local achType = ach['type']
]]
         local definition = typeDefinitions[achType]
function Achievements.renderAchievementBox(identifier)
    local success, result = pcall(function()
         local achievements = Achievements.getUserAchievements(identifier)
        if not achievements or #achievements == 0 then return '' end
       
         local html = '<div class="achievement-box">'
        html = html .. '<div class="achievement-box-title">Achievements</div>'
        html = html .. '<div class="achievement-badges">'
          
          
         for _, achievement in ipairs(achievements) do
         if definition and definition.category and definition.category ~= "" and not foundCategories[definition.category] then
            html = html .. string.format(
            table.insert(categoryLinks, "[[Category:" .. definition.category .. "]]")
                '<div class="achievement-badge" style="color: %s; background-color: %s;" title="%s">%s %s</div>',
             foundCategories[definition.category] = true
                achievement.color or '',
                achievement.background or '',
                htmlEncode(achievement.description or ''),
                achievement.icon or '',
                htmlEncode(achievement.name or '')
             )
         end
         end
       
        html = html .. '</div></div>'
        return html
    end)
   
    if not success then
        mw.log('Error in renderAchievementBox: ' .. (result or 'unknown error'))
        return ''
     end
     end
   
    return result
end
--[[
Tracks a page that displays achievements for cache purging
Note: This would ideally update the JSON with page references, but
      for now we rely on the cache version mechanism for invalidation


@param pageId number|string The page ID to track
    return table.concat(categoryLinks)
@param pageName string The page name (for reference)
@return boolean Always returns true (for future expansion)
]]
function Achievements.trackPage(pageId, pageName)
    -- This function is designed to be safe by default
    return true
end
end


-- Return the module
return Achievements
return Achievements

Latest revision as of 02:56, 25 August 2025

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

--[[
* Name: AchievementSystem
* Author: Mark W. Datysgeld
* Description: Comprehensive achievement system that manages user badges and titles throughout ICANNWiki, loading data from MediaWiki JSON files and providing rendering functions for Person templates
* Notes: Loads from MediaWiki:AchievementData.json (user assignments) and MediaWiki:AchievementList.json (type definitions). CSS styling defined in Templates.css using achievement-{id} format. Includes caching and fallback mechanisms for robust JSON handling
]]

---@class UserAchievement
---@field type string
---@field date? string

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('&', '&amp;')
            :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 = {}

---@return UserAchievement[]
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 all badge-type achievements for a user
-- @param pageId - The page ID to check
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of badge achievement objects
--------------------------------------------------------------------------------
function Achievements.getBadgeAchievements(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 types for efficient access
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        if typeData.id and typeData.type then
            typeDefinitions[typeData.id] = typeData
        end
    end
    
    local badgeAchievements = {}
    -- Filter user achievements to only include badge types
    for _, achievementTbl in ipairs(userAchievements) do
        local achType = achievementTbl['type']
        if achType and typeDefinitions[achType] and typeDefinitions[achType]['type'] == "badge" then
                local newAchievement = {
                    type = achType,
                    date = achievementTbl['date'] or '',
                    name = typeDefinitions[achType].name or achType,
                    category = typeDefinitions[achType].category
                }
            table.insert(badgeAchievements, newAchievement)
        end
    end

    return badgeAchievements
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 _, achievementTbl in ipairs(userAchievements) do
        if achievementTbl["type"] == achievementType then
local def = Achievements.getAchievementDefinition(achievementType)
            return {
                type     = achievementTbl.type,
                date     = achievementTbl.date or '',
                name     = def and def.name or achievementType,
                category = def and def.category
            }
        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 nil
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return nil
    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

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

--------------------------------------------------------------------------------
-- Generate wikitext category links for a given list of achievements
-- @param achievements - An array of user achievement objects
-- @param frame - The Scribunto frame object
-- @return A string of wikitext category links, e.g., "[[Category:Cat1]][[Category:Cat2]]"
--------------------------------------------------------------------------------
function Achievements.getCategoryLinks(achievements, frame)
    if not achievements or #achievements == 0 then
        return ""
    end

    local types = Achievements.loadTypes(frame)
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end

    local categoryLinks = {}
    local foundCategories = {} -- To prevent duplicate categories

    for _, ach in ipairs(achievements) do
        local achType = ach['type']
        local definition = typeDefinitions[achType]
        
        if definition and definition.category and definition.category ~= "" and not foundCategories[definition.category] then
            table.insert(categoryLinks, "[[Category:" .. definition.category .. "]]")
            foundCategories[definition.category] = true
        end
    end

    return table.concat(categoryLinks)
end

return Achievements