Module:GainLoss

From Fallen London Wiki

Documentation for this module may be created at Module:GainLoss/doc

local p = {}

local function wrap_error_message(message, error_category)
    return '<span class="error">' .. message .. '</span>' .. (error_category or '[[Category:Module GainLoss errors]]')
end

-- Checks input parameters for errors before SMW-dependent validation
local function validate_input_syntax(frame, gain_or_loss)
    if not (gain_or_loss == 'gain' or gain_or_loss == 'loss') then
        return error("Module:GainLoss only supports types 'gain' and 'loss', not '" .. gain_or_loss .. "'")
    end
    -- Item/Quality name is always required
    local has_name = (frame.args.name or '') ~= ''
    if not has_name then
        return wrap_error_message('Error, no item/quality was given.')
    end

    -- These are mutually exclusive
    local has_amount = (frame.args.amount or '') ~= ''
    local has_now = (frame.args.now or '') ~= ''
    local has_occurrence = (frame.args.occurrence or '') ~= ''
    local has_to = (frame.args.to or '') ~= ''
    local has_twist = (frame.args.twist or '') ~= ''
    local has_reset = (frame.args.reset or '') ~= ''
    local has_gone = (frame.args.gone or '') ~= ''
    -- Other parameters to validate
    local has_message = (frame.args.message or '') ~= ''
    local has_hidden = (frame.args.hidden or '') ~= ''
    local has_cap = (frame.args.cap or '') ~= ''
    local has_menace_location = (frame.args.menace_location or '') ~= ''

    -- Check for conflicting parameters

    -- Mutually-exclusive parameters
    local arg_count = ((has_amount and 1) or 0) + ((has_now and 1) or 0) + ((has_occurrence and 1) or 0) + ((has_to and 1) or 0) + ((has_twist and 1) or 0) + ((has_gone and 1) or 0) + ((has_reset and 1) or 0)
    if arg_count > 1 then
        return wrap_error_message('Error, too many level parameters were given.')
    end

    -- Message parameter is only compatible with amount or now
    if has_message and arg_count > 0 and not (has_amount or has_now) then
        return wrap_error_message('Error, <code>message</code> parameter given alongside incompatible parameters.')
    end

    -- Cap parameter is only compatible with amount, now, occurrence, and to
    if has_cap and arg_count > 0 and not (has_amount or has_now or has_occurrence or has_to) then
        return wrap_error_message('Error, <code>cap</code> parameter given alongside incompatible parameters.')
    end

    -- Check for parameters only allowed with one of Gain or Loss
    if gain_or_loss ~= 'gain' then
        if has_cap then
            return wrap_error_message('Error, <code>cap</code> parameter is not supported by Loss.')
        end
    elseif gain_or_loss ~= 'loss' then
        if has_gone then
            return wrap_error_message('Error, <code>gone</code> parameter is not supported by Gain.')
        elseif has_reset then
            return wrap_error_message('Error, <code>reset</code> parameter is not supported by Gain.')
        elseif has_menace_location then
            return wrap_error_message('Error, <code>menace_location</code> parameter is not supported by Gain.')
        end
    end

    -- Check values for optional parameters that only accept specific value(s)
    if has_twist and frame.args.twist ~= 'yes' then
        return wrap_error_message('Error, <code>' .. frame.args.twist .. '</code> is not an allowed <code>twist</code> parameter input.')
    end
    if has_reset and frame.args.reset ~= 'yes' then
        return wrap_error_message('Error, <code>' .. frame.args.reset .. '</code> is not an allowed <code>reset</code> parameter input.')
    end
    if has_gone and frame.args.gone ~= 'yes' then
        return wrap_error_message('Error, <code>' .. frame.args.gone .. '</code> is not an allowed <code>gone</code> parameter input.')
    end
    if has_hidden and frame.args.hidden ~= 'yes' then
        return wrap_error_message('Error, <code>' .. frame.args.hidden .. '</code> is not an allowed <code>hidden</code> parameter input.')
    end

    -- No errors found
    return nil
end

