Jump to content

Module:AchievementSystem: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
 
(26 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:AchievementSystem
--[[
-- Achievement system that loads data from MediaWiki:AchievementData.json.
* Name: AchievementSystem
-- STYLING NOTE: All achievement styling is defined in CSS/Templates.css, not in the JSON.
* Author: Mark W. Datysgeld
-- This module only assigns CSS classes based on achievement IDs in the format:
* 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-template .template-title.achievement-{id}::after {}
* 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
--
]]
-- The module does not use any styling information from the JSON data structure.
 
---@class UserAchievement
---@field type string
---@field date? string


local Achievements = {}
local Achievements = {}
-- Debug configuration
local DEBUG_MODE = false
local function debugLog(message)
    if not DEBUG_MODE then return end
    -- Debug logging disabled - do not use mw.log
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
Line 54: Line 50:
end
end


-- We'll use MediaWiki's built-in JSON functions directly, no external module needed
-- Use MediaWiki's built-in JSON functions directly
local function jsonDecode(jsonString)
local function jsonDecode(jsonString)
     if not jsonString then return nil end
     if not jsonString then return nil end
Line 66: Line 62:
         if success and result then
         if success and result then
             return result
             return result
        else
            debugLog('ERROR: JSON decode failed: ' .. tostring(result or 'unknown error'))
         end
         end
     end
     end
      
      
    debugLog('CRITICAL ERROR: mw.text.jsonDecode not available!')
     return nil
     return nil
end
end
Line 91: Line 84:
-- Configuration, Default Data, and Cache
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_LIST_PAGE = 'MediaWiki:AchievementList.json'
local dataCache = nil
local dataCache = nil
local typesCache = nil


local DEFAULT_DATA = {
local DEFAULT_DATA = {
     schema_version = 1,
     schema_version = 2,
     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 = 0 }
}
}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Configuration
-- Load achievement types from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of achievement type definitions
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- This array maps legacy achievement IDs to standardized ones
function Achievements.loadTypes(frame)
local ACHIEVEMENT_TYPE_MAPPING = {
    -- Use the request-level cache if we already loaded data once
    ["title-test"] = "dev-role",
    if typesCache then
    ["jedi"]      = "ach1",
        return typesCache
    ["champion"= "ach2",
    end
    ["sponsor"]  = "ach3"
 
}
    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)


-- Normalizes achievement type to handle variants or legacy types
     if not success or not types then
local function normalizeAchievementType(achievementType)
        -- If there was an error, fall back to AchievementData.json
     if not achievementType then return nil end
        local data = Achievements.loadData(frame)
   
        if data and data.achievement_types then
    -- If it's already a standard type, return it directly
            typesCache = data.achievement_types
    if achievementType == "dev-role" or
            return typesCache
      achievementType == "ach1" or
        end
      achievementType == "ach2" or
         types = {}
      achievementType == "ach3" then
         return achievementType
     end
     end
   
 
     -- Otherwise check the mapping table
     typesCache = types
     return ACHIEVEMENT_TYPE_MAPPING[achievementType] or achievementType
     return types
