Module:Damage display

From bg3.wiki
Jump to navigation Jump to search
Example of an in-game tooltip this module aims to replicate.

This module renders damage information in a format designed to replicate the in-game view.

Parameters

Parameter Meaning
format Format of the damage display, either list (default) or inline for a compact inline display.
damage n The damage string in the simple format

ExprTerm + Expr | Term
TermDice | Integer | Modifier
DiceInteger "d" Integer
ModifierAbility "mod" | Ability "modifier" | Ability | "prof" | "proficiency bonus"
Ability → "strength" | "str" | "dexterity" | "dex" | "constitution" | "con"
    | "wisdom" | "wis" | "intelligence" | "int" | "charisma" | "cha"
    | "spellcasting" | "caster" | "spell" | "melee" | "ranged" | "finesse"

Example: 2d8 + 1d6 + 4 + cha modifier + strength mod

damage n type The type of the damage which may be any of the damage types in the game or one of the special values: weapon (for damage type that is inherited from the weapon), Physical (for an unspecified physical damage type), or Healing (for healing which is displayed separately from damage).
damage n info Free-form field for adding additional information about a damage instance. For example, "per ray" for Scorching Ray damage, "if the target is a Fiend or Undead" for extra Divine Smite damage, or "delayed" for Melf's Acid Arrow damage.
damage n modifier

Deprecated - Add the damage modifier as part of the damage value.

The modifier added to the damage. It may be a specific ability score such as Strength or Charisma or it may be a special value such as melee, ranged, finesse, or spell.

str Strength ability score used for evaluating modifiers
dex Dexterity ability score used for evaluating modifiers
con Constitution ability score used for evaluating modifiers
int Intelligence ability score used for evaluating modifiers
wis Wisdom ability score used for evaluating modifiers
cha Charisma ability score used for evaluating modifiers
casting ability The ability score used for casting. Determines how to evaluate the spell special modifier value.
weapon Specify the weapon used in order to evaluate generic "Normal weapon damage" values.
dice size Specify the size of the dice images. Setting it to 0 removes them entirely.
level Specify the level which is needed to evaluate "Proficiency bonus" damage modifiers.

Examples

