Builders
A builder is a calculator that describes itself. It carries a title, a summary, the list of inputs
it takes — with their labels, units, ranges and options — and a compute function that turns
those inputs into a result. Because the description is data, you can render a form, register a slash
command, or generate a test from the calculator itself, and add a new calculator without touching any
of them.
The Fifteen Builders
Every builder is in CALCULATOR_BUILDERS, and findCalculatorBuilder looks one
up by id.
import { CALCULATOR_BUILDERS } from 'thetowersdk/builders'
for (const builder of CALCULATOR_BUILDERS) {
console.log(builder.id.padEnd(24), builder.title)
}
// assist.stones Assist module stones
// bot.upgrade Bot upgrade
// economy.coins-per-kill Coins per kill
// damage.reduction Damage reduction
// dissonance.boost Dissonance boost
// drops.enemy Enemy drops
// enemy.wave Enemy stats by wave
// guardian.upgrade Guardian upgrade
// uw.inner-land-mines Inner Land Mines
// lab.research Lab research
// module.cost Module upgrade cost
// thorns.damage Thorn damage
// uw.stones Ultimate weapon stones
// uptime.ratio Ability uptime
// workshop.upgrade Workshop upgradeRun One
Start from defaults, change what you care about, pass it through normalize, then compute. Normalising first clamps values into the ranges
the fields declare, so a number typed into a form is safe to hand straight to the calculator.
import { findCalculatorBuilder } from 'thetowersdk/builders'
const uptime = findCalculatorBuilder('uptime.ratio')
const input = uptime.normalize({ durationSeconds: 23, cooldownSeconds: 220 })
const result = uptime.compute(input)
console.log(result)
// { ratio: 0.1045…, percent: 10.45…, permanent: false, downtimeSeconds: 197, notes: [] }Read The Inputs
fields is the shape of the calculator. Each field has a key, a label and a kind; number fields carry min, max and sometimes a unit or a help line; select fields
carry their options.
const uptime = findCalculatorBuilder('uptime.ratio')
console.log(uptime.summary)
// 'What share of the time an ultimate weapon is active, from its duration and cooldown.'
console.log(uptime.fields)
// [
// { key: 'durationSeconds', label: 'Duration', kind: 'number', unit: 'seconds', min: 0 },
// { key: 'cooldownSeconds', label: 'Cooldown', kind: 'number', unit: 'seconds', min: 0,
// help: 'Measured from activation, so duration ≥ cooldown means permanent uptime.' }
// ]Generate A Form
Because the fields are data, one loop renders any calculator. This is the whole of a working form — add a builder to the package and it appears here with no further work.
The component logic — three lines:
import { findCalculatorBuilder } from 'thetowersdk/builders'
const builder = findCalculatorBuilder('lab.research')
let input = $state({ ...builder.defaults })
let result = $derived(builder.compute(builder.normalize(input)))And the markup, which never mentions a specific field:
{#each builder.fields as field}
<label>
{field.label}{field.unit ? ` (${field.unit})` : ''}
{#if field.kind === 'select'}
<select bind:value={input[field.key]}>
{#each field.options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{:else}
<input type="number" min={field.min} max={field.max} bind:value={input[field.key]} />
{/if}
{#if field.help}<small>{field.help}</small>{/if}
</label>
{/each}
<output>{result.totalCoinCost}</output>Results Carry Their Own Detail
compute returns the whole working, not just a headline number — a per-level breakdown where
one applies, totals, and any notes the calculator wants to show alongside the figure.
const labs = findCalculatorBuilder('lab.research')
const result = labs.compute(
labs.normalize({
labName: 'Attack Speed',
currentLevel: 10,
targetLevel: 20,
coinDiscountPercent: 15,
labSpeedPercent: 40
})
)
console.log(result.totalCoinCost) // coins for the whole run
console.log(result.totalHours) // research time, discounts applied
console.log(result.maxLevel) // where this lab tops out
console.log(result.levels[0]) // { level: 11, coinCost: …, hours: … }
console.log(result.notes) // anything worth showing next to the totalWhat Happens To Input You Did Not Check
normalize is total: it takes anything and returns a complete, valid record. Nothing throws,
so a form, a chat command and an imported spreadsheet row can all be passed straight in without validating
first.
const module = findCalculatorBuilder('module.cost')
module.normalize({ currentLevel: -5, targetLevel: 99999, rarity: 'Nope' })
// { rarity: 'Ancestral 5', currentLevel: 1, targetLevel: 300,
// shardDiscountPercent: 0, coinDiscountPercent: 0 }
// Even a value that throws when read: nothing here calls String() or Number()
// on something it did not put there.
module.normalize({ currentLevel: { toString() { throw new Error('hostile') } } })
// { rarity: 'Ancestral 5', currentLevel: 1, targetLevel: 20, … }Out of range becomes the nearest valid value, and an unknown option becomes the default. That is the safe behaviour, and it is also the one that can mislead: the answer is now to a slightly different question than the one asked.
So compare the input you sent with the input that was used, and show notes — a calculator says when it changed something that matters.
const asked = { rarity: 'Common', currentLevel: 1, targetLevel: 9999 }
const used = module.normalize(asked)
const result = module.compute(asked)
result.levels.at(-1).level // 20 — Common stops there
result.notes
// ['Common caps at level 20; the target was clamped.']
// The general check, for any calculator:
const changed = Object.keys(used).filter((key) => used[key] !== asked[key])Not every clamp writes a note. A rarity that does not exist quietly becomes the
default, because there is no useful sentence to write about a value that was never a choice — so
the comparison above is the reliable check, and notes is the readable one.
Turn Every Builder Into A Command
The same description registers a Discord command. Loop the builders, map each field to a command option, and the bot answers with the numbers your site shows.
import { CALCULATOR_BUILDERS, findCalculatorBuilder } from 'thetowersdk/builders'
const commands = CALCULATOR_BUILDERS.map((builder) => ({
name: builder.id.replace('.', '-'),
description: builder.summary,
options: builder.fields.map((field) => ({
name: field.key.toLowerCase(),
description: field.label,
type: field.kind === 'select' ? 'STRING' : 'NUMBER',
choices: field.kind === 'select' ? field.options : undefined,
required: builder.defaults[field.key] === undefined
}))
}))
function run(commandName, values) {
const builder = findCalculatorBuilder(commandName.replace('-', '.'))
return builder.compute(builder.normalize({ ...builder.defaults, ...values }))
}Test Them All At Once
Because every builder has the same shape, one test covers all fifteen — and covers the next one automatically.
import { CALCULATOR_BUILDERS } from 'thetowersdk/builders'
for (const builder of CALCULATOR_BUILDERS) {
const result = builder.compute(builder.normalize(builder.defaults))
console.log(builder.id, Object.keys(result))
}