end
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
-- 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()
function Achievements.loadData(frame)
    debugLog("Starting to load achievement data")
 
     -- Use the request-level cache if we already loaded data once
     -- Use the request-level cache if we already loaded data once
     if dataCache then
     if dataCache then
        debugLog("Using request-level cached data")
         return dataCache
         return dataCache
     end
     end


     local success, data = pcall(function()
     local success, data = pcall(function()
         -- Try using mw.loadJsonData first (preferred method)
         -- Get the JSON content using frame:preprocess if available
         if mw.loadJsonData then
        local jsonText
            debugLog("Attempting to use mw.loadJsonData for " .. ACHIEVEMENT_DATA_PAGE)
         if frame and type(frame) == "table" and frame.preprocess then
              
             -- Make sure frame is valid and has preprocess method
             local loadJsonSuccess, jsonData = pcall(function()
             local preprocessSuccess, preprocessResult = pcall(function()
                 return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
                 return frame:preprocess('{{MediaWiki:AchievementData.json}}')
             end)
             end)
              
              
             if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
             if preprocessSuccess and preprocessResult then
                 debugLog("Successfully loaded data with mw.loadJsonData")
                 jsonText = preprocessResult
                return jsonData
            else
                debugLog("mw.loadJsonData failed: " .. tostring(jsonData or 'unknown error'))
             end
             end
        else
            debugLog("mw.loadJsonData not available, falling back to direct content loading")
         end
         end
          
          
         -- Direct content loading approach as fallback
         -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
         if not jsonText then
         if not pageTitle or not pageTitle.exists then
             -- Try using mw.loadJsonData first (preferred method)
             debugLog(ACHIEVEMENT_DATA_PAGE .. " does not exist")
             if mw.loadJsonData then
             return DEFAULT_DATA
                local loadJsonSuccess, jsonData = pcall(function()
        end
                    return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
       
                end)
        -- Get raw content from the wiki page
               
        local contentSuccess, content = pcall(function()
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
            return pageTitle:getContent()
                    return jsonData
        end)
                end
       
             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
             -- Direct content loading approach as fallback
             content = content:gsub("^%s+", "")
             local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
             if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
             if not pageTitle or not pageTitle.exists then
                 debugLog("Removing UTF-8 BOM from content")
                 return DEFAULT_DATA
                content = content:sub(4)
             end
             end
              
              
             -- Use mw.text.jsonDecode for parsing WITHOUT PRESERVE_KEYS flag
             -- Get raw content from the wiki page
             if mw.text and mw.text.jsonDecode then
            local contentSuccess, content = pcall(function()
                 local jsonDecodeSuccess, jsonData = pcall(function()
                return pageTitle:getContent()
                     return mw.text.jsonDecode(content)
            end)
                 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
                  
                  
                 if jsonDecodeSuccess and jsonData then
                 jsonText = content
                    debugLog("Successfully decoded content with mw.text.jsonDecode")
                    return jsonData
                else
                    debugLog("mw.text.jsonDecode failed: " .. tostring(jsonData or 'unknown error'))
                end
             else
             else
                 debugLog("mw.text.jsonDecode not available")
                 return DEFAULT_DATA
             end
             end
        else
            debugLog("Failed to get content: " .. tostring(content or 'unknown error'))
         end
         end
          
          
         -- As absolute last resort, use local default data
        -- Try different JSON decode approaches
        debugLog("All JSON loading approaches failed, using default data")
        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
         return DEFAULT_DATA
     end)
     end)


     if not success or not data then
     if not success or not data then
        debugLog("Critical error in load process: " .. tostring(data or 'unknown error'))
         data = DEFAULT_DATA
         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
     end


Line 222: Line 321:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Get user achievements with multiple lookup methods
-- 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)
function Achievements.getUserAchievements(pageId)
     if not pageId or pageId == '' then
     if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getUserAchievements")
         return {}
         return {}
    end
   
    -- Check cache first
    local cacheKey = tostring(pageId)
    if userAchievementsCache[cacheKey] then
        return userAchievementsCache[cacheKey]
     end
     end


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


     local key = tostring(pageId)
     local key = cacheKey
     debugLog("Looking up achievements for ID: " .. key)
     local userEntry = data.user_achievements[key]
      
      
     -- Try string key first
     -- If found with string key, return achievements
     local userAchievements = data.user_achievements[key] or {}
     if userEntry and userEntry.achievements then
    if #userAchievements > 0 then
         local achievements = ensureArray(userEntry.achievements)
         debugLog("Found achievements using string key: " .. key)
        userAchievementsCache[cacheKey] = achievements
         return ensureArray(userAchievements)
         return achievements
     end
     end
      
      
     -- Try numeric key if string key didn't work
     -- Try numeric key as fallback
     local numKey = tonumber(key)
     local numKey = tonumber(key)
     if numKey and data.user_achievements[numKey] then
     if numKey then
         debugLog("Found achievements using numeric key: " .. numKey)
         userEntry = data.user_achievements[numKey]
        return ensureArray(data.user_achievements[numKey])
         if userEntry and userEntry.achievements then
    end
             local achievements = ensureArray(userEntry.achievements)
   
             userAchievementsCache[cacheKey] = achievements
    -- Try legacy "n123" style
            return achievements
    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
     end
     end
      
      
     -- Try string comparison as last resort
     -- Cache empty result to avoid repeated lookups
     for userId, achievements in pairs(data.user_achievements) do
     userAchievementsCache[cacheKey] = {}
        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 {}
     return {}
