Discord Bots

Every builder is already a command. thetowersdk/bot turns the fifteen builders into fifteen commands with their options declared, runs them, and returns a reply shaped like an embed — title, description, fields. Nothing here talks to Discord, so the same commands serve a Discord bot, a Slack app, or an HTTP endpoint.

The Commands

import { calculatorCommands } from 'thetowersdk/bot'

const commands = calculatorCommands()
console.log(commands.length)   // 15

console.log(commands.map((command) => command.name))
// ['assist-stones', 'bot-upgrade', 'economy-coins-per-kill', 'damage-reduction',
//  'dissonance-boost', 'drops-enemy', 'enemy-wave', 'guardian-upgrade',
//  'uw-inner-land-mines', 'lab-research', 'module-cost', 'thorns-damage',
//  'uw-stones', 'uptime-ratio', 'workshop-upgrade']

Each command carries a name, a description, its options, and a run function. The options are the builder's own fields, already converted to the shape a command registration expects.

const uptime = commands.find((command) => command.name === 'uptime-ratio')

console.log(uptime.description)
// 'What share of the time an ultimate weapon is active, from its duration and cooldown.'

console.log(uptime.options)
// [ { name: 'durationSeconds', description: 'Duration', type: 'number', min: 0 },
//   { name: 'cooldownSeconds', description: 'Measured from activation, so duration ≥
//     cooldown means permanent uptime.', type: 'number', min: 0 } ]

Run One

run takes a context object with an args record, and returns a reply ready to send.

const reply = await uptime.run({
  args: { durationSeconds: 23, cooldownSeconds: 220 }
})

console.log(reply)
// {
//   title: 'Ability uptime',
//   description: 'What share of the time an ultimate weapon is active, …',
//   fields: [
//     { name: 'Ratio', value: '0.1045', inline: true },
//     { name: 'Percent', value: '10.4545', inline: true },
//     { name: 'Permanent', value: 'no', inline: true },
//     { name: 'Downtime Seconds', value: '197', inline: true }
//   ],
//   notes: []
// }

Build The Bot

createTowerBot takes the commands and gives you one place to look them up and run them. It adds a help command listing everything it knows, and caches replies — commands are pure functions of their arguments, so the same question returns the same answer.

import { createTowerBot, calculatorCommands } from 'thetowersdk/bot'

const bot = createTowerBot({ commands: calculatorCommands() })

console.log(bot.commands.length)   // 16 — the fifteen builders plus help

const reply = await bot.run('uptime-ratio', {
  args: { durationSeconds: 23, cooldownSeconds: 220 }
})

const help = await bot.run('help', { args: {} })
// { title: 'Commands', fields: [ { name: '/assist-stones', value: '…' }, … ] }

bot.commands is a property, not a method. bot.get(name) returns one command or undefined, which is the check to make before running whatever a user typed.

Arguments Arrive As Strings

Chat platforms hand over text. The bot coerces each argument to the type its option declares, so you can pass what the platform gave you without parsing it first.

// Both of these produce the same reply.
await bot.run('uptime-ratio', { args: { durationSeconds: 23, cooldownSeconds: 220 } })
await bot.run('uptime-ratio', { args: { durationSeconds: '23', cooldownSeconds: '220' } })

Register With Discord

The command list is data, so registering is a map from the SDK's option shape to the library's. This is the whole integration for discord.js.

import { SlashCommandBuilder } from 'discord.js'
import { createTowerBot, calculatorCommands } from 'thetowersdk/bot'

const bot = createTowerBot({ commands: calculatorCommands() })

const slashCommands = bot.commands.map((command) => {
  const builder = new SlashCommandBuilder()
    .setName(command.name)
    .setDescription(command.description.slice(0, 100))

  for (const option of command.options ?? []) {
    const describe = (input) =>
      input.setName(option.name.toLowerCase()).setDescription(option.description.slice(0, 100))

    if (option.type === 'number') builder.addNumberOption(describe)
    else builder.addStringOption(describe)
  }

  return builder.toJSON()
})

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return

  const args = Object.fromEntries(
    interaction.options.data.map((option) => [option.name, option.value])
  )

  const reply = await bot.run(interaction.commandName, {
    args,
    userId: interaction.user.id
  })

  await interaction.reply({ embeds: [reply] })
})

The reply's title, description and fields already match Discord's embed shape, so it can go straight into embeds.

Add Your Own Command

A command is a plain object, so anything you can compute can join the same list — and appears in help alongside the rest.

import { createTowerBot, calculatorCommands, markUncacheable } from 'thetowersdk/bot'
import { searchPatchNotes } from 'thetowersdk/knowledge'

const whenChanged = {
  name: 'when-changed',
  description: 'The most recent patch notes mentioning a mechanic.',
  options: [{ name: 'mechanic', description: 'What to search for', type: 'string' }],

  run({ args }) {
    const notes = searchPatchNotes(String(args.mechanic ?? ''), 5)
    return {
      title: `Patch notes: ${args.mechanic}`,
      fields: notes.map((note) => ({
        name: `${note.postedAt.slice(0, 10)} ${note.version ?? ''}`.trim(),
        value: note.title
      }))
    }
  }
}

const bot = createTowerBot({ commands: [...calculatorCommands(), whenChanged] })

Replies are cached by command and arguments. If a command reads a save, a spreadsheet or a database, call markUncacheable('your-command') so each invocation runs fresh.

Builders → · Patch Notes → · Charts →