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 = {}
--------------------------------------------------------------------------------
-- 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
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('&', '&')
: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 = {},
}
--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
--------------------------------------------------------------------------------
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 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 with multiple lookup methods
--------------------------------------------------------------------------------
function Achievements.getUserAchievements(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)
-- Try string key first with new structure
local userEntry = data.user_achievements[key]
if userEntry then
-- Check if it's the new structure with achievements array
if userEntry.achievements then
return ensureArray(userEntry.achievements)
-- Check if it's the old structure (direct array)
elseif type(userEntry) == "table" and #userEntry > 0 then
return ensureArray(userEntry)
end
end
-- Try numeric key if string key didn't work
local numKey = tonumber(key)
if numKey and data.user_achievements[numKey] then
local userEntry = data.user_achievements[numKey]
-- Check if it's the new structure with achievements array
if userEntry.achievements then
return ensureArray(userEntry.achievements)
-- Check if it's the old structure (direct array)
elseif type(userEntry) == "table" and #userEntry > 0 then
return ensureArray(userEntry)
end
end
-- Try legacy "n123" style
if key:match("^%d+$") then
local alt = "n" .. key
local userEntry = data.user_achievements[alt]
if userEntry then
-- Check if it's the new structure with achievements array
if userEntry.achievements then
return ensureArray(userEntry.achievements)
-- Check if it's the old structure (direct array)
elseif type(userEntry) == "table" and #userEntry > 0 then
return ensureArray(userEntry)
end
end
end
-- Try string comparison as last resort
for userId, userEntry in pairs(data.user_achievements) do
if tostring(userId) == key then
-- Check if it's the new structure with achievements array
if userEntry.achievements then
return ensureArray(userEntry.achievements)
-- Check if it's the old structure (direct array)
elseif type(userEntry) == "table" and #userEntry > 0 then
return ensureArray(userEntry)
end
end
end
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
return 'Unknown'
end
local data = Achievements.loadData()
if not data or not data.achievement_types then
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
return typeData.name
else
return achievementType
end
end
end
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
return '', ''
end
local userAchievements = Achievements.getUserAchievements(pageId)
if #userAchievements == 0 then
return '', ''
end
local data = Achievements.loadData()
local highestTier = 999
local highestAchievement = nil
for _, achievement in ipairs(userAchievements) do
local achType = achievement.type
for _, typeData in ipairs(data.achievement_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 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
--------------------------------------------------------------------------------
-- Get page name for a given page ID
--------------------------------------------------------------------------------
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 it's the new structure with page_name at top level
if userEntry and userEntry.page_name then
return userEntry.page_name
end
-- Try numeric key if string key didn't work
local numKey = tonumber(key)
if numKey and data.user_achievements[numKey] and data.user_achievements[numKey].page_name then
return data.user_achievements[numKey].page_name
end
-- Try legacy "n123" style
if key:match("^%d+$") then
local alt = "n" .. key
if data.user_achievements[alt] and data.user_achievements[alt].page_name then
return data.user_achievements[alt].page_name
end
end
-- Try old structure where page_name might be in the first achievement
local achievements = Achievements.getUserAchievements(pageId)
if #achievements > 0 and achievements[1].page_name then
return achievements[1].page_name
end
return ''
end
--------------------------------------------------------------------------------
-- Simple pass-through to track pages (for future expansions)
--------------------------------------------------------------------------------
function Achievements.trackPage(pageId, pageName)
-- In the future, this could update the page_name in the JSON data
return true
end
--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
--------------------------------------------------------------------------------
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 _, achievement in ipairs(userAchievements) do
if achievement.type == achievementType then
return achievement
end
end
return nil
end
--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
--------------------------------------------------------------------------------
function Achievements.getAchievementDefinition(achievementType)
if not achievementType or achievementType == '' then
return nil
end
local data = Achievements.loadData()
if not data or not data.achievement_types then
return nil
end
-- Direct lookup in achievement_types array
for _, typeData in ipairs(data.achievement_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)
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(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 table of achievement definitions for quick lookup
local typeDefinitions = {}
for _, typeData in ipairs(data.achievement_types) do
typeDefinitions[typeData.id] = typeData
end
-- Find title achievements only
local highestTier = 999
local titleAchievement = nil
for _, achievement in ipairs(userAchievements) do
local achType = achievement.type
if achType then
local typeData = typeDefinitions[achType]
if typeData and typeData.type == "title" then
local tier = typeData.tier or 999
if tier < highestTier then
highestTier = tier
titleAchievement = typeData
end
end
end
end
if not titleAchievement or not titleAchievement.id then
return '', '', ''
end
local achievementId = titleAchievement.id
local displayName = titleAchievement.name or achievementId
return achievementId, displayName, achievementId
end
return Achievements