end
end
Line 276: Line 372:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
-- 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)
function Achievements.hasAchievements(pageId)
Line 284: Line 382:
     local userAchievements = Achievements.getUserAchievements(pageId)
     local userAchievements = Achievements.getUserAchievements(pageId)
     return #userAchievements > 0
     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
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Get a user-friendly name for a given achievement type
-- 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)
function Achievements.getAchievementName(achievementType, frame)
     if not achievementType or achievementType == '' then
     if not achievementType or achievementType == '' then
        debugLog("Empty achievement type provided to getAchievementName")
         return 'Unknown'
         return 'Unknown'
     end
     end


    debugLog("Looking up achievement name for type: '" .. tostring(achievementType) .. "'")
     local types = Achievements.loadTypes(frame)
 
      
     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
     -- Try to match achievement ID
     for _, typeData in ipairs(data.achievement_types) do
     for _, typeData in ipairs(types) do
         if typeData.id == achievementType then
         if typeData.id == achievementType then
             if typeData.name and typeData.name ~= "" then
             if typeData.name and typeData.name ~= "" then
                debugLog("Found achievement: " .. typeData.id .. " with name: " .. typeData.name)
                 return typeData.name
                 return typeData.name
             else
             else
                debugLog("'" .. typeData.id .. "' has no name; using ID")
                 return achievementType
                 return achievementType
             end
             end
Line 316: Line 452:
     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
     return achievementType
end
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Find the top-tier achievement for the user (lowest tier number)
-- Find the top-tier Title achievement for the user (lowest tier number)
-- Return the CSS class and the readable achievement name
-- 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)
function Achievements.getTitleClass(pageId, frame)
     if not pageId or pageId == '' then
     if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getTitleClass")
         return '', ''
         return '', ''
     end
     end
Line 337: Line 469:
     local userAchievements = Achievements.getUserAchievements(pageId)
     local userAchievements = Achievements.getUserAchievements(pageId)
     if #userAchievements == 0 then
     if #userAchievements == 0 then
        debugLog("No achievements found for user " .. tostring(pageId))
         return '', ''
         return '', ''
     end
     end


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


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


     if not highestAchievement or not highestAchievement.id then
     if not highestAchievement or not highestAchievement.id then
        debugLog("No valid top-tier achievement found for user " .. tostring(pageId))
         return '', ''
         return '', ''
     end
     end
Line 369: Line 496:
     local cssClass = "achievement-" .. highestAchievement.id
     local cssClass = "achievement-" .. highestAchievement.id
     local displayName = highestAchievement.name or highestAchievement.id or "Award"
     local displayName = highestAchievement.name or highestAchievement.id or "Award"
   
    debugLog("Using top-tier achievement: " .. cssClass .. " with name: " .. displayName)
      
      
     return cssClass, displayName
     return cssClass, displayName
Line 376: Line 501:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Renders a simple "box" with the top-tier achievement for the user
-- 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)
function Achievements.renderAchievementBox(pageId, frame)
     if not pageId or pageId == '' then
     if not pageId or pageId == '' then
         return ''
         return ''
Line 388: Line 516:
     end
     end
      
      
     local data = Achievements.loadData()
     local types = Achievements.loadTypes(frame)
      
      
     -- Build a lookup table for achievement type definitions
     -- Build a lookup table for achievement type definitions
     local typeDefinitions = {}
     local typeDefinitions = {}
     if data and data.achievement_types then
     for _, typeData in ipairs(types) do
        for _, typeData in ipairs(data.achievement_types) do
        if typeData.id and typeData.name then
            if typeData.id and typeData.name then
            typeDefinitions[typeData.id] = {
                typeDefinitions[typeData.id] = {
                name = typeData.name,
                    name = typeData.name,
                tier = typeData.tier or 999
                    tier = typeData.tier or 999
            }
                }
            end
         end
         end
     end
     end


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


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Simple pass-through to track pages (for future expansions)
-- 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.trackPage(pageId, pageName)
function Achievements.getPageName(pageId)
     return true
    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
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
-- 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)
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
     if not pageId or not achievementType or pageId == '' then
        debugLog("Invalid arguments for getSpecificAchievement")
         return nil
         return nil
     end
     end
