Jump to content

Module:T-Campaign

Documentation for this module may be created at Module:T-Campaign/doc

--[[
 * T-Campaign.lua
 * Generic campaign template that dynamically loads campaign data from JSON files. This template provides a flexible, data-driven approach to campaign templates without cluttering ConfigRepository. It dynamically generates field definitions based on the JSON structure and renders any campaign content.
 *
 * Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}
]]

local p = {}

-- Required modules
local Blueprint = require('Module:LuaTemplateBlueprint')
local ErrorHandling = require('Module:ErrorHandling')
local DatasetLoader = require('Module:DatasetLoader')

-- Register the Campaign template with Blueprint
local template = Blueprint.registerTemplate('Campaign', {
    features = { 
        fullPage = true, -- Full-page layout
        categories = true,
        errorReporting = false -- REVIEW
    }
})

-- Dynamic configuration (from JSON)
template.config = {
    fields = {}, -- Dynamically generated
    categories = {
        base = {} -- Populated dynamically based on campaign
    },
    constants = {
        title = "Campaign Information", -- Default title, can be overridden by JSON
        tableClass = "" -- Empty to prevent template box styling
    }
}

-- Blueprint default: Initialize standard configuration
Blueprint.initializeConfig(template)

-- Custom block sequence (bypass standard Blueprint blocks)
template.config.blockSequence = {
    'campaignTitle',
    'campaignInstructions',
    'campaignContent',
    'categories',
    'errors'
}

-- Initialize custom blocks
template.config.blocks = template.config.blocks or {}

-- Helper function: Tokenize semicolon-separated strings for instructions
local function tokenizeForInstructions(value)
    if not value or value == "" then
        return value
    end
    
    local items = {}
    for item in value:gmatch("[^;]+") do
        local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
        if trimmed and trimmed ~= "" then
            table.insert(items, '<span class="campaign-instruction-token">' .. trimmed .. '</span>')
        end
    end
    
    if #items > 0 then
        return table.concat(items, ' ')
    else
        return value
    end
end

-- Helper function: Generate usage instructions
local function generateUsageInstructions(campaignName, campaignData)
    local output = {}
    
    -- Generate template syntax with all available parameters in user-friendly format
    local templateCall = "{{#invoke:T-Campaign|render|\ncampaign_name=" .. tostring(campaignName) .. "|\n"
    
    -- Add each field as a parameter option with example values (except campaign_intro which is fixed)
    if campaignData.field_definitions then
        for _, fieldDef in ipairs(campaignData.field_definitions) do
            if fieldDef.key ~= "campaign_intro" then
                local exampleValue = "text"
                if fieldDef.type == "list" then
                    exampleValue = "text 1; text 2; etc. (semicolon-separated)"
                end
                templateCall = templateCall .. tostring(fieldDef.key) .. " = " .. exampleValue .. "|\n"
            end
        end
    end
    templateCall = templateCall .. "}}"
    
    -- Build instruction content
    table.insert(output, "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:")
    table.insert(output, "")
    table.insert(output, "<pre>" .. templateCall .. "</pre>")
    table.insert(output, "")
    table.insert(output, "'''Available Parameters:'''")
    
    if campaignData.field_definitions then
        for _, fieldDef in ipairs(campaignData.field_definitions) do
            -- Skip campaign_intro since it's a fixed field from JSON defaults
            if fieldDef.key ~= "campaign_intro" then
                local paramDesc = "* '''<span style=\"color: var(--colored-text);\">" .. tostring(fieldDef.key) .. "</span>''' (" .. tostring(fieldDef.label) .. ", " .. tostring(fieldDef.type) .. "): "
                
                -- Add default value as example if available, with tokenization for lists
                local defaultValue = campaignData.defaults[fieldDef.key]
                if defaultValue and defaultValue ~= "" then
                    if fieldDef.type == "list" then
                        paramDesc = paramDesc .. tokenizeForInstructions(tostring(defaultValue))
                    else
                        paramDesc = paramDesc .. tostring(defaultValue)
                    end
                end
                
                -- Add helpful note for list fields
                if fieldDef.type == "list" then
                    paramDesc = paramDesc .. " (separate multiple values with semicolons)"
                end
                table.insert(output, paramDesc)
            end
        end
    end

    return table.concat(output, "\n")