Example Markup Renders as
Unspecified ability scores
{{#invoke: Damage display | main
| damage 1      = 1d6 + 2 + finesse mod
| damage 1 type = Piercing
| damage 2      = 1d6
| damage 2 type = Fire
}}
Damage: 4~14 + modifiers
Specified ability scores
{{#invoke: Damage display | main
| damage 1      = 1d6 + 2 + finesse mod
| damage 1 type = Piercing
| damage 2      = 1d6
| damage 2 type = Fire
| damage 3      = 2d8
| damage 3 type = Radiant

| str = 9
| dex = 17
}}
Damage: 9~33
D6 Piercing.pngD6 Fire.pngD8 Radiant.png
1d6 + 5Damage TypesPiercing
+ 1d6Damage TypesFire
+ 2d8Damage TypesRadiant
Specified casting ability
{{#invoke: Damage display | main
| damage 1      = 1d10 + spell + spell
| damage 1 type = Force
| damage 2      = 1d10 + spell + spell
| damage 2 type = Force
| damage 3      = 1d10 + spell + spell
| damage 3 type = Force

| wis = 10
| int = 8
| cha = 17
| casting ability = cha
}}
Damage: 21~48
D10 Force.pngD10 Force.pngD10 Force.png
1d10 + 6Damage TypesForce
+ 1d10 + 6Damage TypesForce
+ 1d10 + 6Damage TypesForce
Unspecified weapon
{{#invoke: Damage display | main
| damage 1      = weapon
| damage 2      = 1d6
| damage 2 type = Necrotic
}}
Damage: 1~6
D6 Necrotic.png
Normal weapon damage
+ 1d6Damage TypesNecrotic
Specified weapon
{{#invoke: Damage display | main
| damage 1      = weapon
| damage 2      = 1d6
| damage 2 type = Necrotic

| weapon = Spear +1 
}}
Damage: 3~13 + modifiers
Specified weapon and abilities
{{#invoke: Damage display | main
| damage 1      = weapon
| damage 2      = 1d6
| damage 2 type = Necrotic

| weapon = Spear +1
| str = 17
| dex = 12
}}
Damage: 6~16
D6 Piercing.pngD6 Necrotic.png
1d6 + 4Damage TypesPiercing
+ 1d6Damage TypesNecrotic
Healing
{{#invoke: Damage display | main
| damage 1          = 1d6 + wis
| damage 1 type     = Healing

| wis = 19
}}
Healing: 5~10
D6 Healing.png
1d6 + 4Healing
Proficiency bonus
{{#invoke: Damage display | main
| damage 1      = 1d12 + 2
| damage 1 type = Slashing
| damage 2      = prof
| damage 2 type = Radiant
}}
Damage: 3~14 + modifiers
Proficiency bonus w/ level
{{#invoke: Damage display | main
| damage 1      = 1d12 + 2
| damage 1 type = Slashing
| damage 2      = prof
| damage 2 type = Radiant

| level = 8
}}
Damage: 6~17
D12 Slashing.png
1d12 + 2Damage TypesSlashing
Info field
{{#invoke: Damage display | main
| damage 1      = 2d4
| damage 1 type = Acid
| damage 1 info = Delayed
| damage 2      = 2d8
| damage 2 type = Radiant
| damage 2 info = If the target is a Fiend or Undead
| damage 3      = 2d6
| damage 3 type = Fire
| damage 3 info = per ray
| damage 4      = 1d6
| damage 4 type = Piercing
| damage 4 info = to self
}}
Damage: 7~42
D4 Acid.pngD8 Radiant.pngD6 Fire.pngD6 Piercing.png
2d4Damage TypesAcid (Delayed)
+ 2d8Damage TypesRadiant (If the target is a Fiend or Undead)
+ 2d6Damage TypesFire (per ray)
+ 1d6Damage TypesPiercing (to self)
Freeform damage input
{{#invoke: Damage display | main
| damage 1      = (Sorcerer level)/2
| damage 1 type = Lightning
| damage 2      = 5 + 2 x (Cleric level)
| damage 2 type = Necrotic
}}
Damage: 5 + modifiers
(Sorcerer level)/2Damage TypesLightning
+ 5 + 2 x (Cleric level)Damage TypesNecrotic
Big dice
{{#invoke: Damage display | main
| damage 1      = 1d12
| damage 1 type = Cold
| damage 2      = 1d10
| damage 2 type = Lightning
| damage 3      = 2d8
| damage 3 type = Psychic
| damage 4      = 1d4
| damage 4 type = Force
| damage 5      = 2d6
| damage 5 type = Bludgeoning

| dice size = 45
}}
Damage: 7~54
D12 Cold.pngD10 Lightning.pngD8 Psychic.pngD4 Force.pngD6 Bludgeoning.png
1d12Damage TypesCold
+ 1d10Damage TypesLightning
+ 2d8Damage TypesPsychic
+ 1d4Damage TypesForce
No dice
{{#invoke: Damage display | main
| damage 1      = 1d12 + 2
| damage 1 type = Slashing
| damage 2      = 1d6
| damage 2 type = Poison

| dice size = 0
}}
Damage: 4~20
1d12 + 2Damage TypesSlashing
+ 1d6Damage TypesPoison
Inline output
This format can be used inline: {{#invoke: Damage display | main
| format = inline
| damage 1      = 1d12 + 2
| damage 1 type = Slashing
| damage 2      = 1d6
| damage 2 type = Poison
}}. It is simple and compact.

This format can be used inline: 1d12 + 2Damage TypesSlashing + 1d6Damage TypesPoison. It is simple and compact.


local getArgs = require("Module:Arguments").getArgs
local p = {}