Line 451: Line 604:
      
      
     -- Direct lookup for the requested achievement type
     -- Direct lookup for the requested achievement type
     for _, achievement in ipairs(userAchievements) do
     for _, achievementTbl in ipairs(userAchievements) do
         if achievement.type == achievementType then
         if achievementTbl["type"] == achievementType then
            debugLog("Found achievement: " .. achievementType .. " for user " .. tostring(pageId))
local def = Achievements.getAchievementDefinition(achievementType)
             return achievement
            return {
                type    = achievementTbl.type,
                date    = achievementTbl.date or '',
                name    = def and def.name or achievementType,
                category = def and def.category
             }
         end
         end
     end
     end


    debugLog("No match found for achievement type: " .. achievementType)
     return nil
     return nil
end
end
Line 464: Line 621:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
-- 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)
function Achievements.getAchievementDefinition(achievementType, frame)
     if not achievementType or achievementType == '' then
     if not achievementType or achievementType == '' then
        debugLog("ACHIEVEMENT-DEF: Empty achievement type")
         return nil
         return nil
     end
     end
      
      
     local data = Achievements.loadData()
     local types = Achievements.loadTypes(frame)
    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
     -- Direct lookup in achievement_types array
     for _, typeData in ipairs(data.achievement_types) do
     for _, typeData in ipairs(types) do
         if typeData.id == achievementType then
         if typeData.id == achievementType then
            debugLog("ACHIEVEMENT-DEF: Found definition for " .. achievementType)
             return typeData
             return typeData
         end
         end
     end
     end
      
      
    debugLog("ACHIEVEMENT-DEF: No definition found for " .. achievementType)
     return nil
     return nil
end
--------------------------------------------------------------------------------
-- Diagnostic Function: Log badges for a page to console
--------------------------------------------------------------------------------
function Achievements.debugBadgesForPage(pageId)
    if not pageId or pageId == '' then
        return "ERROR: No page ID provided"
    end
    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return "No achievements found for page ID " .. pageId
    end
   
    -- Build output string instead of logging
    local output = {}
    table.insert(output, "Found " .. #userAchievements .. " achievements for page ID " .. pageId)
   
    -- Add each achievement to output
    for i, achievement in ipairs(userAchievements) do
        local achType = achievement.type or "nil"
        local typeDef = Achievements.getAchievementDefinition(achType)
       
        if typeDef then
            table.insert(output, "[" .. i .. "] " .. achType ..
                  " (Name: " .. (typeDef.name or "unnamed") ..
                  ", Type: " .. (typeDef.type or "unspecified") ..
                  ", Tier: " .. (typeDef.tier or "none") .. ")")
        else
            table.insert(output, "[" .. i .. "] " .. achType .. " (WARNING: No definition found)")
        end
    end
   
    return table.concat(output, "\n")
end
end


Line 528: Line 646:
-- This specifically looks for achievements with type="title"
-- This specifically looks for achievements with type="title"
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
-- 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)
function Achievements.getTitleAchievement(pageId, frame)
     if not pageId or pageId == '' then
     if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getTitleAchievement")
         return nil
         return '', '', ''
     end
     end


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


     local data = Achievements.loadData()
     local types = Achievements.loadTypes(frame)
      
      
     -- Build a table of achievement definitions for quick lookup
     -- Build a table of achievement definitions for quick lookup
     local typeDefinitions = {}
     local typeDefinitions = {}
     for _, typeData in ipairs(data.achievement_types) do
     for _, typeData in ipairs(types) do
         typeDefinitions[typeData.id] = typeData
         typeDefinitions[typeData.id] = typeData
     end
     end
Line 552: Line 671:
     local highestTier = 999
     local highestTier = 999
     local titleAchievement = nil
     local titleAchievement = nil
 
   
     for _, 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 and typeData.type == "title" then
             if typeData and typeData["type"] == "title" then
                 local tier = typeData.tier or 999
                 local tier = typeData.tier or 999
                 if tier < highestTier then
                 if tier < highestTier then
