Module:AchievementSystem: Difference between revisions
Appearance
// via Wikitext Extension for VSCode |
// via Wikitext Extension for VSCode |
||
| (27 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- | --[[ | ||
* 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 = {} | local Achievements = {} | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
| Line 55: | Line 50: | ||
end | end | ||
-- | -- 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 67: | Line 62: | ||
if success and result then | if success and result then | ||
return result | return result | ||
end | end | ||
end | end | ||
return nil | return nil | ||
end | end | ||
| Line 92: | 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 = | 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 = {}, | ||
} | } | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- | -- 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) | ||
local | -- 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 | |||
if not | local data = Achievements.loadData(frame) | ||
if data and data.achievement_types then | |||
typesCache = data.achievement_types | |||
return typesCache | |||
end | |||
types = {} | |||
end | end | ||
typesCache = types | |||
return | 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) | ||
-- 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 | ||
return dataCache | return dataCache | ||
end | end | ||
local success, data = pcall(function() | local success, data = pcall(function() | ||
-- | -- Get the JSON content using frame:preprocess if available | ||
if | local jsonText | ||
if frame and type(frame) == "table" and frame.preprocess then | |||
-- Make sure frame is valid and has preprocess method | |||
local | local preprocessSuccess, preprocessResult = pcall(function() | ||
return | return frame:preprocess('{{MediaWiki:AchievementData.json}}') | ||
end) | end) | ||
if | if preprocessSuccess and preprocessResult then | ||
jsonText = preprocessResult | |||
end | end | ||
end | end | ||
-- | -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading | ||
if not jsonText then | |||
if not | -- 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 | if not pageTitle or not pageTitle.exists then | ||
return DEFAULT_DATA | |||
end | end | ||
-- | -- Get raw content from the wiki page | ||
if | local contentSuccess, content = pcall(function() | ||
return pageTitle:getContent() | |||
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 | |||
jsonText = content | |||
else | else | ||
return DEFAULT_DATA | |||
end | end | ||
end | end | ||
-- As absolute last resort, use local default data | -- 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 | return DEFAULT_DATA | ||
end) | end) | ||
if not success or not data then | if not success or not data then | ||
data = DEFAULT_DATA | data = DEFAULT_DATA | ||
end | end | ||
| Line 223: | Line 321: | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- Get user achievements | -- 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 | ||
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 | ||
return {} | return {} | ||
end | end | ||
local key = | 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 | return achievements | ||
end | end | ||
-- Try numeric key | -- Try numeric key as fallback | ||
local numKey = tonumber(key) | local numKey = tonumber(key) | ||
if numKey | if numKey then | ||
userEntry = data.user_achievements[numKey] | |||
if userEntry and userEntry.achievements then | |||
local achievements = ensureArray(userEntry.achievements) | |||
userAchievementsCache[cacheKey] = achievements | |||
return achievements | |||
if | |||
end | end | ||
end | end | ||
-- | -- Cache empty result to avoid repeated lookups | ||
userAchievementsCache[cacheKey] = {} | |||
return {} | return {} | ||
end | end | ||
| Line 277: | 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 285: | 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 | ||
return 'Unknown' | return 'Unknown' | ||
end | end | ||
local types = Achievements.loadTypes(frame) | |||
local | |||
-- Try to match achievement ID | -- Try to match achievement ID | ||
for _, typeData in ipairs( | 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 | ||
return typeData.name | return typeData.name | ||
else | else | ||
return achievementType | return achievementType | ||
end | end | ||
| Line 317: | Line 452: | ||
end | end | ||
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 | ||
return '', '' | return '', '' | ||
end | end | ||
| Line 338: | Line 469: | ||
local userAchievements = Achievements.getUserAchievements(pageId) | local userAchievements = Achievements.getUserAchievements(pageId) | ||
if #userAchievements == 0 then | if #userAchievements == 0 then | ||
return '', '' | return '', '' | ||
end | end | ||
local | 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 | local achType = achievement["type"] | ||
for _, typeData in ipairs( | 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 | ||
if tier < highestTier then | if tier < highestTier then | ||
highestTier = tier | highestTier = tier | ||
highestAchievement = typeData | highestAchievement = typeData | ||
end | end | ||
end | end | ||
| Line 364: | Line 491: | ||
if not highestAchievement or not highestAchievement.id then | if not highestAchievement or not highestAchievement.id then | ||
return '', '' | return '', '' | ||
end | end | ||
| Line 370: | 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" | ||
return cssClass, displayName | return cssClass, displayName | ||
| Line 377: | Line 501: | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- Renders a | -- 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 389: | Line 516: | ||
end | end | ||
local | 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 = {} | ||
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 | ||
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 431: | Line 556: | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- | -- 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. | function Achievements.getPageName(pageId) | ||
return | 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) | ||
if not pageId or not achievementType or pageId == '' then | if not pageId or not achievementType or pageId == '' then | ||
return nil | return nil | ||
end | end | ||
| Line 452: | Line 604: | ||
-- Direct lookup for the requested achievement type | -- Direct lookup for the requested achievement type | ||
for _, | for _, achievementTbl in ipairs(userAchievements) do | ||
if | 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 | ||
end | end | ||
return nil | return nil | ||
end | end | ||
| Line 465: | 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 | ||
return nil | return nil | ||
end | end | ||
local | local types = Achievements.loadTypes(frame) | ||
-- Direct lookup in achievement_types array | -- Direct lookup in achievement_types array | ||
for _, typeData in ipairs( | for _, typeData in ipairs(types) do | ||
if typeData.id == achievementType then | if typeData.id == achievementType then | ||
return typeData | return typeData | ||
end | end | ||
end | end | ||
return nil | return nil | ||
end | end | ||
| Line 529: | 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 | ||
return nil | |||
return | |||
end | end | ||
local userAchievements = Achievements.getUserAchievements(pageId) | local userAchievements = Achievements.getUserAchievements(pageId) | ||
if #userAchievements == 0 then | if #userAchievements == 0 then | ||
return nil | |||
return | |||
end | end | ||
local | 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( | for _, typeData in ipairs(types) do | ||
typeDefinitions[typeData.id] = typeData | typeDefinitions[typeData.id] = typeData | ||
end | end | ||
| Line 553: | 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 | local achType = achievement["type"] | ||
if achType then | if achType then | ||
local typeData = typeDefinitions[achType] | local typeData = typeDefinitions[achType] | ||
if typeData and typeData | 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 568: | Line 686: | ||
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 | |||
return | 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 | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- | -- 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. | function Achievements.getCategoryLinks(achievements, frame) | ||
if not achievements or #achievements == 0 then | |||
return "" | |||
if not | |||
end | end | ||
local types = Achievements.loadTypes(frame) | |||
local typeDefinitions = {} | |||
for _, typeData in ipairs(types) do | |||
typeDefinitions[typeData.id] = typeData | |||
end | end | ||
local categoryLinks = {} | |||
local | 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 | |||
if | |||
end | end | ||
end | end | ||
return table.concat(categoryLinks) | |||
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('&', '&')
:gsub('<', '<')
:gsub('>', '>')
:gsub('"', '"')
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