Module:Wand
This documentation is transcluded from Module:Wand/doc (edit).
This is a template for display of wand builds, and/or detailed information about wand stats. This new template replaces the {{Wand}}
and {{Wand Card}}
templates. The template output includes a link to view and edit the wand on the Wand Simulator site, which can also export a template of this format for direct insertion into wiki articles.
Key differences to previous version:
- Spells are now specified using their ID, rather than name.
- Spells are specified in a comma separated list.
This simplifies exporting/importing Wand builds to mods and other tools.
Some useful resources:
- List of spell names and ids: Special:CargoTables/Spells
- List of wand icons: Category:Wand icons
- List of legacy Wand templates (to be converted): Category:Pages with wand templates
{{SpellName}}
- map spell IDs to names
{{SpellTypeClass}}
{{SpellTypeColour}}
- spell type classes and CSS variables for styling
{{SpellCategory}}
- spell groupings based on tags
This demonstrates leaving gaps in the wand's spell listing. The spells can be formatted any way you want - whitespace is ignored.
This demonstrates leaving gaps in the wand's spell listing. The spells can be formatted any way you want - whitespace is ignored.
Also shows use of multiple always cast spells (up to 4 are allowed).
Also shows use of multiple always cast spells (up to 4 are allowed).
Also shows use of multiple always cast spells (up to 4 are allowed).
Parameters
- All parameters are optional.
- Parameters without a default value are omitted from the output entirely.
- Ranges may be specified for values by giving a lower and upper bound separated by a comma, e.g.: manaMax=100,700
Parameter | Default | Description |
---|---|---|
Wand configuration | ||
wandName |
Omitted | The name of the wand. If this field is blank, or missing, the wand name is omitted. [notes 1] |
wandPicId |
0821 | Specifies the id of the wand image. [notes 1] |
shuffle |
No | Whether or not the wand is a shuffle-type wand. Yes/No or 1/0 |
spellsCast |
1 | The number of spells cast per cast. |
castDelay |
0.00 | The cast delay between each spell in the wand (in seconds). |
rechargeTime |
0.00 | The recharge delay between each cast (in seconds). |
manaMax |
500 | The maximum mana capacity of the wand. |
manaRecharge |
250 | The mana recharge rate of the wand (in mana per second). |
capacity |
1 | The capacity of the wand. Spell slots numbered greater than this will not be shown. |
spread |
0.0 | The spread of the wand's casts. |
speed |
1 | The speed multiplier of the wand's casts. [notes 1] |
alwaysCasts |
Omitted | Comma separated list of Spell IDs. Up to 4 spells that the wand always casts. |
spells |
Comma separated list of Spell IDs. This is the ordered sequence of spells the wand casts. Empty spell slots are produced by omitting an ID between two commas, e.g. MANA_REDUCE,,BUCKSHOT will leave a space empty between Add Mana and Triplicate Bolt.
| |
Template configuration | ||
hideLink |
No | Set to Yes to hide the Wand Simulator link.
|
vertical |
Auto | Set to Yes to always display in a vertical (mobile-friendly) layout format (this is also enabled automatically on narrow screens via CSS media query).
|
Deprecated | ||
wandPic |
Wand_0821.png | Specifies the wand image. Must include the filetype extension, e.g.: Wand handgun.png [notes 1]
|
local p = {}
local cargo = mw.ext.cargo
-- Scribunto (Lua main): https://www.mediawiki.org/wiki/Extension:Scribunto/Lua_reference_manual
-- Cargo Lua support: https://www.mediawiki.org/wiki/Extension:Cargo/Other_features#Lua_support
local MAX_ALWAYSCAST = 4
local framesPerSecond = 60
local NNBSP = ' '
-- Trims whitespace off of the left and right sides of a string
local function trim(str)
return str:gsub("^%s*(.-)%s*$", "%1")
end
-- Basically is null, is empty, or is whitespace
local function is_empty(str)
return str == nil or type(str) == 'string' and string.len(trim(str)) == 0
end
local function table_empty(tbl)
return next(tbl) == nil
end
-- If a given string is empty or nil, return nil, otherwise give back the value
local function nil_or_value(str)
if str == nil or type(str) ~= 'string' or str == '' then
return nil
end
return str
end
local function minAndMax(a, b)
if a == nil then
return b, b
end
if b == nil then
return a, a
end
return math.min(a, b), math.max(a, b)
end
-- For sim url, convert seconds to frames (@60fps)
local function formatSeconds(seconds)
return ("%u"):format(tonumber(seconds or 0 * framesPerSecond))
end
local function formatRange(minV, maxV, fSingle, fRange)
if minV == nil and maxV == nil then
return '--'
end
if minV == maxV or maxV == nil then
return string.format(fSingle, minV)
end
if minV == nil then
return string.format(fSingle, maxV)
end
return string.format(fRange, minV, maxV)
end
local function bothIfDifferent(a, b)
if a == nil and b == nil then
return nil
end
if a == b or b == nil then
return a
end
if a == nil then
return b
end
return a .. ',' .. b
end
-- Normally you'd do `table.insert(table, item)` to add to the end, but this
-- does not appear to be working in this Lua implementation so here's a workaround.
local function append_table(tbl, data)
tbl[#tbl + 1] = data
end
-- Split a string by comma, return each item trimmed in a table.
local function split(str, by)
by = by or ','
local result = {}
if str == nil then
return result
end
local pattern = string.format("([^%s]+)", by)
for token in string.gmatch(str, pattern) do
append_table(result, trim(token))
end
return result
end
local function map(source, func, ...)
local result = {}
for key, value in pairs(source) do
result[key] = func(value, unpack(arg))
end
return result
end
local truthy = {"^1", "^y", "^Y", "^true", "^True"}
local falsey = {"^0", "^n", "^N", "^false", "^False"}
local function seek_truth(predicate, default)
if not is_empty(predicate) then
if type(predicate) == 'boolean' then
return predicate
end
if type(predicate) == 'number' then
return not predicate == 0
end
if type(predicate) == 'string' then
for _, truth in ipairs(truthy) do
if predicate:find(truth) then
return true
end
end
for _, falsehood in ipairs(falsey) do
if predicate:find(falsehood) then
return false
end
end
end
end
return default
end
---------------------------------------------- G(et) ARGuments from frames -----
--- If sep is not nil it specifies the character that delimits multiple values
---
local function garg_base(frame, name, default, sep, parseFunc, ...)
local args = frame.args
local parentArgs = frame:getParent().args
local valFromArgs = nil_or_value(parentArgs[name]) or nil_or_value(args[name])
if valFromArgs == nil then
return { default, default }
end
if parseFunc == nil or not type(parseFunc) == 'function' then
return { valFromArgs, valFromArgs }
end
if not is_empty(sep) then
return map(split(valFromArgs, sep), parseFunc, unpack(arg))
end
local parsedVal = parseFunc(valFromArgs, unpack(arg))
return { parsedVal, parsedVal }
end
-- local shuffle = garg_boo("shuffle", pArgs, args, false )
-- GARG for strings
local function garg_str(frame, name, default, sep)
return unpack(garg_base(frame, name, default, sep))
end
-- GARG for numbers
local function garg_num(frame, name, default, sep)
return unpack(garg_base(frame, name, default, sep, tonumber, 10))
end
-- GARG for booleans
local function garg_boo(frame, name, default, sep)
return unpack(garg_base(frame, name, default, sep, seek_truth, default))
end
------------------------------------------------------- Cargo spell lookup -----
-- Remove invalid characters from potential spell ID
-- Valid ones are [A-Za-z1-9_], then uppercased
local function sanitizeSpellId(str)
if is_empty(str) then
return nil
end
return str:gsub("[^%w_]+", ""):upper()
end
-- Split a string of SPELL_IDS,BY,COMMA preserving holes "A,,B" -> ["A","","B"]
local function splitSpells(str)
local result = {}
if is_empty(str) then
return result
end
for token in string.gmatch(str, "([^,]*)[,]?") do
append_table(result, sanitizeSpellId(token) or '')
end
return result
end
-- Check if a split spell list actually contains any spells (or just holes)
local function isEmptySpellList(spellList)
for _, item in ipairs(spellList) do
if not is_empty(item) then
return false
end
end
return true
end
-- Build a simple '(id=X or id=Y or...)' query clause
local function makeQuery(spellList, alwaysCastList)
local result = {}
for _, item in ipairs(spellList) do
if not is_empty(item) then
append_table(result, string.format("id=\"%s\"", item))
end
end
for _, item in ipairs(alwaysCastList) do
if not is_empty(item) then
append_table(result, string.format("id=\"%s\"", item))
end
end
return "(" .. table.concat(result, " " .. "OR" .. " ") .. ")"
end
-- Make a new table indexed by the specified key of each item
-- reindex({ 1: {id: 'q', }, 2: {id: 'f', }, 3: {id: 'm', }}, 'id')
-- = {'q': {id: 'q', }, 'f': {id: 'f', }, 'm': {id: 'm', }}
local function reindex(from, key)
local to = {}
for _, item in ipairs(from) do
to[item[key]] = item
end
return to
end
-- Look up all the spells on the wand in one query
-- Return them as a lookup table keyed by spell ID
local function doSpellQuery(spellIdList, acIdList)
if isEmptySpellList(spellIdList) then
return {}
end
-- Perform a single cargo query for all spell details
local query = {}
query.where = makeQuery(spellIdList, acIdList)
query.limit = 200
if tooltips then
query.fields = [[
_pageName,image,name,id,description,type,tags,manaDrain,uses,
damageProjectile,damageMelee,damageElectric,damageFire,
damageExplosion,damageIce,damageSlice,damageDrill,
damageHealing,damageHoly,
speed,castDelay,rechargeDelay,bounces,effect
]]
else
query.fields = "_pageName,image,name,id,description,type,tags"
end
return reindex(cargo.query("Spells", query.fields, query), 'id')
end
--------------------------------------------------- Spell helper functions -----
local SpellTypeClassMap = {
["projectile"] = "spellProjectile",
["static projectile"] = "spellStatic",
["projectile modifier"] = "spellModifier",
["multicast"] = "spellMulticast",
["material"] = "spellMaterial",
["other"] = "spellOther",
["utility"] = "spellUtility",
["passive"] = "spellPassive",
}
local function getSpellTypeClass(type)
return SpellTypeClassMap[string.lower(type)] or "spellUnknown"
end
-------------------------------------------------------------- HTML output -----
local function addSpell(parent, spell, tooltips)
local card = parent:tag('div'):attr('class', 'wand2-spell')
if type(spell) == "table" then
card:tag('div')
:attr('class',
string.format('spellBorder spellBackground %s',
getSpellTypeClass(spell.type)))
:wikitext(
string.format('[[File:%s|link=%s|alt=%s|128px]]',
spell.image, spell._pageName, spell.name))
if tooltips then
card:addClass('wand2-spelltip-target')
local tip = card:tag('div'):attr('class', 'wand2-spelltip')
tip:tag('div'):attr('class', 'wand2-spelltip-name')
:wikitext(string.format('%s', spell.name))
tip:tag('div'):attr('class', 'wand2-spelltip-desc')
:wikitext(string.format('%s', spell.description))
tip:tag('div'):attr('class', 'wand2-spelltip-key wand2-spelltip-sub')
:wikitext(string.format('Type'))
tip:tag('div'):attr('class', 'wand2-spelltip-value wand2-spelltip-sub')
:wikitext(string.format('%s', spell.type or ''))
tip:tag('div'):attr('class', 'wand2-spelltip-key')
:wikitext(string.format('Mana drain'))
tip:tag('div'):attr('class', 'wand2-spelltip-value')
:wikitext(string.format('%s', spell.manaDrain or ''))
tip:tag('div'):attr('class', 'wand2-spelltip-img')
:wikitext(string.format(
'[[File:%s|link=%s|alt=%s|128px]]',
spell.image, spell._pageName, spell.name))
end
end
end
---------------------------------------------- Main entry point for module -----
function p.Wand(frame)
local vertical = garg_boo(frame, "vertical", false )
local wandCard = garg_boo(frame, "wandCard", false )
-- Card wraps at 10 vb2x10 +6
-- Mini wraps at 26 10x 26
-- Vertical wraps at 6 (6x4 +2)
local defaultWrap = vertical and 6 or wandCard and 10 or 26
-- Display options
local wrapCount = garg_num(frame, "wrapCount", defaultWrap )
local hideLink = garg_boo(frame, "hideLink", false )
local hideName = garg_boo(frame, "hideName", false )
local hideSpells = garg_boo(frame, "hideSpells", false )
local tooltips = garg_boo(frame, "tooltips", true )
-- Wand stats
-- Always show
local alwaysCasts = garg_str(frame, "alwaysCasts" )
local spells = garg_str(frame, "spells" )
local shuffle = garg_boo(frame, "shuffle", false )
local pCastMin, pCastMax = minAndMax(garg_num(frame, "spellsCast", 1, ','))
-- Shown on expanded + cardviews
local delayMin, delayMax = minAndMax(garg_num(frame, "castDelay", 0.17, ','))
local rTimeMin, rTimeMax = minAndMax(garg_num(frame, "rechargeTime", 0.48, ','))
local manaMin, manaMax = minAndMax(garg_num(frame, "manaMax", 900, ','))
local regenMin, regenMax = minAndMax(garg_num(frame, "manaCharge", 700, ','))
local capMin, capMax = minAndMax(garg_num(frame, "capacity", 26, ','))
local spreadMin, spreadMax = minAndMax(garg_num(frame, "spread", -2.00, ','))
-- Shown on expanded + cardviews (Not shown in-game)
local speedMin, speedMax = minAndMax(garg_num(frame, "speed", 1.00, ','))
-- Customisation
local wandName = garg_str(frame, "wandName", "" )
local wandPic = garg_str(frame, "wandPic", "Wand_0821.png" )
local wandPicId = garg_str(frame, "wandPicId", "0821" )
local cap = capMax or capMin or 26
-- Constants
local picSize = "165px"
-- Create tables from the CSV strings
local spellIdList = splitSpells(spells)
local acIdList = splitSpells(alwaysCasts)
local results = doSpellQuery(spellIdList, acIdList)
-- Build output HTML
local root = mw.html.create('div')
:attr('class', string.format('%s %s',
wandCard and 'wand2-card' or 'wand2-mini',
vertical and 'wand2-vertical' or ''))
:cssText(string.format('--wand2-cap: %d; --wand2-wrap: %d;', cap, wrapCount))
-- Wand name
if not hideName and not is_empty(wandName) then
root:tag('div'):attr('class', 'wand2-name')
:wikitext(wandName)
end
-- Wand image
if not hidePic then
root:tag('div'):attr('class', 'wand2-sprite')
:cssText(string.format('max-width: %s;', picSize))
:wikitext(string.format('[[File:%s|link=]]', wandPic))
end
-- Basic wand attributes
root:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'shuffle')
:attr('data-value', shuffle and '1' or '0')
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon gun shuffle.png]] Shuffle')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(string.format('%s', shuffle and 'Yes' or 'No'))
root:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'spells-per-cast')
:attr('data-value-min', pCastMin)
:attr('data-value-max', pCastMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon gun actions per round.png]] Spells/Cast')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(pCastMin, pCastMax, '%s', '( %s - %s )'))
-- Extended wand attributes (initially hidden for mini view)
local details = root:tag('div'):attr('class', 'wand2-details')
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'cast-delay')
:attr('data-value-min', delayMin)
:attr('data-value-max', delayMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon fire rate wait.png]] Cast delay')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(delayMin, delayMax, '%1.2f s', '( %1.2f - %1.2f ) s'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-recharge')
:attr('data-value-min', rTimeMin)
:attr('data-value-max', rTimeMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon gun reload time.png]] Rechrg. Time')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(rTimeMin, rTimeMax, '%1.2f s', '( %1.2f - %1.2f ) s'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-max')
:attr('data-value-min', manaMin)
:attr('data-value-max', manaMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon mana max.png]] Mana max')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(manaMin, manaMax, '%d', '( %d - %d )'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-charge')
:attr('data-value-min', regenMin)
:attr('data-value-max', regenMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon mana charge speed.png]] Mana chg. Spd')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(regenMin, regenMax, '%d', '( %d - %d )'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-cap')
:attr('data-value-min', capMin)
:attr('data-value-max', capMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon gun capacity.png]] Capacity')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(capMin, capMax, '%d', '( %d - %d )'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-spread')
:attr('data-value-min', spreadMin)
:attr('data-value-max', spreadMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon spread degrees.png]] Spread')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(spreadMin, spreadMax, '%1.1f DEG', '( %1.1f - %1.1f ) DEG'))
details:tag('div'):attr('class', 'wand2-stat')
:attr('data-name', 'wand2-stat-speed')
:attr('data-value-min', speedMin)
:attr('data-value-max', speedMax)
:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon speed multiplier.png]] Speed')
:done()
:tag('div'):attr('class', 'wand2-value')
:wikitext(formatRange(speedMin, speedMax, '× %1.2f', '× ( %1.2f - %1.2f )'))
-- Simulator link
local simURI = mw.uri.new({
protocol = "https",
host = "tinker-with-wands-online.vercel.app",
query = {
a = bothIfDifferent(pCastMin, pCastMax),
d = bothIfDifferent(formatSeconds(delayMin),formatSeconds(delayMax)),
r = bothIfDifferent(formatSeconds(rTimeMin),formatSeconds(rTimeMax)),
m = bothIfDifferent(manaMin, manaMax),
c = bothIfDifferent(regenMin, regenMax),
l = bothIfDifferent(capMin, capMax),
q = bothIfDifferent(spreadMin, spreadMax),
v = bothIfDifferent(speedMin, speedMax),
x = shuffle and 1 or 0,
n = wandName,
p = wandPic,
w = table.concat(acIdList, ","),
s = table.concat(spellIdList, ","),
},
})
root:tag('div')
:attr('class', 'wand2-simlink ' .. (hideLink and 'hidden' or ''))
:tag('div'):attr('class', 'wand2-simlink-link')
:wikitext(string.format('[%s Tinker]', tostring(simURI)))
:done()
:tag('div'):attr('class', 'wand2-simlink-desc')
:tag('div'):wikitext('Visit the Wand Simulator site')
:tag('div'):wikitext('to view & edit this wand')
-- Always casts
if not hideSpells and not table_empty(acIdList) then
local alwaysCount = 0
local alwaysContainer = nil
for _, acId in ipairs(acIdList) do
if alwaysCount < MAX_ALWAYSCAST and not is_empty(acId) then
if alwaysCount < 1 then
local always = root:tag('div'):attr('class', 'wand2-stat wand2-always')
always:tag('div'):attr('class', 'wand2-label')
:wikitext('[[File:Inventory Icon gun permanent actions.png]]Always casts')
alwaysContainer = always:tag('div'):attr('class', 'wand2-value')
end
addSpell(alwaysContainer, results[acId] or nil, tooltips)
end
alwaysCount = alwaysCount + 1
end
end
-- Main spells
if not hideSpells then
local spellsContainer = root:tag('div'):attr('class', 'wand2-spells')
local count = 0
for _, spellId in ipairs(spellIdList) do
if count < cap then
addSpell(spellsContainer, results[spellId] or nil, tooltips)
end
count = count + 1
end
while count < cap do
addSpell(spellsContainer, nil, tooltips)
count = count + 1
end
end
root:allDone()
return tostring(root)
end
return p