Line 567: Line 686:
     end
     end


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


    local achievementId = titleAchievement.id
-- Renders a title block with achievement integration
     local displayName = titleAchievement.name or achievementId
function Achievements.renderTitleBlockWithAchievement(args, titleClass, titleText, achievementClass, achievementId, achievementName)
     titleClass = titleClass or "template-title"
      
      
     debugLog("Found title achievement: " .. achievementId .. " with name: " .. displayName)
     -- Only add achievement attributes if they exist
     return achievementId, displayName, achievementId
    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
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Simplified diagnostic function for JSON loading issues
-- 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.diagnoseJsonLoading()
function Achievements.getCategoryLinks(achievements, frame)
    local output = {}
     if not achievements or #achievements == 0 then
    table.insert(output, "Starting JSON diagnostics")
         return ""
   
    -- Check MediaWiki capabilities
     if not mw.loadJsonData then
         table.insert(output, "ERROR - mw.loadJsonData not available!")
     end
     end
   
 
     if not (mw.text and mw.text.jsonDecode) then
     local types = Achievements.loadTypes(frame)
        table.insert(output, "CRITICAL ERROR - mw.text.jsonDecode not available!")
    local typeDefinitions = {}
         return "JSON decoding not available"
    for _, typeData in ipairs(types) do
         typeDefinitions[typeData.id] = typeData
     end
     end
   
 
     -- Check page existence
     local categoryLinks = {}
     local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
     local foundCategories = {} -- To prevent duplicate categories
    if not pageTitle or not pageTitle.exists then
 
        table.insert(output, "ERROR - " .. ACHIEVEMENT_DATA_PAGE .. " does not exist")
     for _, ach in ipairs(achievements) do
        return "JSON data page does not exist"
         local achType = ach['type']
     end
         local definition = typeDefinitions[achType]
   
    -- Check content model
    if pageTitle.contentModel and pageTitle.contentModel ~= "json" then
        table.insert(output, "ERROR - Incorrect content model: " .. pageTitle.contentModel)
         table.insert(output, "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
        table.insert(output, "ERROR - Failed to load JSON: " .. tostring(loadJsonResult or "unknown error"))
          
          
        -- Try to get raw content for further diagnosis
         if definition and definition.category and definition.category ~= "" and not foundCategories[definition.category] then
        local contentSuccess, content = pcall(function()
            table.insert(categoryLinks, "[[Category:" .. definition.category .. "]]")
            return pageTitle:getContent()
             foundCategories[definition.category] = true
        end)
       
         if contentSuccess and content and content ~= "" then
            -- Check for common issues
            if content:match("^<!DOCTYPE") or content:match("^<[Hh][Tt][Mm][Ll]") then
                table.insert(output, "CRITICAL ERROR - Content appears to be HTML, not JSON!")
            elseif not content:match("^%s*{") then
                table.insert(output, "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
                table.insert(output, "ERROR - JSON syntax error in content")
             end
        else
            table.insert(output, "ERROR - Could not read page content")
         end
         end
       
        return "JSON loading failed"
     end
     end
   
 
    -- Verify data structure
     return table.concat(categoryLinks)
    if type(loadJsonResult) ~= "table" then
        table.insert(output, "ERROR - Loaded data is not a table")
        return "Invalid JSON structure"
    end
   
    if not loadJsonResult.achievement_types then
        table.insert(output, "ERROR - Missing achievement_types array")
        return "Missing achievement_types in JSON"
     end
   
    if not loadJsonResult.user_achievements then
        table.insert(output, "ERROR - Missing user_achievements object")
        return "Missing user_achievements in JSON"
    end
   
    -- Success
    table.insert(output, "JSON loading successful")
    table.insert(output, "Found " .. #loadJsonResult.achievement_types .. " achievement types")
   
    local userCount = 0
    for _, _ in pairs(loadJsonResult.user_achievements) do
        userCount = userCount + 1
    end
    table.insert(output, "Found achievements for " .. userCount .. " users")
   
    -- For critical issues, print to terminal
    if #output > 0 then
        print("ACHIEVEMENT DIAGNOSTICS: " .. table.concat(output, " | "))
    end
   
    return "JSON diagnostics complete - all checks passed"
end
end


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