end

-- Helper function: Get campaign name with fallback logic (consolidates repeated logic)
local function getCampaignName(args, campaignData)
    local campaignName = args.campaign_name or args.campaignname or args.Campaign_name or args.CampaignName
    
    -- If still nil, try to get it from the campaign data
    if not campaignName and campaignData and campaignData.template_id then
        campaignName = campaignData.template_id
    end
    
    -- Final fallback
    if not campaignName then
        campaignName = "CAMPAIGN_NAME"
    end
    
    return campaignName
end

-- Block 1: Campaign Title
template.config.blocks.campaignTitle = {
    feature = 'fullPage',
    render = function(template, args)
        local titleText = template.config.constants.title or "Campaign Information"
        return "== " .. titleText .. " =="
    end
}

-- Block 2: Campaign Instructions (only in documentation/partial modes)
template.config.blocks.campaignInstructions = {
    feature = 'fullPage',
    render = function(template, args)
        if not args._show_instructions or not args._campaign_data then
            return "" -- Only render when instructions should be shown
        end
        
        local campaignName = getCampaignName(args, args._campaign_data)
        local instructions = generateUsageInstructions(campaignName, args._campaign_data)
        
        return '<div class="campaign-instructions">\n== ⚠️ Editing Instructions ==\n\n' .. instructions .. '\n</div>'
    end
}

-- Block 3: Campaign Content (all campaign fields)
template.config.blocks.campaignContent = {
    feature = 'fullPage',
    render = function(template, args)
        local output = {}
        
        for _, field in ipairs(template.config.fields or {}) do
            -- Skip usage instructions field (handled by campaignInstructions block)
            if field.key ~= "usageInstructions" then
                local rawValue = args[field.key]
                local processor = template._processors[field.key] or template._processors.default
                
                if processor then
                    -- Pass field type as additional parameter to fix broken detection
                    local value = processor(rawValue, args, template, field.type)
                    if value and value ~= "" then
                        -- Special handling for campaign_intro - no section header
                        if field.key == "campaign_intro" then
                            table.insert(output, value)
                            table.insert(output, "")
                        else
                            table.insert(output, "=== " .. field.label .. " ===")
                            table.insert(output, value)
                            table.insert(output, "")
                        end
                    end
                end
            end
        end
        
        return table.concat(output, "\n")
    end
}

-- Generic field processor that handles different data types
local function processFieldValue(value, fieldType)
    if type(value) == "table" then
        if #value > 0 then
            -- Array of values - render as bullet list
            return "* " .. table.concat(value, "\n* ")
        else
            -- Object with key-value pairs - render as sections
            local output = {}
            for category, content in pairs(value) do
                local categoryTitle = "'''" .. category:gsub("^%l", string.upper):gsub("_", " ") .. "'''"
                table.insert(output, categoryTitle)
                if type(content) == "table" and #content > 0 then
                    table.insert(output, "* " .. table.concat(content, "\n* "))
                else
                    table.insert(output, tostring(content))
                end
            end
            return table.concat(output, "\n")
        end
    elseif fieldType == "list" and type(value) == "string" then
        -- Handle semicolon-separated lists with token styling
        local items = {}
        for item in value:gmatch("[^;]+") do
            local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
            if trimmed and trimmed ~= "" then
                table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
            end
        end
        if #items > 0 then
            return table.concat(items, ' ')
        else
            return tostring(value)
        end
    else
        return tostring(value)
    end
end