-- Check input parameters for semantic errors given the SMW properties
local function validate_input_semantics(params, game_type, is_pyramidal)
	local name = params.name
	local amount = params.amount
	local reset = params.reset
	local menace_location = params.menace_location

    -- Amounts for pyramidal qualities must specify whether the change is CP or levels,
    -- represented by " CP" or " x" followed by a word boundary (checked via frontier patterns)
    if is_pyramidal and amount and not (mw.ustring.find(amount, ' CP%f[%A]') or mw.ustring.find(amount, ' x%f[%A]')) then
        return wrap_error_message('Error, [[' .. name .. ']] is a Pyramidal quality but the change amount did not specify "<code> CP</code>" or "<code> x</code>".', '[[Category:Module GainLoss effects with ambiguous Pyramidal quality]]')
    end
    -- If reset was provided, make sure that this is a quality
    if reset and game_type ~= 'Quality' then
        return wrap_error_message('Error, [[' .. name .. ']] is a(n) ' .. game_type .. ' and does not support the <code>reset</code> parameter.')
    end
    -- If menace_location was provided, validate it
    -- TODO find a better way to confirm this is a valid menace/location pair than hard-coding it
    local LOCATION_TO_MENACE = {
        ['a slow boat passing a dark beach on a silent river'] = 'Wounds',
        ['State of some confusion'] = 'State of some confusion',
        ['Mirror-Marches'] = 'Nightmares',
        ['Tomb-Colonies'] = 'Scandal',
        ['Imprisoned'] = 'Suspicion',
    }
    if menace_location and LOCATION_TO_MENACE[menace_location] ~= name then
        return wrap_error_message('Error, ' .. menace_location .. ' is not a valid <code>Menace location</code> input for [[' .. name .. ']].')
    end

    -- No errors found
    return nil
end

local function is_pyramidal_cp_amount(amount)
	-- Assume pyramidal qualities are CP unless explicitly discrete
	if not amount then
		return true
	end
	return mw.ustring.find(amount, ' x%f[%A]') == nil
end

local function now_param_with_message(now, IL_link, cap_msg, gain_or_loss)
    local cap_msg_comma = (cap_msg and (', ' .. cap_msg)) or ''
    if gain_or_loss == 'loss' and (now == '0' or now == '0 x') then           -- TODO find a way to handle zero values that include level description
        -- "Removes [all ]<appearance | name>"
        return "Removes " .. ((now == '0 x' and 'all ') or '') .. IL_link
    else
        -- "Sets <appearance | name> to <now>[, up to [level ]<cap>]"
        return "Sets " .. IL_link .. " to " .. now .. cap_msg_comma
    end
end

local function amount_param_with_message(amount, IL_link, cap_msg, game_type, is_pyramidal, gain_or_loss)
    local cap_msg_comma = (cap_msg and (', ' .. cap_msg)) or ''
    if game_type == 'Item' then
        -- "<'Gives' | 'Removes'> <amount | '? x'> <appearance | name>[, up to <cap>]"
        local gain_loss_text = (gain_or_loss == 'gain' and 'Gives ') or 'Removes '
        return gain_loss_text .. (amount or '? x') .. " " .. IL_link .. cap_msg_comma
    elseif is_pyramidal and is_pyramidal_cp_amount(amount) then
        -- "<'Increases' | 'Decreases'> <appearance | name> by <amount | '? CP'>[, up to level <cap>]"
        local gain_loss_text = (gain_or_loss == 'gain' and 'Increases') or 'Decreases'
        return gain_loss_text .. " " .. IL_link .. " by " .. (amount or '? CP') .. cap_msg_comma
    else
        -- "<'Raises' | 'Lowers'> <appearance | name> by <amount | '?'>[, up to <cap>]"
        local gain_loss_text = (gain_or_loss == 'gain' and 'Raises') or 'Lowers'
        return gain_loss_text .. " " .. IL_link .. " by " .. (amount or '?') .. cap_msg_comma
    end
end

local function text_with_message_param(message, amount, now, IL_link, cap_msg, game_type, is_pyramidal, gain_or_loss)
    -- "<message> (<change description>)"
    local change_desc
    local cap_msg_comma = (cap_msg and (', ' .. cap_msg)) or ''
    if now then
        -- Set directly to a level
        change_desc = now_param_with_message(now, IL_link, cap_msg, gain_or_loss)
    else
        -- Modified by amount
        change_desc = amount_param_with_message(amount, IL_link, cap_msg, game_type, is_pyramidal, gain_or_loss)
    end
    return "''" .. message .. "'' (" .. change_desc .. ")"
end

