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.

End users may invoke this module through the wrapper templates:

  • {{Damage display}} - For the full feature set of the module
  • {{Damage inline}} - For an inline-only version with simplified syntax and features

Parameters

Parameter Meaning
format Format of the damage display, either list (default), nosummary to hide the header, 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

ExampleMarkupRenders 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
1d6 + 5PiercingPiercing
+ 1d6FireFire
+ 2d8RadiantRadiant
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
1d10 + 6ForceForce
+ 1d10 + 6ForceForce
+ 1d10 + 6ForceForce
Unspecified weapon
{{#invoke: Damage display | main
| damage 1      = weapon
| damage 2      = 1d6
| damage 2 type = Necrotic
}}
Damage: 1~6
Normal weapon damage
+ 1d6NecroticNecrotic
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
1d6 + 4PiercingPiercing
+ 1d6NecroticNecrotic
Healing
{{#invoke: Damage display | main
| damage 1          = 1d6 + wis
| damage 1 type     = Healing

| wis = 19
}}
Healing: 5~10
1d6 + 4HealingHealing
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
1d12 + 2SlashingSlashing
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
2d4AcidAcid (Delayed)
+ 2d8RadiantRadiant (If the target is a Fiend or Undead)
+ 2d6FireFire (per ray)
+ 1d6PiercingPiercing (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)/2LightningLightning
+ 5 + 2 x (Cleric level)NecroticNecrotic
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
1d12ColdCold
+ 1d10LightningLightning
+ 2d8PsychicPsychic
+ 1d4ForceForce
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 + 2SlashingSlashing
+ 1d6PoisonPoison
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 + 2SlashingSlashing + 1d6PoisonPoison. It is simple and compact.
No summary
{{#invoke: Damage display | main
| format = nosummary
| damage 1      = 1d12 + 2
| damage 1 type = Slashing
| damage 2      = 1d6
| damage 2 type = Poison
}}
1d12 + 2SlashingSlashing
+ 1d6PoisonPoison

local getArgs = require("Module:Arguments").getArgs
local formatting = require("Module:Damage display/format")
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",
}

-- 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,
	},
}

-- 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, args)
	local args = args or getArgs(frame)
	
	-- Use parent frames arguments if arguments are not supplied when calling {{#invoke}}. 
	-- This makes it unnecessary to explicitly pass parameters into the function
	-- when calling it from a page generator template for instance.
	if args["damage"] == nil and args["damage 1"] == nil then
		args = getArgs(frame:getParent())
	end

	-- 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 formatting.damage_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 .. formatting.damage(frame, args, parsed_data.damage, "Damage")
		end
		if #parsed_data.healing.instances > 0 then
			result = result .. formatting.damage(frame, args, parsed_data.healing, "Healing")
		end
		return result
	end
end

-- Display table of examples. This can be invoked from the debug console with
--     =p.display_examples()
-- to preview changes before saving.
function p.display_examples(frame)
	return require("Module:Damage display/examples").display_examples(frame)
end

return p