-- Custom preprocessor to load campaign data and generate fields dynamically
Blueprint.addPreprocessor(template, function(template, args)
    -- Get campaign_name from args (which should already be merged by Blueprint)
    -- But also check the current frame for direct module invocations
    local campaignName = args.campaign_name
    
    -- If not found in args, try to get it from the current frame
    if (not campaignName or campaignName == "") and template.current_frame then
        local frameArgs = template.current_frame.args or {}
        campaignName = frameArgs.campaign_name
    end
    
    if not campaignName or campaignName == "" then
        return args
    end
    
    -- Load campaign data from JSON
    local campaignData = DatasetLoader.get('Campaigns/' .. campaignName)
    if not campaignData or not campaignData.defaults or not campaignData.field_definitions then
        return args
    end
    
    -- Simple console message
    ErrorHandling.addError(
        template._errorContext or ErrorHandling.createContext('T-Campaign'),
        'info',
        'Campaign ' .. campaignName .. ' loaded',
        nil,
        false
    )
    
    -- Detect what custom parameters are provided BEFORE merging defaults
    local customParameters = {}
    local hasCustomParameters = false
    for k, v in pairs(args) do
        -- Skip campaign_name, internal parameters, and empty values
        if k ~= "campaign_name" and 
           not k:match("^_") and  -- Skip internal parameters like _recursion_depth
           v and v ~= "" then
            customParameters[k] = true
            hasCustomParameters = true
        end
    end
    
    -- Check if ALL fields are provided as custom parameters (excluding campaign_intro which is fixed)
    local hasAllParameters = true
    for _, fieldDef in ipairs(campaignData.field_definitions) do
        if fieldDef.key ~= "campaign_intro" and not customParameters[fieldDef.key] then
            hasAllParameters = false
            break
        end
    end
    
    -- Determine mode:
    -- Pure Documentation: only campaign_name provided
    -- Partial Mode: some but not all fields provided  
    -- Complete Mode: all fields provided
    local isPureDocumentation = not hasCustomParameters
    local isPartialMode = hasCustomParameters and not hasAllParameters
    local isCompleteMode = hasCustomParameters and hasAllParameters
    local showInstructions = isPureDocumentation or isPartialMode
    
    -- Store mode flags for use in rendering
    args._documentation_mode = isPureDocumentation
    args._partial_mode = isPartialMode
    args._complete_mode = isCompleteMode
    args._show_instructions = showInstructions
    args._campaign_data = campaignData -- Store for usage instruction generation
    
    -- Defaults are only used for parameter documentation, not content rendering
    
    -- Generate field definitions based on mode
    local fields = {}
    
    -- Always show campaign content fields (they'll show placeholder text when empty)
    for _, fieldDef in ipairs(campaignData.field_definitions) do
        table.insert(fields, {
            key = fieldDef.key,
            label = fieldDef.label,
            type = fieldDef.type
        })
    end
    
    -- Add usage instructions in documentation and partial modes
    if isPureDocumentation or isPartialMode then
        table.insert(fields, {
            key = "usageInstructions",
            label = "Usage Instructions",
            type = "text"
        })
        -- Set a dummy value so the field gets processed
        args.usageInstructions = "documentation"
    end
    
    template.config.fields = fields
    
    -- Override title if provided in JSON defaults
    if campaignData.defaults.title then
        template.config.constants.title = campaignData.defaults.title
    end
    
    -- Add campaign-specific category
    template.config.categories.base = {campaignName}
    
    return args
end)

-- Initialize field processors for the template
-- Set up a universal processor that can handle any field type
if not template._processors then
    template._processors = {}
end

-- Set up a universal field processor that handles all field types
template._processors.default = function(value, args, template, fieldType)
    if value and value ~= "" then
        return processFieldValue(value, fieldType or "text")
    else
        -- Show placeholder for empty fields in documentation and partial modes
        if args._documentation_mode or args._partial_mode then
            return "''Please see usage instructions above to customize this field.''"
        end
        return nil -- Don't display empty fields in complete mode
    end
end

-- Special processor for campaign introduction - always uses JSON default, not user input
template._processors.campaign_intro = function(value, args, template, fieldType)
    -- Always use the campaign data default, ignore user input
    if args._campaign_data and args._campaign_data.defaults and args._campaign_data.defaults.campaign_intro then
        local defaultIntro = args._campaign_data.defaults.campaign_intro
        return "''" .. tostring(defaultIntro) .. "''"
    else
        -- Fallback if no campaign data available
        if args._documentation_mode or args._partial_mode then
            return "''Campaign introduction will appear here from JSON defaults.''"
        end
        return nil
    end
end

-- Special processor for usage instructions
template._processors.usageInstructions = function(value, args, template)
    if not args._show_instructions or not args._campaign_data then
        return nil -- Only render when instructions should be shown
    end
    
    local campaignName = getCampaignName(args, args._campaign_data)
    local instructions = generateUsageInstructions(campaignName, args._campaign_data)
    
    return "\n----\n'''Usage Instructions'''\n\n" .. instructions
end

-- Export the render function
function p.render(frame)
    -- Create a custom render function that bypasses Blueprint's argument extraction
    -- and handles arguments properly for direct module invocation
    
    template.current_frame = frame -- Store frame on template instance

    -- Check recursion depth to prevent infinite loops
    local depth = 0
    if frame.args and frame.args._recursion_depth then
        depth = tonumber(frame.args._recursion_depth) or 0
    elseif frame:getParent() and frame:getParent().args and frame:getParent().args._recursion_depth then
        depth = tonumber(frame:getParent().args._recursion_depth) or 0
    end
    
    if depth > 3 then
        return '<span class="error">Template recursion depth exceeded (limit: 3)</span>'
    end
    
    if not template._errorContext then
        template._errorContext = require('Module:ErrorHandling').createContext(template.type)
    end
    
    if not template.config.meta then
        require('Module:LuaTemplateBlueprint').initializeConfig(template)
    end
    
    -- Handle arguments from both direct module invocation and template calls
    local args = {}
    
    -- Get arguments from parent frame (template parameters)
    local parentArgs = frame:getParent().args or {}
    for k, v in pairs(parentArgs) do
        args[k] = v
    end
    
    -- Get arguments from current frame (module invocation parameters) - these take precedence
    local frameArgs = frame.args or {}
    for k, v in pairs(frameArgs) do
        args[k] = v
    end
    
    -- Normalize argument case like Blueprint does
    local TemplateHelpers = require('Module:TemplateHelpers')
    args = TemplateHelpers.normalizeArgumentCase(args)
    
    -- Increment recursion depth for any child template calls
    args._recursion_depth = tostring(depth + 1)
    
    -- Run preprocessors manually (since we're bypassing Blueprint's renderTemplate)
    local Blueprint = require('Module:LuaTemplateBlueprint')
    args = Blueprint.runPreprocessors(template, args)
    
    -- Get table class configuration
    local tableClass = "template-table"
    if template.config.constants and template.config.constants.tableClass then
        tableClass = template.config.constants.tableClass
    end
    
    -- Set up structure configuration for rendering
    local structureConfig = {
        tableClass = tableClass,
        blocks = {},
        containerTag = template.features.fullPage and "div" or "table"
    }
    
    -- Build rendering sequence manually
    local renderingSequence = Blueprint.buildRenderingSequence(template)
    
    if renderingSequence._length == 0 then
        return ""
    end
    
    -- Add rendering functions to structure config
    for i = 1, renderingSequence._length do
        table.insert(structureConfig.blocks, function(a)
            return renderingSequence[i](a)
        end)
    end
    
    -- Render using TemplateStructure
    local TemplateStructure = require('Module:TemplateStructure')
    local result = TemplateStructure.render(args, structureConfig, template._errorContext)
    
    template.current_frame = nil -- Clear frame from template instance
    
    return result
end

return p