local function default_now_param(now, wikitext_link, cap_msg, gain_or_loss)
    local cap_msg_paren = (cap_msg and (' (' .. cap_msg .. ')')) or ''
    if gain_or_loss == 'loss' and (now == '0 x' or now == '0') then     -- TODO find a way to handle zero values that include level description
        -- "You no longer have any of this: <appearance | name>"
        return "You no longer have any of this: <b>" .. wikitext_link .. "</b>"
    else
        -- "You now have <now> <appearance | name>[ (up to <cap>)]"
        return "You now have " .. now .. " <b>" .. wikitext_link .. "</b>" .. cap_msg_paren
    end
end

local function default_amount_param(amount, wikitext_link, cap_msg, is_pyramidal, gain_or_loss)
    -- While <amount> usually represents CP for Pyramidal qualities, they can also increase by
    -- flat level amounts, using the same "You've <gained | lost> # x <quality>" message
    -- as for items and discrete qualities.
    -- As we don't know how this is represented internally, determine which message to use
    -- based on whether the amount string contains " x" or " CP".
    if is_pyramidal and is_pyramidal_cp_amount(amount) then
        -- "<appearance | name> is <'increasing' | 'dropping'>… (<'+' | '-'><amount | '? CP'>[, up to level <cap>])"
        local gain_loss_text = (gain_or_loss == 'gain' and 'increasing… (+') or 'dropping… (-'
        local cap_msg_comma = (cap_msg and (', ' .. cap_msg)) or ''
        return "<b>" .. wikitext_link .. "</b> is " .. gain_loss_text .. (amount or '? CP') .. cap_msg_comma .. ")"
    else
        -- "You've <'gained' | 'lost'> <amount | '?'> <appearance | name>[ (up to <cap>)]"
        local gain_loss_text = (gain_or_loss == 'gain' and 'gained') or 'lost'
        local cap_msg_paren = (cap_msg and (' (' .. cap_msg .. ')')) or ''
        return "You've " .. gain_loss_text .. " " .. (amount or '?') .. " <b>" .. wikitext_link .. "</b>" .. cap_msg_paren
    end
end

