Module:AchievementSystem
Documentation for this module may be created at Module:AchievementSystem/doc
-- Module:AchievementSystem
-- Achievement system that loads data from MediaWiki:AchievementData.json.
-- STYLING NOTE: All achievement styling is defined in CSS/Templates.css, not in the JSON.
-- This module only assigns CSS classes based on achievement IDs in the format:
-- .person-template .template-title.achievement-{id}::after {}
--
-- The module does not use any styling information from the JSON data structure.
local Achievements = {}
-- Debug configuration
local DEBUG_MODE = false
local function debugLog(message)
if not DEBUG_MODE then return end
-- Use print for debugging since mw.log might not be available
print("[ACHIEVEMENT-DEBUG] " .. tostring(message))
end
--------------------------------------------------------------------------------
-- JSON Handling
--------------------------------------------------------------------------------
-- Helper function to ensure we get an array
local function ensureArray(value)
if type(value) ~= "table" then
return {}
end
-- Check if it's an array-like table
local isArray = true
local count = 0
for _ in pairs(value) do
count = count + 1
end
-- If it has no numeric indices or is empty, return empty array
if count == 0 then
return {}
end
-- If it's a single string, wrap it in an array
if count == 1 and type(value[1]) == "string" then
return {value[1]}
end
-- If it has a single non-array value, try to convert it to an array
if count == 1 and next(value) and type(next(value)) ~= "number" then
local k, v = next(value)
if type(v) == "string" then
return {v}
end
end
-- Return the original table if it seems to be an array
return value
end
-- We'll use MediaWiki's built-in JSON functions directly, no external module needed
local function jsonDecode(jsonString)
if not jsonString then return nil end
if mw.text and mw.text.jsonDecode then
local success, result = pcall(function()
-- Use WITHOUT PRESERVE_KEYS flag to ensure proper array handling
return mw.text.jsonDecode(jsonString)
end)
if success and result then
return result
else
debugLog('ERROR: JSON decode failed: ' .. tostring(result or 'unknown error'))
end
end
debugLog('CRITICAL ERROR: mw.text.jsonDecode not available!')
return nil
end
-- Simple HTML encode fallback
local function htmlEncode(str)
if mw.text and mw.text.htmlEncode then
return mw.text.htmlEncode(str or '')
else
return (str or '')
:gsub('&', '&')
:gsub('<', '<')
:gsub('>', '>')
:gsub('"', '"')
end
end
--------------------------------------------------------------------------------
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local dataCache = nil
local DEFAULT_DATA = {
schema_version = 1,
last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
achievement_types = {},
user_achievements = {},
cache_control = { version = 0 }
}
--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
-- This array maps legacy achievement IDs to standardized ones
local ACHIEVEMENT_TYPE_MAPPING = {
["title-test"] = "dev-role",
["jedi"] = "ach1",
["champion"] = "ach2",
["sponsor"] = "ach3"
}
-- Normalizes achievement type to handle variants or legacy types
local function normalizeAchievementType(achievementType)
if not achievementType then return nil end
-- If it's already a standard type, return it directly
if achievementType == "dev-role" or
achievementType == "ach1" or
achievementType == "ach2" or
achievementType == "ach3" then
return achievementType
end
-- Otherwise check the mapping table
return ACHIEVEMENT_TYPE_MAPPING[achievementType] or achievementType
end
--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
--------------------------------------------------------------------------------
function Achievements.loadData()
debugLog("Starting to load achievement data")
-- Use the request-level cache if we already loaded data once
if dataCache then
debugLog("Using request-level cached data")
return dataCache
end
local success, data = pcall(function()
-- Try using mw.loadJsonData first (preferred method)
if mw.loadJsonData then
debugLog("Attempting to use mw.loadJsonData for " .. ACHIEVEMENT_DATA_PAGE)
local loadJsonSuccess, jsonData = pcall(function()
return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
end)
if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
debugLog("Successfully loaded data with mw.loadJsonData")
return jsonData
else
debugLog("mw.loadJsonData failed: " .. tostring(jsonData or 'unknown error'))
end
else
debugLog("mw.loadJsonData not available, falling back to direct content loading")
end
-- Direct content loading approach as fallback
local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
if not pageTitle or not pageTitle.exists then
debugLog(ACHIEVEMENT_DATA_PAGE .. " does not exist")
return DEFAULT_DATA
end
-- Get raw content from the wiki page
local contentSuccess, content = pcall(function()
return pageTitle:getContent()
end)
if contentSuccess and content and content ~= "" then
debugLog("Successfully retrieved raw content, length: " .. #content)
-- Remove any BOM or leading whitespace that might cause issues
content = content:gsub("^%s+", "")
if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
debugLog("Removing UTF-8 BOM from content")
content = content:sub(4)
end
-- Use mw.text.jsonDecode for parsing WITHOUT PRESERVE_KEYS flag
if mw.text and mw.text.jsonDecode then
local jsonDecodeSuccess, jsonData = pcall(function()
return mw.text.jsonDecode(content)
end)
if jsonDecodeSuccess and jsonData then
debugLog("Successfully decoded content with mw.text.jsonDecode")
return jsonData
else
debugLog("mw.text.jsonDecode failed: " .. tostring(jsonData or 'unknown error'))
end
else
debugLog("mw.text.jsonDecode not available")
end
else
debugLog("Failed to get content: " .. tostring(content or 'unknown error'))
end
-- As absolute last resort, use local default data
debugLog("All JSON loading approaches failed, using default data")
return DEFAULT_DATA
end)
if not success or not data then
debugLog("Critical error in load process: " .. tostring(data or 'unknown error'))
data = DEFAULT_DATA
end
-- Show success source in log
if data ~= DEFAULT_DATA then
debugLog("Successfully loaded JSON data with " .. tostring(#(data.achievement_types or {})) .. " achievement types")
end
dataCache = data
return data
end
--------------------------------------------------------------------------------
-- Get user achievements with multiple lookup methods
--------------------------------------------------------------------------------
function Achievements.getUserAchievements(pageId)
if not pageId or pageId == '' then
debugLog("Empty page ID provided to getUserAchievements")
return {}
end
local data = Achievements.loadData()
if not data or not data.user_achievements then
debugLog("No achievement data available in getUserAchievements")
return {}
end
local key = tostring(pageId)
debugLog("Looking up achievements for ID: " .. key)
-- Try string key first
local userAchievements = data.user_achievements[key] or {}
if #userAchievements > 0 then
debugLog("Found achievements using string key: " .. key)
return ensureArray(userAchievements)
end
-- Try numeric key if string key didn't work
local numKey = tonumber(key)
if numKey and data.user_achievements[numKey] then
debugLog("Found achievements using numeric key: " .. numKey)
return ensureArray(data.user_achievements[numKey])
end
-- Try legacy "n123" style
if key:match("^%d+$") then
local alt = "n" .. key
if data.user_achievements[alt] and #data.user_achievements[alt] > 0 then
debugLog("Found achievements using legacy key: " .. alt)
return ensureArray(data.user_achievements[alt])
end
end
-- Try string comparison as last resort
for userId, achievements in pairs(data.user_achievements) do
if tostring(userId) == key then
debugLog("Found achievements using string comparison with key type: " .. type(userId))
return ensureArray(achievements)
end
end
debugLog("No achievements found for user " .. key)
return {}
end
--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
--------------------------------------------------------------------------------
function Achievements.hasAchievements(pageId)
if not pageId or pageId == '' then
return false
end
local userAchievements = Achievements.getUserAchievements(pageId)
return #userAchievements > 0
end
--------------------------------------------------------------------------------
-- Get a user-friendly name for a given achievement type
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType)
if not achievementType or achievementType == '' then
debugLog("Empty achievement type provided to getAchievementName")
return 'Unknown'
end
debugLog("Looking up achievement name for type: '" .. tostring(achievementType) .. "'")
local data = Achievements.loadData()
if not data or not data.achievement_types then
debugLog("No achievement data or achievement_types missing")
return achievementType
end
-- Try to match achievement ID
for _, typeData in ipairs(data.achievement_types) do
if typeData.id == achievementType then
if typeData.name and typeData.name ~= "" then
debugLog("Found achievement: " .. typeData.id .. " with name: " .. typeData.name)
return typeData.name
else
debugLog("'" .. typeData.id .. "' has no name; using ID")
return achievementType
end
end
end
-- Special case for dev-role lookup
if achievementType == "dev-role" then
debugLog("Could not find dev-role in achievement_types!")
end
debugLog("No achievement found with type '" .. achievementType .. "'; using ID fallback")
return achievementType
end
--------------------------------------------------------------------------------
-- Find the top-tier achievement for the user (lowest tier number)
-- Return the CSS class and the readable achievement name
--------------------------------------------------------------------------------
function Achievements.getTitleClass(pageId)
if not pageId or pageId == '' then
debugLog("Empty page ID provided to getTitleClass")
return '', ''
end
local userAchievements = Achievements.getUserAchievements(pageId)
if #userAchievements == 0 then
debugLog("No achievements found for user " .. tostring(pageId))
return '', ''
end
local data = Achievements.loadData()
local highestTier = 999
local highestAchievement = nil
for _, achievement in ipairs(userAchievements) do
local achType = achievement.type
debugLog("Processing achievement type: " .. (achType or "nil"))
for _, typeData in ipairs(data.achievement_types) do
if typeData.id == achType then
local tier = typeData.tier or 999
debugLog(" Found type '" .. typeData.id .. "' with tier " .. tier .. " and name '" .. (typeData.name or "nil") .. "'")
if tier < highestTier then
highestTier = tier
highestAchievement = typeData
debugLog(" New highest tier achievement: " .. typeData.id)
end
end
end
end
if not highestAchievement or not highestAchievement.id then
debugLog("No valid top-tier achievement found for user " .. tostring(pageId))
return '', ''
end
local cssClass = "achievement-" .. highestAchievement.id
local displayName = highestAchievement.name or highestAchievement.id or "Award"
debugLog("Using top-tier achievement: " .. cssClass .. " with name: " .. displayName)
return cssClass, displayName
end
--------------------------------------------------------------------------------
-- Renders a simple "box" with the top-tier achievement for the user
--------------------------------------------------------------------------------
function Achievements.renderAchievementBox(pageId)
if not pageId or pageId == '' then
return ''
end
local userAchievements = Achievements.getUserAchievements(pageId)
if #userAchievements == 0 then
return ''
end
local data = Achievements.loadData()
-- Build a lookup table for achievement type definitions
local typeDefinitions = {}
if data and data.achievement_types then
for _, typeData in ipairs(data.achievement_types) do
if typeData.id and typeData.name then
typeDefinitions[typeData.id] = {
name = typeData.name,
tier = typeData.tier or 999
}
end
end
end
-- Look for the highest-tier achievement (lowest tier number)
local highestTier = 999
local topAchType = nil
for _, achievement in ipairs(userAchievements) do
local achType = achievement.type
if typeDefinitions[achType] and typeDefinitions[achType].tier < highestTier then
highestTier = typeDefinitions[achType].tier
topAchType = achType
end
end
-- If we found an achievement, render it
if topAchType and typeDefinitions[topAchType] then
local achName = typeDefinitions[topAchType].name or topAchType
return string.format(
'<div class="achievement-box-simple" data-achievement-type="%s">%s</div>',
topAchType,
htmlEncode(achName)
)
end
return ''
end
--------------------------------------------------------------------------------
-- Simple pass-through to track pages (for future expansions)
--------------------------------------------------------------------------------
function Achievements.trackPage(pageId, pageName)
return true
end
--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
--------------------------------------------------------------------------------
function Achievements.getSpecificAchievement(pageId, achievementType)
debugLog("Looking for achievement '" .. tostring(achievementType) ..
"' in page ID: " .. tostring(pageId))
if not pageId or not achievementType or pageId == '' then
debugLog("Invalid arguments for getSpecificAchievement")
return nil
end
local userAchievements = Achievements.getUserAchievements(pageId)
-- Direct lookup for the requested achievement type
for _, achievement in ipairs(userAchievements) do
if achievement.type == achievementType then
debugLog("Found achievement: " .. achievementType .. " for user " .. tostring(pageId))
return achievement
end
end
debugLog("No match found for achievement type: " .. achievementType)
return nil
end
--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
--------------------------------------------------------------------------------
function Achievements.getAchievementDefinition(achievementType)
if not achievementType or achievementType == '' then
debugLog("ACHIEVEMENT-DEF: Empty achievement type")
return nil
end
local data = Achievements.loadData()
if not data or not data.achievement_types then
debugLog("ACHIEVEMENT-DEF: No achievement data loaded")
return nil
end
-- Direct lookup in achievement_types array
for _, typeData in ipairs(data.achievement_types) do
if typeData.id == achievementType then
debugLog("ACHIEVEMENT-DEF: Found definition for " .. achievementType)
return typeData
end
end
debugLog("ACHIEVEMENT-DEF: No definition found for " .. achievementType)
return nil
end
--------------------------------------------------------------------------------
-- Diagnostic Function: Log badges for a page to console
--------------------------------------------------------------------------------
function Achievements.debugBadgesForPage(pageId)
if not pageId or pageId == '' then
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
--------------------------------------------------------------------------------
-- Find and return title achievement for the user if one exists
-- This specifically looks for achievements with type="title"
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(pageId)
if not pageId or pageId == '' then
debugLog("Empty page ID provided to getTitleAchievement")
return '', '', ''
end
local userAchievements = Achievements.getUserAchievements(pageId)
if #userAchievements == 0 then
debugLog("No achievements found for user " .. tostring(pageId))
return '', '', ''
end
local data = Achievements.loadData()
-- Build a table of achievement definitions for quick lookup
local typeDefinitions = {}
for _, typeData in ipairs(data.achievement_types) do
typeDefinitions[typeData.id] = typeData
end
-- Find title achievements only
local highestTier = 999
local titleAchievement = nil
-- Debug output for achievements
debugLog("Processing " .. #userAchievements .. " achievements for page ID " .. tostring(pageId))
for _, achievement in ipairs(userAchievements) do
local achType = achievement.type
if achType then
debugLog("Processing achievement type: " .. achType)
local typeData = typeDefinitions[achType]
if typeData and typeData.type == "title" then
debugLog("Found title achievement: " .. achType .. " with tier " .. (typeData.tier or "nil"))
local tier = typeData.tier or 999
if tier < highestTier then
highestTier = tier
titleAchievement = typeData
debugLog("New highest tier title achievement: " .. achType)
end
end
end
end
if not titleAchievement or not titleAchievement.id then
debugLog("No title achievement found for user " .. tostring(pageId))
return '', '', ''
end
local achievementId = titleAchievement.id
local displayName = titleAchievement.name or achievementId
debugLog("Found title achievement: " .. achievementId .. " with name: " .. displayName)
return achievementId, displayName, achievementId
end
--------------------------------------------------------------------------------
-- Simplified diagnostic function for JSON loading issues
--------------------------------------------------------------------------------
function Achievements.diagnoseJsonLoading()
local output = {}
table.insert(output, "Starting JSON diagnostics")
-- Check MediaWiki capabilities
if not mw.loadJsonData then
table.insert(output, "ERROR - mw.loadJsonData not available!")
end
if not (mw.text and mw.text.jsonDecode) then
table.insert(output, "CRITICAL ERROR - mw.text.jsonDecode not available!")
return "JSON decoding not available"
end
-- Check page existence
local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
if not pageTitle or not pageTitle.exists then
table.insert(output, "ERROR - " .. ACHIEVEMENT_DATA_PAGE .. " does not exist")
return "JSON data page does not exist"
end
-- Check content model
if pageTitle.contentModel and pageTitle.contentModel ~= "json" then
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
local contentSuccess, content = pcall(function()
return pageTitle:getContent()
end)
if contentSuccess and content and content ~= "" then
-- Check for common issues
if content:match("^<!DOCTYPE") or content:match("^<[Hh][Tt][Mm][Ll]") then
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
return "JSON loading failed"
end
-- Verify data structure
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
return Achievements