-- Text to insert in place of modifiers whose value could not be evaluated
local unevaluated_modifiers = {
	melee        = "[[Strength#Strength_modifier_chart|Strength modifier]]",
	ranged       = "[[Dexterity#Dexterity_modifier_chart|Dexterity modifier]]",
	finesse      = "[[Finesse|Strength or Dexterity modifier]]",
	spell        = "[[Spells#Spellcasting_ability|Spellcasting modifier]]",
	strength     = "[[Strength#Strength_modifier_chart|Strength modifier]]",
	dexterity    = "[[Dexterity#Dexterity_modifier_chart|Dexterity modifier]]",
	constitution = "[[Constitution#Constitution_modifier_chart|Constitution modifier]]",
	wisdom       = "[[Wisdom#Wisdom_modifier_chart|Wisdom modifier]]",
	intelligence = "[[Intelligence#Intelligence_modifier_chart|Intelligence modifier]]",
	charisma     = "[[Charisma#Charisma_modifier_chart|Charisma modifier]]",
	proficiency  = "[[Proficiency bonus]]"
}

-- Aliases for modifiers since they are not used consistently in every place
local modifier_aliases = {
	spellcasting = "spell",
	spellcaster  = "spell",
	casting      = "spell",
	caster       = "spell",
	melee        = "strength",
	ranged       = "dexterity",
	str          = "strength",
	dex          = "dexterity",
	con          = "constitution",
	wis          = "wisdom",
	int          = "intelligence",
	cha          = "charisma",
	prof         = "proficiency",
}

-- Special damage values, mostly a legacy of {{Damage info}}
local special_values = {
	["half weapon"] = function(frame) 
		return "1/2 Normal weapon damage"
	end,
	["superiority die"] = function(frame)
		return frame:expandTemplate{
			title = "SmallIcon",
			args = { "Superiority Die d8 Icon.png" },
		} .. "[[Battlemaster#Superiority_dice|Superiority Die]]"
	end,
	["unarmed"] = function(frame)
		return frame:expandTemplate{
			title = "DamageColor",
			args = { "Bludgeoning", "Unarmed" }
		} .. frame:expandTemplate{
			title = "DamageType",
			args = { "Bludgeoning" }
		}
	end,
}

-- Translation and rotation to position each dice image in a way that replicates
-- the in-game damage preview
local dice_image_transform = {
	[1] = "translate(  0%,  0%)",
	[2] = "translate( 40%, -30%) rotate(20deg)",
	[3] = "translate(-35%, -25%) rotate(40deg)",
	[4] = "translate( 40%, -70%) rotate(25deg)",
	[5] = "translate(-40%, -68%) rotate(40deg)",
}

-- These variables will be populated by the parser function
local parsed_data = {
	damage = {
		dice       = {},
		instances  = {},
		min_roll   = 0,
		max_roll   = 0,
		uneval_mods = false,
	},
	healing = {
		dice      = {},
		instances = {},
		min_roll  = 0,
		max_roll  = 0,
		uneval_mods = false,
	},
}

