Jump to content

Module:Effect

From Creopedia

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