-- Returns either [result, nil] or [nil, error_message]
local function gainloss_message(frame, gain_or_loss)
    -- Item or Quality name (required)
    local name = frame.args.name

    -- Load parameters, setting empty parameters to false
    -- note "<expression> and <a> or <b>" is similar to the ternary operator in other languages, "<expression> ? <a> : <b>"
    -- i.e. returns <a> if <expression> is true and <a> is truthy, otherwise returns <b>

    -- Level change type parameters
    -- Amount of change (unless provided via a mutually-exclusive parameter)
    local amount = (frame.args.amount ~= '') and frame.args.amount or false
    -- "now" set directly to level param (optional, mutually exclusive)
    local now = (frame.args.now ~= '') and frame.args.now or false
    -- Custom change message (optional, only compatible with <amount> and <now>)
    local message = (frame.args.message ~= '') and frame.args.message or false
    -- "occurrence" set directly to level param (optional, mutually exclusive)
    local occurrence = (frame.args.occurrence ~= '') and frame.args.occurrence or false
    -- "to" set directly to level param (optional, mutually exclusive)
    local to = (frame.args.to ~= '') and frame.args.to or false
    -- add or remove item/quality param (optional, only allows value "yes", mutually exclusive)
    local twist = frame.args.twist == 'yes'

    -- Display parameters
    -- Denote a hidden effect (optional, only allows value "yes")
    local hidden = frame.args.hidden == 'yes'
    -- Custom image to use (optional, takes "none" or file name)
    local image = (frame.args.image ~= '') and frame.args.image or false
    -- Custom text for item/quality links (optional)
    local appearance = (frame.args.appearance ~= '') and frame.args.appearance or false

    -- Gain-exclusive parameters
    -- Gains cap as a string (optional, use "none" to override cap)
    local cap = gain_or_loss == 'gain' and ((frame.args.cap ~= '') and frame.args.cap or false)

    -- Loss-exclusive parameters
    -- "gone" remove item/quality param (optional, takes value "yes", mutually exclusive)
    local gone = gain_or_loss == 'loss' and frame.args.gone == 'yes'
    -- "reset" remove item/quality param (optional, takes value "yes", mutually exclusive)
    local reset = gain_or_loss == 'loss' and frame.args.reset == 'yes'
    -- Menace location param for categorizing those loses separately (optional)
    local menace_location = gain_or_loss == 'loss' and ((frame.args.menace_location ~= '') and frame.args.menace_location or false)

    -- Begin generating output
    local result = ''

    -- Display the image at the start of the message, unless image = none (useful for living stories and hidden qualities)
    if not image then
        -- By default use the Icon associated with the quality/item
        result = '{{I|' .. name .. '}}&nbsp;'
    elseif image ~= 'none' then
        --  Use a custom image if given
        if not mw.ustring.find(image, 'small%.png') then
            -- Force small images
            image = mw.ustring.gsub(image, '%.png', 'small.png')
        end
        result = '[[File:' .. image .. '|link=' .. name .. ']]&nbsp;'
    end

    -- Load SMW properties and if appropriate throw errors
    local game_type = frame:callParserFunction('#show', { name, '?Has Game Type' })
    if not (game_type == 'Item' or game_type == 'Quality' or game_type == 'World Quality') then
        -- TODO check if the page exists and attempt to circumvent weird transient SMW issue
        -- Add the image to this error message for style reasons (i.e. for when the transient SMW issues break working template uses)
        return nil, result .. wrap_error_message('Error, [[' .. name .. ']] is neither a quality nor an item. Please check if [[' .. name .. ']] exists, and if it doesn\'t please create it with the appropriate template.', '[[Category:Module GainLoss invalid game type errors]]')
    end
    local is_pyramidal
    if game_type == 'Quality' or game_type == 'World Quality' then
        local qual_type = frame:callParserFunction('#show', { name, '?Increase Type' })
        if not (qual_type == 'Discrete' or qual_type == 'Pyramidal') then
            return nil, wrap_error_message('Error, ' .. name .. ' is a quality but neither Discrete nor Pyramidal.')
        end
        is_pyramidal = qual_type == 'Pyramidal'
    end
    
    -- Standardize formatting for <amount>
    if amount then
    	-- The module will re-add plus and minus signs where appropriate
    	amount = mw.ustring.gsub(amount, '^[+-] *', '')
    	-- Format discrete amounts consistently
    	amount = mw.ustring.gsub(amount, ' [Xx]%f[%A]', ' x')
    	-- Format CP amounts consistently
    	if is_pyramidal then
    		amount = mw.ustring.gsub(amount, ' [Cc][Pp]%f[%A]', ' CP')
    	end
    end
    
    local found_semantic_errors = validate_input_semantics({
    	['name'] = name,
    	['amount'] = amount,
    	['reset'] = reset,
    	['menace_location'] = menace_location,
    }, game_type, is_pyramidal)
    if found_semantic_errors then
        return nil, found_semantic_errors
    end

    local wikitext_link = "[[" .. name .. ((appearance and ('|' .. appearance)) or '') .. "]]"
    local IL_link = "{{IL|" .. name ..  ((appearance and ('|Appearance=' .. appearance)) or '') .. "}}"

    -- Gain cap message strings for easier concatenation
    -- Determine these using parameter or SMW property
    local cap_msg
    if gain_or_loss == 'gain' then    -- TODO add category or property to uncapped gains?
        if cap and cap ~= 'none' then
            -- Cap parameter given, add a maintenance category if it contains a question mark
            cap_msg = cap
            if mw.ustring.find(cap, '%?') then
                cap = cap .. "{{Noembed|[[Category:Unknown Gains cap]]}}"
            end
        else
            -- Check for [[Property:Has cap]]
            local cap_prop = frame:callParserFunction('#show', { name, '?Has cap' })
            if cap_prop ~= '' then
                if cap == 'none' then
                    -- Normal cap is being overridden, explicitly note this
                    cap_msg = 'uncapped'
                else
                    cap_msg = cap_prop
                    -- If this quality has [[Property:Has cap raise]], display the cap formula
                    local raise_quality = frame:callParserFunction('#show', { name, '?Has cap raise', link = 'none' })
                    if raise_quality ~= '' then
                        local raise_amount = tonumber(frame:callParserFunction('#show', { raise_quality, '?Has cap' })) or 0
                        if raise_amount > 0 then
                            -- While the cap parameter can be a string, we assume cap_prop is always numeric
                            cap_msg = (cap_prop - raise_amount) .. ' + {{IL|' .. raise_quality .. '}}'
                        end
                    end
                end
            end
        end
        -- Format cap message
        if cap_msg and cap_msg ~= 'uncapped' then
            cap_msg = 'up to ' .. ((is_pyramidal and 'level ') or '') .. cap_msg
        end
    end

    -- Some parameters take priority and display independent of Game Type, check them first
    if occurrence then
        -- "An occurrence! Your <appearance | name> quality is now <occurrence>!"
        local cap_msg_paren = (cap_msg and (' (' .. cap_msg .. ')')) or ''
        result = result .. "An occurrence! Your '<b>" .. wikitext_link .. "</b>' Quality is now " .. occurrence .. "!" .. cap_msg_paren
    elseif to then
        -- "<appearance | name> has <'increased' | 'dropped'> to <to>!"
        local gain_loss_text = (gain_or_loss == 'gain' and 'increased') or 'dropped'
        local cap_msg_paren = (cap_msg and (' (' .. cap_msg .. ')')) or ''
        result = result .. "<b>" .. wikitext_link .. "</b> has " .. gain_loss_text .. " to " .. to .. "!"  .. cap_msg_paren
    elseif twist then
        -- "A twist in your tale! You are <'now' | 'no longer'> <appearance | name>."
        local gain_loss_text = (gain_or_loss == 'gain' and 'now') or 'no longer'
        result = result .. "A twist in your tale! You are " .. gain_loss_text .. " <b>" .. wikitext_link .. "</b>."
    -- These two parameters are only used by Loss
    elseif gone then
        -- "Your '<appearance | name>' Quality has gone!"
        result = result .. "Your '<b>" .. wikitext_link .. "</b>' Quality has gone!"
    elseif reset then
        -- "'<appearance | name>' has been reset: a conclusion, or a new beginning?"
        result = result .. "'<b>" .. wikitext_link .. "</b>' has been reset: a conclusion, or a new beginning?"
    -- Check parameters that display differently for different Game Type
    elseif message then
        -- Custom message with effect in parentheses
        result = result .. text_with_message_param(message, amount, now, IL_link, cap_msg, game_type, is_pyramidal, gain_or_loss)
    elseif now then
        -- Set directly to a given level
        result = result .. default_now_param(now, wikitext_link, cap_msg, gain_or_loss)
    else
        -- Modified by amount
        result = result .. default_amount_param(amount, wikitext_link, cap_msg, is_pyramidal, gain_or_loss)
    end

    if hidden then
        result = result .. ' {{hidden}}'
    end

    -- Set Gain and Loss categories
    if gain_or_loss == 'gain' then
        result = result .. '{{Noembed|[[Category:' .. name ..' Gain]]}}'
    else
        if menace_location then
            -- Menaces with menace locations categorize those losses separately
            result = result .. '{{Noembed|[[Category:' .. name ..' Loss (' .. menace_location ..')]]}}'
        else
            result = result .. '{{Noembed|[[Category:' .. name ..' Loss]]}}'
        end
    end

    -- Set Gains/Loses properties
    result = result .. '{{Noembed|{{#set:' .. ((gain_or_loss == 'gain' and 'Gains') or 'Loses') .. '=' .. name .. '}}}}'

    -- Apply [[Property:Has cap on gain]] to Gain if cap parameter present
    if cap and cap ~= 'none' then
        result = result .. '{{Noembed|{{#set:Has cap on gain=' .. name .. ';' .. cap .. '}}}}'
    end

    -- Add tracking categories for missing information
    -- TODO make parent error category Pages missing Gain or Loss information
    if not (amount or now or occurrence or to or twist or gone or reset) then
        result = result .. "{{Noembed|[[Category:Gain or Loss without new value or increase]]}}"
    elseif is_pyramidal and amount and mw.ustring.find(amount, '%?.*CP') then
        result = result .. "{{Noembed|[[Category:Missing CP]]}}"
    elseif (amount or now or occurrence or to) and mw.ustring.find((amount or now or occurrence or to), '%?') then
        -- TODO filter out parameters that include quality level descriptions with question marks
        result = result .. "{{Noembed|[[Category:Pages with unknown Gain or Loss numbers]]}}"
    end

    return result, nil
end

function p.gain(frame)
    local error_message = validate_input_syntax(frame, 'gain')
    if error_message then
        return error_message
    end

    local result, error = gainloss_message(frame, 'gain')
    return frame:preprocess(result or error)
end

function p.loss(frame)
    local error_message = validate_input_syntax(frame, 'loss')
    if error_message then
        return error_message
    end

    local result, error = gainloss_message(frame, 'loss')
    return frame:preprocess(result or error)
end

return p