Module:Effect
Appearance
Documentation for this module may be created at Module:Effect/doc
require( 'strict' )
local infoboxItemCard = require( 'Module:InfoboxLua/Components/Item/Card' )
local p = {}
--- Items, traits, abilities and moves all carry the same effect map shape, but
--- they do not all read the same way, so each gets its own context.
local DEFAULT_CONTEXT = 'item'
--- How a self-targeted effect reads, per context.
---
--- This is the one clause that cannot be shared. An item is worn, a move is
--- used, and a trait simply belongs to the creo, so no single wording serves
--- all four without reading as nonsense somewhere.
local SELF_CLAUSE = {
item = 'to the equipped creo',
trait = 'to the creo itself',
ability = 'to the creo itself',
move = 'to the user',
}
--- Trigger and target pairings so ordinary that a note would be noise, keyed
--- 'trigger id:target id' within each context.
---
--- Measured with `effects.py pairs --kind <k>`, not guessed. What counts as
--- obvious differs by kind: a move that inflicts something on attack hit is
--- just an attacking move, while the same pairing on an item is the whole
--- reason the item is interesting.
local OBVIOUS = {
item = {
['59:0'] = true, -- On stat calculation, to the equipped creo
['1:8'] = true, -- On use, to the chosen creo
['1:50'] = true, -- On use, to you
},
trait = {
['59:0'] = true, -- On stat calculation, to the creo itself
},
ability = {
-- Every ability effect is this one pairing, so no ability shows a note.
['1:50'] = true, -- On use, to you
},
move = {
['45:1'] = true, -- On attack hit, to the opposing creo
},
}
--- How each trigger reads in a note.
---
--- Keyed by the numeric id rather than the display name, because ids are stable
--- across game data refreshes and the names are not.
local TRIGGER_PHRASE = {
[1] = 'when used',
[2] = 'when the battle starts',
[3] = 'when the battle ends',
[4] = 'when the creo is summoned',
[5] = 'when the creo faints',
[6] = 'after the creo is hit',
[7] = 'before the creo acts',
[8] = "before the opponent's turn",
[9] = 'while below 75% HP',
[10] = 'while below 50% HP',
[11] = 'while below 25% HP',
[12] = 'when HP is regained',
[13] = 'while below 75% energy',
[14] = 'while below 50% energy',
[15] = 'while below 25% energy',
[16] = 'when energy runs out',
[17] = 'on capture',
[26] = 'while below full HP',
[27] = 'while below full energy',
[28] = 'on level up',
[29] = 'on prestige up',
[30] = 'when the creo is hit',
[31] = 'before the creo is hit',
[32] = 'after the creo acts',
[34] = 'when an item is used',
[35] = 'when stance changes',
[36] = 'after an item is used',
[37] = 'when an attack is used',
[38] = 'before an attack is used',
[39] = 'after an attack is used',
[40] = 'when turn order is decided',
[41] = 'before escaping',
[42] = 'before the creo is summoned',
[43] = 'while moving',
[44] = 'always active',
[45] = 'on attack hit',
[46] = 'before an attack hits',
[47] = 'after an attack hits',
[49] = 'when a party member is chosen',
[50] = 'when experience is earned',
[51] = 'when money is earned',
[52] = 'when HP change is calculated',
[53] = 'when energy use is calculated',
[54] = 'when experience is calculated',
[55] = 'when accuracy is calculated',
[56] = 'when energy regeneration is calculated',
[57] = 'while exploring',
[58] = 'when creo rarity is rolled',
[59] = 'always active',
[60] = 'when a boon is gained',
[61] = 'when a boon is lost',
[62] = 'when a condition is applied',
[63] = 'when a condition is removed',
[64] = 'at the condition limit',
[65] = 'at the boon limit',
[66] = 'when a capture is resolved',
[67] = 'when an escape is resolved',
[68] = 'when money earned is calculated',
[69] = 'when money lost is calculated',
[70] = 'before stance changes',
[71] = 'on entering a map',
[72] = 'when a boon counter changes',
[73] = 'when a condition counter changes',
[74] = 'when the creo is healed',
}
--- How each target reads in a note.
---
--- The preposition is part of the clause, because the targets do not all take
--- the same one. An effect applies *to* a creo, but 'to the world' reads as
--- nonsense where 'affecting the world' does not.
---
--- Target 0 is deliberately absent: it comes from SELF_CLAUSE instead.
local TARGET_CLAUSE = {
[1] = 'to the opposing creo',
[2] = 'to every creo on the field',
[3] = 'to your party',
[4] = "to the opponent's party",
[5] = 'to both parties',
[6] = 'to your active creo',
[8] = 'to the chosen creo',
[50] = 'to you',
[51] = 'to the opposing trainer',
[52] = 'to both trainers',
[80] = 'affecting the world',
[81] = 'affecting the whole battle',
[100] = 'affecting everything',
}
--- Names the casing rule below cannot reach on its own, because they need a
--- word inserted rather than a letter changed.
---
--- Watch the spelling of the keys: the export writes 'Immune Bad burn' with a
--- lower case burn but 'Immune Bad Bleed' with an upper case bleed. They are
--- matched exactly, so they are copied from `effects.py names` rather than
--- typed out from memory.
local NAME_OVERRIDES = {
['Immune Bad Bleed'] = 'Immune to bad bleed',
['Immune Bad burn'] = 'Immune to bad burn',
['Immune Bad Poison'] = 'Immune to bad poison',
}
--- @param context string|nil
--- @return string
local function contextOf( context )
return SELF_CLAUSE[context] and context or DEFAULT_CONTEXT
end
--- @param targetId number|nil
--- @param context string
--- @return string|nil
local function targetClause( targetId, context )
if targetId == 0 then
return SELF_CLAUSE[context]
end
return TARGET_CLAUSE[targetId]
end
--- The effect name as the wiki shows it: the game's own wording, cased sanely.
---
--- The export mixes shouting and title case within one table, so a plain list
--- of effect names puts 'REGEN 50HP' next to 'Special up 25%' next to
--- 'LINK Base Chance' and reads as though it came from three different games.
--- Lower casing and restoring the first letter settles that without inventing
--- wording the game never used.
---
--- @param name string
--- @return string
local function tidyName( name )
if NAME_OVERRIDES[name] then
return NAME_OVERRIDES[name]
end
local text = mw.ustring.lower( mw.text.trim( name ) )
-- 'REGEN 50HP' -> 'Regen 50 HP', 'Air affinity up 20pts' -> '... 20 pts'.
-- These run first, so the HP they write is not matched again below.
text = mw.ustring.gsub( text, '(%d)%s*hp', '%1 HP' )
text = mw.ustring.gsub( text, '(%d)%s*pts', '%1 pts' )
-- Whole word fixes: 'Absorb fire 20% HP recovered' and 'One hit KO', where
-- the token stands alone rather than following a digit. Padded with spaces
-- so a match at either end of the string still has a neighbour, rather than
-- relying on a frontier pattern.
text = ' ' .. text .. ' '
text = mw.ustring.gsub( text, '([^%w])hp([^%w])', '%1HP%2' )
text = mw.ustring.gsub( text, '([^%w])ko([^%w])', '%1KO%2' )
text = mw.ustring.sub( text, 2, -2 )
-- Sentence case, but only when the name begins with a letter. Capitalising
-- the first letter *found* instead would turn '2 to 6 Multi-hit' into
-- '2 To 6 multi-hit'.
local first = mw.ustring.sub( text, 1, 1 )
if mw.ustring.match( first, '%a' ) then
text = mw.ustring.upper( first ) .. mw.ustring.sub( text, 2 )
end
return text
end
--- @param effectMap table
--- @return string|nil
local function getEffectName( effectMap )
if not effectMap or type( effectMap.effect ) ~= 'table' or not effectMap.effect.name then
return nil
end
return tidyName( effectMap.effect.name )
end
--- A block of effect lines.
---
--- Built with mw.html rather than as a '*' wikitext list, because the result is
--- handed to :wikitext() inside a div, where a bullet list needs leading
--- newlines to parse and will not reliably render.
---
--- @param lines string[]
--- @param class string
--- @return string
local function renderList( lines, class )
local list = mw.html.create( 'ul' ):addClass( class )
for _, line in ipairs( lines ) do
list:tag( 'li' ):wikitext( line )
end
return tostring( list )
end
--- One line of the Effects list.
---
--- The chance is shown only when it is not a certainty. No effect map in the
--- August 2026 export has a chance of zero, so a bare '< 100' test carries no
--- 'zero means always' trap.
---
--- @param effectMap table
--- @return string|nil
function p.getLabel( effectMap )
local name = getEffectName( effectMap )
if not name then
return nil
end
local chance = effectMap.triggerChance
if type( chance ) == 'number' and chance < 100 then
return string.format( '%s (%s%%)', name, tostring( chance ) )
end
return name
end
--- One line of the Notes list, or nil when the timing and target are obvious.
---
--- A trigger or target with no phrase falls back to the wording in the game
--- data rather than dropping the note. A refresh that introduces a new trigger
--- should read stiffly for a while, not silently lose information.
---
--- @param effectMap table
--- @param context string|nil 'item' | 'trait' | 'ability' | 'move'
--- @return string|nil
function p.getNote( effectMap, context )
local name = getEffectName( effectMap )
if not name then
return nil
end
context = contextOf( context )
local trigger = type( effectMap.effectTrigger ) == 'table' and effectMap.effectTrigger or {}
local target = type( effectMap.target ) == 'table' and effectMap.target or {}
if trigger.id and target.id and OBVIOUS[context][trigger.id .. ':' .. target.id] then
return nil
end
local when = TRIGGER_PHRASE[trigger.id]
local who = targetClause( target.id, context )
if not when and trigger.name then
when = mw.ustring.lower( trigger.name )
end
if not who and target.name then
who = 'to ' .. mw.ustring.lower( target.name )
end
if not when and not who then
return nil
end
if not who then
return string.format( '%s: %s.', name, when )
end
if not when then
return string.format( '%s: applies %s.', name, who )
end
return string.format( '%s: %s, %s.', name, when, who )
end
--- The Effects card, and the Notes card when any effect earns one.
---
--- Cards rather than plain infobox items: a section is a two or three column
--- grid, and Module:InfoboxLua/styles.css spans a card across the whole row.
--- A plain item would squeeze the list into one narrow cell.
---
--- @param effectMaps table[]|nil
--- @param context string|nil 'item' | 'trait' | 'ability' | 'move'
--- @return table[] zero, one or two ItemCardComponentData
function p.getInfoboxCards( effectMaps, context )
context = contextOf( context )
local labels, notes = {}, {}
-- Guarded, because a record with no effect maps at all would otherwise stop
-- the whole infobox with a script error.
for _, effectMap in ipairs( effectMaps or {} ) do
local label = p.getLabel( effectMap )
local note = p.getNote( effectMap, context )
if label then
table.insert( labels, label )
end
if note then
table.insert( notes, note )
end
end
local cards = {}
if #labels == 0 then
return cards
end
table.insert( cards, infoboxItemCard.getItemComponentData( {
label = 'Effects',
content = renderList( labels, 't-infobox-effects' )
} ) )
if #notes > 0 then
table.insert( cards, infoboxItemCard.getItemComponentData( {
label = 'Notes',
content = renderList( notes, 't-infobox-notes' )
} ) )
end
return cards
end
return p