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 .. '}} '
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 .. ']] '
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