-- Render the scattered damage dice to replicate how it looks in-game
local function damage_dice_format(frame, damage_dice, width)
	local n_dice = #damage_dice
	
	-- Determine width of overall element which is dependent on number of dice
	local elem_width = width
	if n_dice >= 2 then
		elem_width = elem_width * 1.4
	end
	
	-- Determine padding which is dependent on number of dice
	local left_padding = 0
	if n_dice >= 3 then
		left_padding = width * 0.4
	end
	
	local top_padding = 0
	if n_dice >= 2 and n_dice < 4 then
		top_padding = width * 0.3
	elseif n_dice >= 4 then
		top_padding = width * 0.7
	end
	
	local element = string.format([[<span style="
		display:      block;
		position:     relative;
		width:        %s;
		height:       %s;
		margin-left:  %s;
		margin-top:   %s;
		margin-right: 10px;
	">]],
		elem_width .. "px",
		width .. "px",
		left_padding .. "px",
		top_padding .. "px"
	)

	for i, dice in ipairs(damage_dice) do
		if i > #dice_image_transform then
			break
		end
		element = element .. string.format(
			"<span style=\"z-index: %d; position: absolute; transform: %s\">",
			n_dice - i,
			dice_image_transform[i]
		)
		element = element .. string.format(
			"[[File:%s %s.png|link= |x%s]]</span>",
			dice["value"],
			dice["type"],
			width .. "px"
		)
	end
	return element .. "</span>"
end

-- Format the damage values in a list format
local function damage_format(frame, args, data, header)
	local result = "<b>" .. header .. ": "
	-- Damage range preview
	if data.min_roll and data.max_roll then
		if data.min_roll ~= data.max_roll then
			result = result .. data.min_roll .. "~" .. data.max_roll
		elseif data.max_roll > 0 then
			result = result .. data.max_roll
		end
		if data.uneval_mods and data.max_roll > 0 then
			result = result .. " + modifiers"
		end
	end
	result = result .. "</b>" -- End header
	

	-- Flexbox that holds the dice images on the left and damage values on the right
	result = result .. [[<div style="
		display: flex;
		align-items: center;
		width: fit-content;
	">]]

	-- Left div element containing the damage dice images
	local dice_size = tonumber(args["dice size"] or args["dice width"] or "30")
	if dice_size > 0 and #data.dice  > 0 then
		result = result .. damage_dice_format(frame, data.dice, dice_size)
	end

	-- Right div element containing the damage instance list
	result = result .. "<div>"
	for i, damage in ipairs(data.instances) do
		result = result .. "<div>" -- Begin damage line div
		if i > 1 then
			result = result .. " + "
		end
		local value = damage["value"]
		if value == "weapon" then
			result = result .. "Normal weapon damage"
		elseif special_values[value] then
			-- Handle some legacy special values
			result = result .. special_values[value](frame)
		else
			result = result .. frame:expandTemplate{
				title = "DamageColor",
				args = { damage["type"], value }
			} .. frame:expandTemplate{
				title = "DamageType",
				args = { damage["type"] }
			}
		end

		-- Extra info field to add free-form context to the damage instance
		if damage["info"] then
			result = result .. " (" .. damage["info"] .. ")"
		end

		result = result .. "</div>" -- End damage line div
	end
	result = result .. "</div>" -- End right flexbox element
	result = result .. "</div>" -- End flexbox

	return result
end

-- Format the damage values in a compact inline format with less details
local function damage_format_inline(frame, args, data)
	local result = ""
    mw.log(data)
	for _, kind in pairs(data) do
		mw.log(kind)
		for i, damage in ipairs(kind.instances) do
			local value = damage["value"]
			if i > 1 then
				result = result .. " + "
			end
			if value == "weapon" then
				result = result .. "Normal weapon damage"
			elseif special_values[value] then
				-- Handle some legacy special values
				result = result .. special_values[value](frame)
			else
				result = result .. frame:expandTemplate{
					title = "DamageColor",
					args = { damage["type"], value }
				} .. frame:expandTemplate{
					title = "DamageType",
					args = { damage["type"] }
				}
			end
		end
	end

	return result
end

-- Parse and try to evaluate a term containing an ability score modifier
-- The first return value is the value of the modifier it can be evaluated
-- The second return value is the unevaluated modifier in a format ready to be rendered
local function parse_modifier(term, args)
	-- Some basic parsing to match strings of the form "<ability> mod" or "<ability> modifier" or "<ability>"
	local term = string.lower(term)
	local words = {}
	for word in string.gmatch(term, "[^ ]+") do
		table.insert(words, word)
	end
	if #words >= 3 then
		return nil
	end
	if words[2] and words[2] ~= "mod" and words[2] ~= "modifier" and words[2] ~= "bonus" then
		return nil
	end

	local modifier_name = modifier_aliases[words[1]] or words[1]
	
	-- Function to calculate a given ability score from the provided information
	local calc_modifier = function(args, ability)
		return function()
		local ability_score = args[ability] or args[string.sub(ability, 1 ,3)]
			return ability_score and math.floor((ability_score - 10)/2) or nil
		end
	end

	-- Functions to calculate the modifier of the specific type
	local modifiers = {
		strength     = calc_modifier(args, "strength"),
		dexterity    = calc_modifier(args, "dexterity"),
		constitution = calc_modifier(args, "constitution"),
		wisdom       = calc_modifier(args, "wisdom"),
		intelligence = calc_modifier(args, "intelligence"),
		charisma     = calc_modifier(args, "charisma"),

		proficiency = function()
			local level = tonumber(args.level)
			if level then
				return math.floor((level + 7)/4)
			end
		end,

		finesse = function()
			local str = calc_modifier(args, "strength")()
			local dex = calc_modifier(args, "dexterity")()
			if str and dex then
				return math.max(str, dex)
			end
		end,

		spell = function()
			local casting_ability = args["casting ability"] or modifier_aliases[args["casting ability"]]
			if casting_ability then
				return calc_modifier(args, casting_ability)()
			end
		end
	}

	-- Return the modifier evaluated or the standardized modifier name if it could not be
	if modifiers[modifier_name] then
		local modifier = modifiers[modifier_name]()
		if modifier then
			return modifier, nil
		else
			return nil, unevaluated_modifiers[modifier_name]
		end
	end
end

-- Parse a damage term
-- The position of the return value indicates the type of term
-- The first return value is a dice term that should be displayed first
-- The second return value is an integer that should be summed with all other integer terms.
-- It should be displayed after the dice.
-- The third return value is an unevaluated modifier. It should be displayed last.
function parse_term(term, damage_type, args, data)
	-- Strip whitespace
	local term = string.match(term, "^%s*(.-)%s*$")

	-- Try to parse as a dice (e.g. 6d8)
	local d_idx = string.find(term, "d")
	if d_idx then
		local count = tonumber(string.sub(term, 0, string.find(term, "d") - 1))
		local dice	= tonumber(string.sub(term, string.find(term, "d") + 1, -1))

		-- Treat missing first number as 1. E.g. "d8" is the same as "1d8"
		if d_idx == 1 then
			count = 1
		end

		if count and dice then
			-- Track the dice type for displaying the dice icons
			table.insert(data.dice, {
				["value"] = "d" .. dice,
				["count"] = count,
				["type"] = damage_type
			})

			-- Track the low/high values for the overall damage range preview
			data.min_roll = data.min_roll + count
			data.max_roll = data.max_roll + count*dice

			return term, nil, nil
		end
	end

	-- Try to parse as a flat integer
	local i = tonumber(term)
	if i then
		return nil, i, nil
	end

	-- Try to parse as a special value
	local name, value = parse_modifier(term, args)
	if name or value then
		return nil, name, value
	end

	-- Catchall for any other term
	return nil, nil, term
end

-- Parse a damage instance in the simple format of
-- Expr     = Term + Expr | Term
-- Term     = Dice | Integer | Modifier
-- Dice     = Integer d Integer
-- Modifier = Ability mod | Ability modifier | Ability | prof | proficiency bonus
-- Ability  = Strength | str | Dexterity | dex | ...
--
-- Writes result to the global variable parsed_data
local function damage_parse(args, damage_instance)
	local damage      = damage_instance["value"]
	local damage_type = damage_instance["type"]

	-- Determine whether this instance is damage or healing
	local data = parsed_data.damage
	if string.lower(damage_type or "") == "healing" then
		data = parsed_data.healing
	end

	local parsed = ""
	local unevaluated_terms = ""
	local bonus = 0

	for term in string.gmatch(damage, "[^+]+") do
		local dice, value, modifier = parse_term(term, damage_type, args, data)

		if dice then parsed = parsed .. " + " .. dice end
		if value then bonus = bonus + value end
		if modifier then unevaluated_terms = unevaluated_terms .. " + " .. modifier end
	end

	data.min_roll = data.min_roll + bonus
	data.max_roll = data.max_roll + bonus

	-- Re-add the updated flat bonus to the damage string
	if bonus > 0 then
		parsed = parsed .. " + " .. bonus
	elseif bonus < 0 then
		parsed = parsed .. " - " .. -bonus
	end
	
	-- Re-add the unevaluated modifiers to the end of the string
	parsed = parsed .. unevaluated_terms
	if unevaluated_terms ~= "" then
		data.uneval_mods = true
	end

	-- Strip leading " + "
	parsed = string.sub(parsed, 4, -1)

	table.insert(data.instances, {
		["value"] = parsed,
		["type"]  = damage_type,
		["info"]  = damage_instance["info"],
	})
end

-- Parse the special "weapon" damage value which involves a cargo query into the
-- weapons table for the specific damage values
local function weapon_parse(args)
	local weapon_name = args["weapon"]
	if not weapon_name then
		table.insert(parsed_data["damage"].instances, { ["value"] = "weapon" })
		return
	end

	-- Fields stored in the weapons table. These are liable to change.
	local fields = [[
		name,
		damage,
		damage_type,
		extra_damage,
		extra_damage_type,
		extra_damage_2,
		extra_damage_2_type,
		melee_or_ranged,
		finesse
	]]
	local query = mw.ext.cargo.query('weapons', fields, {
		where = "name=\"" .. weapon_name .. "\""
	})
	if #query > 0 then
		weapon = query[1]
		
		-- Apply the weapon modifier to the main weapon damage
		-- TODO: Weapons with special modifiers like Sylvan Scimitar do not
		-- have their modifier stored in the table correctly.
		local modifier = "melee"
		if weapon["melee_or_ranged"] == "ranged" then
			modifier = "ranged"
		elseif weapon["finesse"] == "1" then
			modifier = "finesse"
		end
		weapon["damage"] = weapon["damage"] .. " + " .. modifier
		
		for i, damage_field in ipairs({"damage", "extra_damage", "extra_damage_2"}) do
			if weapon[damage_field] then
				damage_parse(args, {
					["value"]  = weapon[damage_field],
					["type"]   = weapon[damage_field .. "_type"],
				})
			end
		end
	else
		table.insert(parsed_data["damage"].instances, { ["value"] = "weapon" })
	end
end

function p.main(frame)
	local args = getArgs(frame)

	-- Alias for damage 1. Omitting the 1 is acceptable.
	args["damage 1"]      = args["damage 1"]      or args["damage"]
	args["damage 1 type"] = args["damage 1 type"] or args["damage type"]
	args["damage 1 info"] = args["damage 1 info"] or args["damage info"]

	local i = 1
	while args["damage " .. i] do
		local damage = args["damage " .. i]

		if damage == "weapon" then
			weapon_parse(args)
		else
			-- Handle deprecated "damage modifier" field
			if args["damage " .. i .. " modifier"] then
				damage = damage .. " + " .. args["damage " .. i .. " modifier"]
			end
			damage_parse(args, {
				["value"]  = damage,
				["type"]   = args["damage " .. i .. " type"],
				["info"]   = args["damage " .. i .. " info"],
			})
		end
		i = i + 1
	end

	if args["format"] == "inline" then
		return damage_format_inline(frame, args, parsed_data)
	else
		local result = ""
		-- Damage and healing instances are tracked and displayed separately
		if #parsed_data.damage.instances > 0 then
			result = result .. damage_format(frame, args, parsed_data.damage, "Damage")
		end
		if #parsed_data.healing.instances > 0 then
			result = result .. damage_format(frame, args, parsed_data.healing, "Healing")
		end
		return result
	end
end

return p