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 upgrade

Run 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 total

What 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))
}

Formulas → · Discord Bots → · Runnable Examples →