diff --git a/.github/workflows/cd-assets.yml b/.github/workflows/cd-assets.yml index 4d840d1fd..3b7757240 100644 --- a/.github/workflows/cd-assets.yml +++ b/.github/workflows/cd-assets.yml @@ -61,4 +61,4 @@ jobs: $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" - yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/production' && '--production' || '' }} + yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} diff --git a/docs/changelogs/1.17.0.md b/docs/changelogs/1.17.0.md new file mode 100644 index 000000000..41aba51cf --- /dev/null +++ b/docs/changelogs/1.17.0.md @@ -0,0 +1,13 @@ +# Blitzkrieg 1.17.0 + +A preview command and a touchup. + +## Features + +- Added `/research` in preview only accessible in the official Discord server. + - More details will be posted when the command is made public. + +## Changes + +- `/owned-tanks` now returns a simple text message listing all the tanks instead of an image. + - This is meant to improve the readability and ease of copying. diff --git a/package.json b/package.json index 3546cc0e0..9d13fd0fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitzkrieg", - "version": "1.16.4", + "version": "1.17.0", "description": "πŸŽ‰ Tools for everything World of Tanks Blitz", "main": "src/server.ts", "type": "module", @@ -35,7 +35,7 @@ "@octokit/plugin-throttling": "^8.1.3", "@octokit/rest": "^20.0.1", "@prisma/client": "^5.4.1", - "@radix-ui/colors": "^3.0.0-rc.4", + "@radix-ui/colors": "^3.0.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/themes": "^2.0.1", "@react-three/drei": "^9.93.0", diff --git a/scripts/buildAssets/currencies.ts b/scripts/buildAssets/currencies.ts new file mode 100644 index 000000000..8764410e8 --- /dev/null +++ b/scripts/buildAssets/currencies.ts @@ -0,0 +1,39 @@ +import sharp from 'sharp'; +import { readDVPLFile } from '../../src/core/blitz/readDVPLFile'; +import { commitAssets } from '../../src/core/blitzkrieg/commitAssets'; +import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles'; +import { DATA } from './constants'; + +const ICONS = [ + ['currency_silver_m.packed.webp', 'silver'], + ['currency_premium_xl.packed.webp', 'premium'], + ['currency_gold_m.packed.webp', 'gold'], + ['currency_free-xp_xl.packed.webp', 'free-xp'], + ['currency_elite-xp_xl.packed.webp', 'elite-xp'], + ['currency_crew-xp_xl.packed.webp', 'crew-xp'], + ['currency_battle-xp_xl.packed.webp', 'xp'], +]; + +export async function currencies(production: boolean) { + console.log('Building currency icons...'); + + const changes = await Promise.all( + ICONS.map(async ([file, name]) => { + const content = ( + await sharp( + await readDVPLFile(`${DATA}/Gfx/Lobby/currency/${file}.dvpl`), + ) + .trim() + .toBuffer() + ).toString('base64'); + + return { + content, + encoding: 'base64', + path: `icons/currencies/${name}.webp`, + } satisfies FileChange; + }), + ); + + await commitAssets('currency icons', changes, production); +} diff --git a/scripts/buildAssets/definitions.ts b/scripts/buildAssets/definitions.ts index 52452b722..d6dd7b58f 100644 --- a/scripts/buildAssets/definitions.ts +++ b/scripts/buildAssets/definitions.ts @@ -1,4 +1,4 @@ -import { readdir } from 'fs/promises'; +import { readdir, writeFile } from 'fs/promises'; import { parse as parsePath } from 'path'; import { Vector3Tuple } from 'three'; import { parse as parseYaml } from 'yaml'; @@ -79,6 +79,16 @@ type VehicleDefinitionArmor = Record< string, number | { vehicleDamageFactor: 0; '#text': number } >; +interface UnlocksInner { + cost: number; + '#text': number; +} +type Unlocks = { + [key in 'vehicle' | 'engine' | 'chassis' | 'turret' | 'gun']: + | UnlocksInner + | UnlocksInner[]; +}; +type UnlocksListing = Unlocks | undefined; interface VehicleDefinitions { invisibility: { moving: number; @@ -102,6 +112,7 @@ interface VehicleDefinitions { }; chassis: { [key: string]: { + unlocks: UnlocksListing; weight: number; terrainResistance: string; rotationSpeed: number; @@ -130,10 +141,13 @@ interface VehicleDefinitions { }; }; engines: { - [key: string]: unknown; + [key: string]: { + unlocks: UnlocksListing; + }; }; turrets0: { [key: string]: { + unlocks: UnlocksListing; rotationSpeed: number; weight: number; circularVisionRadius: number; @@ -149,6 +163,7 @@ interface VehicleDefinitions { }; guns: { [key: string]: { + unlocks: UnlocksListing; armor: VehicleDefinitionArmor & { gun?: | number @@ -353,6 +368,8 @@ export async function definitions(production: boolean) { `${DATA}/${POI.provisionsCommon}.dvpl`, ); + const tankXps = new Map(); + await Promise.all( nations.map(async (nation) => { const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>( @@ -385,6 +402,10 @@ export async function definitions(production: boolean) { ); for (const tankKey in tankList.root) { + const gunXps = new Map(); + const turretXps = new Map(); + const engineXps = new Map(); + const trackXps = new Map(); const tank = tankList.root[tankKey]; const tankPrice: TankDefinitionPrice = typeof tank.price === 'number' @@ -427,6 +448,7 @@ export async function definitions(production: boolean) { }); const crew: Crew[] = []; const fixedCamouflage = tankTags.includes('eventCamouflage_user'); + const totalUnlocks: UnlocksListing[] = []; Object.entries(tankDefinition.root.crew).forEach(([key, value]) => { let entry: Crew; @@ -532,6 +554,7 @@ export async function definitions(production: boolean) { .split(' ') .map(Number) as Vector3Tuple; + totalUnlocks.push(track.unlocks); tankDefinitions[tankId].tracks.push({ id: trackId, weight: track.weight, @@ -558,16 +581,18 @@ export async function definitions(production: boolean) { }); Object.keys(tankDefinition.root.engines).forEach((engineKey) => { - const engine = enginesList.root.shared[engineKey]; + const engine = tankDefinition.root.engines[engineKey]; + const engineListEntry = enginesList.root.shared[engineKey]; const engineId = toUniqueId(nation, enginesList.root.ids[engineKey]); + totalUnlocks.push(engine.unlocks); tankDefinitions[tankId].engines.push({ id: engineId, - name: strings[engine.userString], - fireChance: engine.fireStartingChance, - tier: engine.level as Tier, - weight: engine.weight, - power: engine.power, + name: strings[engineListEntry.userString], + fireChance: engineListEntry.fireStartingChance, + tier: engineListEntry.level as Tier, + weight: engineListEntry.weight, + power: engineListEntry.power, }); }); @@ -594,6 +619,7 @@ export async function definitions(production: boolean) { .map(Number) as Vector3Tuple; const turretArmor: ModelArmor = { thickness: {} }; + totalUnlocks.push(turret.unlocks); Object.keys(turret.armor) .filter((name) => name.startsWith('armor_')) .forEach((name) => { @@ -646,11 +672,11 @@ export async function definitions(production: boolean) { }; Object.keys(turret.guns).forEach((gunKey, gunIndex) => { - const turretGunEntry = turret.guns[gunKey]; + const gun = turret.guns[gunKey]; const gunId = toUniqueId(nation, gunList.root.ids[gunKey]); const gunListEntry = gunList.root.shared[gunKey]; const pitchLimitsRaw = - turretGunEntry.pitchLimits ?? gunListEntry.pitchLimits; + gun.pitchLimits ?? gunListEntry.pitchLimits; const gunPitch = ( typeof pitchLimitsRaw === 'string' ? pitchLimitsRaw @@ -659,43 +685,41 @@ export async function definitions(production: boolean) { .split(' ') .map(Number) as [number, number]; const gunModel = Number( - parsePath(turretGunEntry.models.undamaged).name.split('_')[1], + parsePath(gun.models.undamaged).name.split('_')[1], ); const gunName = strings[gunListEntry.userString] ?? gunKey.replaceAll('_', ' '); const gunType = - 'clip' in turretGunEntry - ? turretGunEntry.pumpGunMode + 'clip' in gun + ? gun.pumpGunMode ? 'autoReloader' : 'autoLoader' : 'regular'; const gunReload = gunType === 'autoReloader' - ? turretGunEntry.pumpGunReloadTimes!.split(' ').map(Number) - : turretGunEntry.reloadTime; + ? gun.pumpGunReloadTimes!.split(' ').map(Number) + : gun.reloadTime; const gunClipCount = - gunType === 'regular' ? undefined : turretGunEntry.clip!.count; + gunType === 'regular' ? undefined : gun.clip!.count; const gunIntraClip = - gunType === 'regular' - ? undefined - : 60 / turretGunEntry.clip!.rate; - const front = turretGunEntry.extraPitchLimits?.front - ? turretGunEntry.extraPitchLimits.front.split(' ').map(Number) + gunType === 'regular' ? undefined : 60 / gun.clip!.rate; + const front = gun.extraPitchLimits?.front + ? gun.extraPitchLimits.front.split(' ').map(Number) : undefined; - const back = turretGunEntry.extraPitchLimits?.back - ? turretGunEntry.extraPitchLimits.back.split(' ').map(Number) + const back = gun.extraPitchLimits?.back + ? gun.extraPitchLimits.back.split(' ').map(Number) : undefined; - const transition = turretGunEntry.extraPitchLimits?.transition - ? typeof turretGunEntry.extraPitchLimits.transition === 'number' - ? turretGunEntry.extraPitchLimits.transition - : turretGunEntry.extraPitchLimits.transition.at(-1)! + const transition = gun.extraPitchLimits?.transition + ? typeof gun.extraPitchLimits.transition === 'number' + ? gun.extraPitchLimits.transition + : gun.extraPitchLimits.transition.at(-1)! : undefined; const gunArmor: ModelArmor = { thickness: {} }; const shotDispersionFactors = - turretGunEntry.shotDispersionFactors ?? - gunListEntry.shotDispersionFactors; + gun.shotDispersionFactors ?? gunListEntry.shotDispersionFactors; - Object.keys(turretGunEntry.armor) + totalUnlocks.push(gun.unlocks); + Object.keys(gun.armor) .filter((name) => name.startsWith('armor_')) .forEach((name) => { const armorIdString = name.match(/armor_(\d+)/)?.[1]; @@ -703,7 +727,7 @@ export async function definitions(production: boolean) { throw new SyntaxError(`Invalid armor id: ${name}`); } const armorId = parseInt(armorIdString); - const armorRaw = turretGunEntry.armor[name]; + const armorRaw = gun.armor[name]; if (typeof armorRaw === 'number') { gunArmor.thickness[armorId] = armorRaw; } else { @@ -725,13 +749,13 @@ export async function definitions(production: boolean) { count: gunClipCount, intraClip: gunIntraClip, camouflageLoss: - typeof turretGunEntry.invisibilityFactorAtShot === 'number' - ? turretGunEntry.invisibilityFactorAtShot - : turretGunEntry.invisibilityFactorAtShot.at(-1)!, - aimTime: turretGunEntry.aimingTime ?? gunListEntry.aimingTime, + typeof gun.invisibilityFactorAtShot === 'number' + ? gun.invisibilityFactorAtShot + : gun.invisibilityFactorAtShot.at(-1)!, + aimTime: gun.aimingTime ?? gunListEntry.aimingTime, dispersion: { base: - turretGunEntry.shotDispersionRadius ?? + gun.shotDispersionRadius ?? gunListEntry.shotDispersionRadius, damaged: shotDispersionFactors.whileGunDamaged, shot: shotDispersionFactors.afterShot, @@ -743,11 +767,11 @@ export async function definitions(production: boolean) { armor: gunArmor, model: gunModel, thickness: - turretGunEntry.armor.gun === undefined + gun.armor.gun === undefined ? 0 - : typeof turretGunEntry.armor.gun === 'number' - ? turretGunEntry.armor.gun - : turretGunEntry.armor.gun['#text'], + : typeof gun.armor.gun === 'number' + ? gun.armor.gun + : gun.armor.gun['#text'], pitch: { min: gunPitch[0], max: gunPitch[1], @@ -809,10 +833,100 @@ export async function definitions(production: boolean) { }); }, ); + + totalUnlocks.forEach((unlocks) => { + if (unlocks === undefined) return; + + Object.entries(unlocks).forEach(([key, value]) => { + (Array.isArray(value) ? value : [value]).forEach((vehicle) => { + switch (key as keyof Unlocks) { + case 'vehicle': { + const tankListEntry = tankList.root[vehicle['#text']]; + const currentTank = tankDefinitions[tankId]; + const successorId = toUniqueId(nation, tankListEntry.id); + + tankXps.set(successorId, vehicle.cost); + + if (currentTank.successors === undefined) { + currentTank.successors = []; + } + if (!currentTank.successors!.includes(successorId)) { + currentTank.successors!.push(successorId); + } + break; + } + + case 'gun': { + gunXps.set( + toUniqueId(nation, gunList.root.ids[vehicle['#text']]), + vehicle.cost, + ); + break; + } + + case 'turret': { + turretXps.set( + toUniqueId(nation, turretList.root.ids[vehicle['#text']]), + vehicle.cost, + ); + break; + } + + case 'engine': { + engineXps.set( + toUniqueId(nation, enginesList.root.ids[vehicle['#text']]), + vehicle.cost, + ); + break; + } + + case 'chassis': { + trackXps.set( + toUniqueId(nation, chassisList.root.ids[vehicle['#text']]), + vehicle.cost, + ); + break; + } + } + }); + }); + }); + + Object.values(tankDefinitions[tankId].turrets).forEach((turret) => { + turret.xp = turretXps.get(turret.id); + + Object.values(turret.guns).forEach((gun) => { + gun.xp = gunXps.get(gun.id); + }); + }); + + Object.values(tankDefinitions[tankId].engines).forEach((engine) => { + engine.xp = engineXps.get(engine.id); + }); + + Object.values(tankDefinitions[tankId].tracks).forEach((track) => { + track.xp = trackXps.get(track.id); + }); } }), ); + Object.values(tankDefinitions).forEach((tank) => { + tank.xp = tankXps.get(tank.id); + }); + + Object.values(tankDefinitions).forEach((tank) => { + tank.successors?.forEach((predecessorId) => { + if (tankDefinitions[predecessorId].ancestors === undefined) { + tankDefinitions[predecessorId].ancestors = []; + } + + if (!tankDefinitions[predecessorId].ancestors?.includes(tank.id)) { + tankDefinitions[predecessorId].ancestors?.push(tank.id); + } + }); + }); + Object.entries(optionalDevices.root).forEach( ([optionalDeviceKey, optionalDeviceEntry]) => { if (optionalDeviceKey === 'nextAvailableId') return; @@ -970,6 +1084,10 @@ export async function definitions(production: boolean) { } }); + writeFile( + 'test.tanks.cdon.lz4', + Buffer.from(superCompress(tankDefinitions), 'base64'), + ); await commitAssets( 'definitions', [ diff --git a/scripts/buildAssets/index.ts b/scripts/buildAssets/index.ts index b864c754f..643ee2b6b 100644 --- a/scripts/buildAssets/index.ts +++ b/scripts/buildAssets/index.ts @@ -14,6 +14,7 @@ import { boosterIcons } from './boosterIcons'; import { camouflageIcons } from './camouflageIcons'; import { circleFlags } from './circleFlags'; import { consumableProvisionIcons } from './consumableProvisionIcons'; +import { currencies } from './currencies'; import { definitions } from './definitions'; import { equipmentIcons } from './equipmentIcons'; import { moduleIcons } from './moduleIcons'; @@ -38,6 +39,7 @@ const methods = [ boosterIcons, tankArmors, tankModels, + currencies, ]; for (const method of methods) { diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..e84103846 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "jsx": "preserve", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "incremental": true, + "isolatedModules": true + } +} diff --git a/src/commands/ownedTanks.tsx b/src/commands/ownedTanks.tsx index c5faee701..29f081c67 100644 --- a/src/commands/ownedTanks.tsx +++ b/src/commands/ownedTanks.tsx @@ -1,14 +1,13 @@ -import CommandWrapper from '../components/CommandWrapper'; -import NoData from '../components/NoData'; -import * as Tanks from '../components/Tanks'; -import TitleBar from '../components/TitleBar'; +import { chunk } from 'lodash'; +import markdownEscape from 'markdown-escape'; +import { TANK_CLASSES } from '../components/Tanks/components/Item/constants'; import { getAccountInfo } from '../core/blitz/getAccountInfo'; -import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import getTankStats from '../core/blitz/getTankStats'; -import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { + NATIONS, TankDefinition, Tier, + flags, tankDefinitions, } from '../core/blitzkrieg/tankDefinitions'; import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants'; @@ -20,6 +19,8 @@ import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { translator } from '../core/localization/translator'; import { CommandRegistry } from '../events/interactionCreate'; +const TANKS_PER_MESSAGE = 64; + export const ownedTanksCommand = new Promise((resolve) => { resolve({ inProduction: true, @@ -32,10 +33,9 @@ export const ownedTanksCommand = new Promise((resolve) => { async handler(interaction) { const { translate } = translator(interaction.locale); const tier = Number(interaction.options.getString('tier')); - const account = await resolvePlayerFromCommand(interaction); - const { id, region: server } = account; - const accountInfo = await getAccountInfo(server, id); - const tankStats = await getTankStats(server, id, interaction.locale); + const { id, region } = await resolvePlayerFromCommand(interaction); + const accountInfo = await getAccountInfo(region, id); + const tankStats = await getTankStats(region, id, interaction.locale); const filteredTanks = ( await Promise.all( tankStats.map(async (tankData) => ({ @@ -44,7 +44,6 @@ export const ownedTanksCommand = new Promise((resolve) => { })), ) ).filter((tank) => tank.tankDefinitions?.tier === tier); - const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']); const groupedTanks: Record = {}; const nations: string[] = []; @@ -57,71 +56,28 @@ export const ownedTanksCommand = new Promise((resolve) => { } }); - nations.sort(); + const awaitedTankDefinitions = await tankDefinitions; + const awaitedNations = await NATIONS; + const lines = tankStats + .map(({ tank_id }) => awaitedTankDefinitions[tank_id]) + .filter((tank) => tank.tier === tier) + .sort( + (a, b) => + TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class), + ) + .sort( + (a, b) => + awaitedNations.indexOf(a.nation) - awaitedNations.indexOf(b.nation), + ) + .map((tank) => `${flags[tank.nation]} ${markdownEscape(tank.name)}`); - return ( - - + index === 0 + ? `${translate('bot.commands.owned_tanks.body.title', [ + markdownEscape(accountInfo.nickname), TIER_ROMAN_NUMERALS[tier as Tier], - ])} - /> - - {filteredTanks.length === 0 && ( - - )} - - {filteredTanks.length > 0 && - (await Promise.all( - nations.map(async (nation) => { - const tanks = groupedTanks[nation]; - const leftColumnSize = Math.ceil(tanks.length / 2); - const leftColumn = tanks.slice(0, leftColumnSize); - const rightColumn = tanks.slice(leftColumnSize); - - return ( - - - {translate(`common.nations.${nation}`)} - - - - - {await Promise.all( - leftColumn.map(async (tank) => ( - - )), - )} - - - {await Promise.all( - rightColumn.map(async (tank) => ( - - )), - )} - - - - ); - }), - ))} - + ])}${lines.join('\n')}` + : lines.join('\n'), ); }, diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts index c2916f13f..ab5052cf1 100644 --- a/src/commands/permissions.ts +++ b/src/commands/permissions.ts @@ -27,6 +27,10 @@ export const permissionsCommand = new Promise((resolve) => { interaction.appPermissions?.has('AttachFiles'), t`bot.commands.permissions.body.attach_files`, ], + [ + interaction.appPermissions?.has('UseExternalEmojis'), + t`bot.commands.permissions.body.external_emojis`, + ], ]; const body = permissions .map(([hasPermission, description]) => { diff --git a/src/commands/research.tsx b/src/commands/research.tsx new file mode 100644 index 000000000..43dc84788 --- /dev/null +++ b/src/commands/research.tsx @@ -0,0 +1,546 @@ +import { Locale, escapeMarkdown } from 'discord.js'; +import CommandWrapper from '../components/CommandWrapper'; +import TitleBar from '../components/TitleBar'; +import { equipmentPriceMatrix } from '../constants/equipmentPrice'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; +import getTankStats from '../core/blitz/getTankStats'; +import resolveTankId from '../core/blitz/resolveTankId'; +import { asset } from '../core/blitzkrieg/asset'; +import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine'; +import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; +import { iconPng } from '../core/blitzkrieg/iconPng'; +import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry'; +import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions'; +import { + TANK_ICONS, + TIER_ROMAN_NUMERALS, +} from '../core/blitzkrieg/tankDefinitions/constants'; +import { tankIconPng } from '../core/blitzkrieg/tankIconPng'; +import autocompleteTanks from '../core/discord/autocompleteTanks'; +import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; +import { localizationObject } from '../core/discord/localizationObject'; +import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import { translator } from '../core/localization/translator'; +import { CommandRegistry } from '../events/interactionCreate'; +import { theme } from '../stitches.config'; + +export const researchCommand = new Promise((resolve) => { + const { t } = translator(Locale.EnglishUS); + + resolve({ + inProduction: true, + inPublic: true, + inPreview: true, + + command: createLocalizedCommand('research') + .addStringOption((option) => + option + .setName(t`bot.commands.research.options.target_tank`) + .setNameLocalizations( + localizationObject('bot.commands.research.options.target_tank'), + ) + .setDescription( + t`bot.commands.research.options.target_tank.description`, + ) + .setDescriptionLocalizations( + localizationObject( + 'bot.commands.research.options.target_tank.description', + ), + ) + .setAutocomplete(true) + .setRequired(true), + ) + .addStringOption((option) => + option + .setName(t`bot.commands.research.options.starting_tank`) + .setNameLocalizations( + localizationObject('bot.commands.research.options.starting_tank'), + ) + .setDescription( + t`bot.commands.research.options.starting_tank.description`, + ) + .setDescriptionLocalizations( + localizationObject( + 'bot.commands.research.options.starting_tank.description', + ), + ) + .setAutocomplete(true) + .setRequired(false), + ), + + async handler(interaction) { + const { t, translate } = translator(interaction.locale); + const awaitedTankDefinitions = await tankDefinitions; + const targetTankId = await resolveTankId( + interaction.options.getString('target-tank', true), + interaction.locale, + true, + ); + const startingTankRaw = interaction.options.getString('starting-tank'); + const targetTank = awaitedTankDefinitions[targetTankId]; + const { id, region } = await resolvePlayerFromCommand(interaction); + let startingTankId: number; + const targetTankAncestry = await resolveAncestry(targetTankId); + + if (startingTankRaw) { + startingTankId = await resolveTankId( + startingTankRaw, + interaction.locale, + true, + ); + + if (targetTankId === startingTankId) { + return translate('bot.commands.research.errors.start_end_equal', [ + escapeMarkdown(targetTank.name), + ]); + } + + const startingTank = awaitedTankDefinitions[startingTankId]; + + if (startingTank.tier > targetTank.tier) { + return translate('bot.commands.research.errors.unordered', [ + escapeMarkdown(startingTank.name), + escapeMarkdown(targetTank.name), + escapeMarkdown(targetTank.name), + ]); + } + + if (!targetTankAncestry.includes(startingTankId)) { + return translate('bot.commands.research.errors.tanks_not_on_line', [ + escapeMarkdown(targetTank.name), + escapeMarkdown(startingTank.name), + ]); + } + } else { + if (targetTank.treeType !== 'researchable') { + return translate('bot.commands.research.errors.non_tech_tree', [ + escapeMarkdown(targetTank.name), + ]); + } + + if (targetTank.tier === 1) { + return translate('bot.commands.research.errors.tier_1', [ + escapeMarkdown(targetTank.name), + ]); + } + + const tankStats = await getTankStats(region, id, interaction.locale); + + if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) { + return translate('bot.commands.research.errors.already_researched', [ + escapeMarkdown(targetTank.name), + ]); + } + + const foundAncestor = targetTankAncestry.find( + (id) => + awaitedTankDefinitions[id].tier === 1 || + tankStats.some(({ tank_id }) => tank_id === id), + ); + + if (foundAncestor === undefined) { + throw new Error('No first owned tech tree found.'); + } + + startingTankId = foundAncestor; + } + + const techTreeLine = await buildTechTreeLine( + startingTankId, + targetTankId, + ); + + const line = [...techTreeLine, startingTankId].reverse(); + const { nickname } = await getAccountInfo(region, id); + const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; + const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; + const costs = await Promise.all( + line.map(async (id) => { + const tank = awaitedTankDefinitions[id]; + const turretXps = new Map(); + const gunXps = new Map(); + const engineXps = new Map(); + const trackXps = new Map(); + + tank.turrets.forEach((turret) => { + if (turret.xp !== undefined) { + turretXps.set(turret.id, turret.xp); + } + + turret.guns.forEach((gun) => { + if (gun.xp !== undefined) { + gunXps.set(gun.id, gun.xp); + } + }); + }); + + tank.engines.forEach((engine) => { + if (engine.xp !== undefined) { + engineXps.set(engine.id, engine.xp); + } + }); + + tank.tracks.forEach((track) => { + if (track.xp !== undefined) { + trackXps.set(track.id, track.xp); + } + }); + + let moduleXp = 0; + turretXps.forEach((value) => { + moduleXp += value; + }); + gunXps.forEach((value) => { + moduleXp += value; + }); + engineXps.forEach((value) => { + moduleXp += value; + }); + trackXps.forEach((value) => { + moduleXp += value; + }); + + return { + research: tank.xp, + upgrades: moduleXp, + purchase: tank.price.value, + equipment: equipmentPriceMatrix[tank.tier].reduce( + (a, b) => a + 3 * b, + 0, + ), + }; + }), + ); + + const image = ( + + + +
+
+
+ +
+ + {t`bot.commands.research.body.total`} + +
+ +
+
+ + + {costs + .reduce((a, b) => a + b.purchase + b.equipment, 0) + .toLocaleString(interaction.locale)} + +
+ +
+ + + {costs + .reduce((a, b) => a + (b.research ?? 0) + b.upgrades, 0) + .toLocaleString(interaction.locale)} + +
+
+
+ + {await Promise.all( + line.map(async (id, index) => { + const tank = awaitedTankDefinitions[id]; + + return ( +
+
+ + {TIER_ROMAN_NUMERALS[tank.tier]} + +
+ +
+
+ +
+ +
+ + + + {tank.name} + +
+
+ +
+
+ + + {translate('bot.commands.research.body.research', [ + `${costs[index].research?.toLocaleString( + interaction.locale, + )}`, + ])} + +
+ + {costs[index].upgrades > 0 && ( +
+ + + {translate('bot.commands.research.body.upgrades', [ + costs[index].upgrades.toLocaleString(), + ])} + +
+ )} + +
+ + + {translate('bot.commands.research.body.purchase', [ + costs[index].purchase.toLocaleString( + interaction.locale, + ), + ])} + +
+ +
+ + + {translate('bot.commands.research.body.equipment', [ + costs[index].equipment.toLocaleString( + interaction.locale, + ), + ])} + +
+
+
+ ); + }), + )} +
+
+ ); + + return startingTankRaw + ? image + : [t`bot.commands.research.body.estimation`, image]; + }, + + autocomplete(interaction) { + autocompleteTanks(interaction, true, ['target-tank', 'starting-tank']); + }, + }); +}); diff --git a/src/components/CommandWrapper.tsx b/src/components/CommandWrapper.tsx index bf9b99f49..5a953ef3f 100644 --- a/src/components/CommandWrapper.tsx +++ b/src/components/CommandWrapper.tsx @@ -16,7 +16,7 @@ export default function CommandWrapper({ children, fat }: CommandWrapperProps) { gap: 16, width: 480, color: theme.colors.textHighContrast, - background: 'url(https://i.imgur.com/PhS06NJ.png)', + background: theme.colors.appBackground1, }} > {children} diff --git a/src/constants/equipmentPrice.ts b/src/constants/equipmentPrice.ts new file mode 100644 index 000000000..115d05fd8 --- /dev/null +++ b/src/constants/equipmentPrice.ts @@ -0,0 +1,15 @@ +import { Vector3Tuple } from 'three'; +import { Tier } from '../core/blitzkrieg/tankDefinitions'; + +export const equipmentPriceMatrix: Record = { + 1: [100, 200, 300], + 2: [200, 300, 400], + 3: [400, 500, 600], + 4: [4000, 5000, 6000], + 5: [40_000, 50_000, 60_000], + 6: [100_000, 120_000, 140_000], + 7: [150_000, 170_000, 190_000], + 8: [200_000, 225_000, 250_000], + 9: [250_000, 275_000, 300_000], + 10: [300_000, 350_000, 400_000], +}; diff --git a/src/core/blitz/resolveTankId.ts b/src/core/blitz/resolveTankId.ts index 6db757940..1de2635a7 100644 --- a/src/core/blitz/resolveTankId.ts +++ b/src/core/blitz/resolveTankId.ts @@ -2,24 +2,27 @@ import { Locale } from 'discord.js'; import { go } from 'fuzzysort'; import markdownEscape from 'markdown-escape'; import { UserError } from '../../hooks/userError'; -import { - tankDefinitions, - tanksDefinitionsArray, -} from '../blitzkrieg/tankDefinitions'; +import { tankDefinitions, tankNames } from '../blitzkrieg/tankDefinitions'; +import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks'; import { translator } from '../localization/translator'; export default async function resolveTankId( tank: string | number, locale: Locale, + techTreeOnly = false, ) { const { translate } = translator(locale); const number = typeof tank === 'string' ? Number(tank) : tank; if (Number.isNaN(number)) { - const searchResult = go(`${tank}`, await tanksDefinitionsArray, { - keys: ['name'], - limit: 1, - }); + const searchResult = go( + `${tank}`, + await (techTreeOnly ? tankNamesTechTreeOnly : tankNames), + { + keys: ['combined'], + limit: 1, + }, + ); if (searchResult.length === 0) { throw new UserError( diff --git a/src/core/blitzkrieg/buildTechTreeLine.ts b/src/core/blitzkrieg/buildTechTreeLine.ts new file mode 100644 index 000000000..5f7a56aea --- /dev/null +++ b/src/core/blitzkrieg/buildTechTreeLine.ts @@ -0,0 +1,20 @@ +import { resolveAncestry } from './resolveAncestry'; +import { tankDefinitions } from './tankDefinitions'; + +export async function buildTechTreeLine(start: number, end: number) { + const awaitedTankDefinitions = await tankDefinitions; + const endTank = awaitedTankDefinitions[end]; + if (!endTank.ancestors) throw new Error('End tank has no ancestors'); + const line = [end]; + + if (start === end) return line; + for (const ancestorId of endTank.ancestors) { + if ((await resolveAncestry(ancestorId)).includes(start)) { + const ancestorTree = await buildTechTreeLine(start, ancestorId); + line.push(...ancestorTree); + break; + } + } + + return line; +} diff --git a/src/core/blitzkrieg/iconPng.ts b/src/core/blitzkrieg/iconPng.ts new file mode 100644 index 000000000..7a42c16e9 --- /dev/null +++ b/src/core/blitzkrieg/iconPng.ts @@ -0,0 +1,14 @@ +import sharp from 'sharp'; + +const cache: Record = {}; + +export async function iconPng(url: string) { + if (cache[url]) return cache[url]; + + const imageResponse = await fetch(url); + const imageBufferWEBP = await imageResponse.arrayBuffer(); + const imageBufferPNG = await sharp(imageBufferWEBP).png().toBuffer(); + cache[url] = `data:image/png;base64,${imageBufferPNG.toString('base64')}`; + + return cache[url]; +} diff --git a/src/core/blitzkrieg/resolveAncestry.ts b/src/core/blitzkrieg/resolveAncestry.ts new file mode 100644 index 000000000..980c67894 --- /dev/null +++ b/src/core/blitzkrieg/resolveAncestry.ts @@ -0,0 +1,25 @@ +import { tankDefinitions } from './tankDefinitions'; + +export async function resolveAncestry(id: number) { + const awaitedTankDefinitions = await tankDefinitions; + const tank = awaitedTankDefinitions[id]; + const ancestors: number[] = []; + + if (tank.ancestors) { + for (const ancestor of tank.ancestors) { + if (!ancestors.includes(ancestor)) { + ancestors.push(ancestor); + + for (const ancestorLayer2 of await resolveAncestry(ancestor)) { + if (!ancestors.includes(ancestorLayer2)) { + ancestors.push(ancestorLayer2); + } + } + } + } + } + + return ancestors.sort( + (a, b) => awaitedTankDefinitions[b].tier - awaitedTankDefinitions[a].tier, + ); +} diff --git a/src/core/blitzkrieg/tankDefinitions/index.ts b/src/core/blitzkrieg/tankDefinitions/index.ts index 7edca84ff..47ac938d8 100644 --- a/src/core/blitzkrieg/tankDefinitions/index.ts +++ b/src/core/blitzkrieg/tankDefinitions/index.ts @@ -22,6 +22,8 @@ export interface Crew { export interface TankDefinition { id: number; fixedCamouflage?: boolean; + ancestors?: number[]; + successors?: number[]; crew: Crew[]; health: number; nation: string; @@ -37,6 +39,7 @@ export interface TankDefinition { engines: EngineDefinition[]; tracks: TrackDefinition[]; price: TankDefinitionPrice; + xp?: number; speed: { forwards: number; backwards: number; @@ -54,6 +57,7 @@ export interface TrackDefinition { tier: Tier; weight: number; traverseSpeed: number; + xp?: number; dispersion: { move: number; traverse: number; @@ -67,6 +71,7 @@ export interface TrackDefinition { export interface EngineDefinition { id: number; name: string; + xp?: number; tier: Tier; fireChance: number; power: number; @@ -79,6 +84,7 @@ export interface TurretDefinition { health: number; viewRange: number; traverseSpeed: number; + xp?: number; id: number; name: string; tier: Tier; @@ -91,6 +97,7 @@ export type GunDefinition = | GunDefinitionAutoReloader; interface GunDefinitionBase { rotationSpeed: number; + xp?: number; weight: number; id: number; name: string; @@ -139,6 +146,10 @@ export const tankDefinitions = fetchCdonLz4( asset('definitions/tanks.cdon.lz4'), ); +// export const tankDefinitions = readFile('test.tanks.cdon.lz4').then((data) => +// superDecompress(data.buffer), +// ); + const entries = new Promise(async (resolve) => { resolve(Object.entries(await tankDefinitions).map(([, entry]) => entry)); }); @@ -156,6 +167,7 @@ export const tankNames = tanksDefinitionsArray.then((tanks) => id, original: tank.name, combined: `${tank.name}${deburr(tank.name)}${tank.nameFull ? `${tank.nameFull}${deburr(tank.nameFull)}` : ''}`, + treeType: tank.treeType, }; }), ), @@ -173,3 +185,15 @@ export const NATIONS = tanksDefinitionsArray.then((tanks) => { return nations; }); + +export const flags: Record = { + ussr: '<:ussr:1218421042033197197>', + germany: 'πŸ‡©πŸ‡ͺ', + usa: 'πŸ‡ΊπŸ‡Έ', + china: 'πŸ‡¨πŸ‡³', + uk: 'πŸ‡¬πŸ‡§', + france: 'πŸ‡«πŸ‡·', + japan: 'πŸ‡―πŸ‡΅', + european: 'πŸ‡ͺπŸ‡Ί', + other: '<:other:1218421572243558482>', +}; diff --git a/src/core/blitzkrieg/tankIconPng.ts b/src/core/blitzkrieg/tankIconPng.ts new file mode 100644 index 000000000..97b076bf9 --- /dev/null +++ b/src/core/blitzkrieg/tankIconPng.ts @@ -0,0 +1,6 @@ +import { iconPng } from './iconPng'; +import { tankIcon } from './tankIcon'; + +export function tankIconPng(id: number) { + return iconPng(tankIcon(id)); +} diff --git a/src/core/discord/autocompleteTanks.ts b/src/core/discord/autocompleteTanks.ts index ac05a3b84..312ab7c91 100644 --- a/src/core/discord/autocompleteTanks.ts +++ b/src/core/discord/autocompleteTanks.ts @@ -10,19 +10,29 @@ import { OVERFLOW_SUFFIX, } from './autocompleteClan/constants'; +export const tankNamesTechTreeOnly = tankNames.then((names) => + names.filter((tank) => tank.treeType === 'researchable'), +); + export default async function autocompleteTanks( interaction: AutocompleteInteraction, + techTreeOnly = false, + fields = ['tank'], ) { const focusedOption = interaction.options.getFocused(true); - if (focusedOption.name !== 'tank') return; + if (!fields.includes(focusedOption.name)) return; await interaction.respond( focusedOption.value ? await Promise.all( - go(focusedOption.value, await tankNames, { - keys: ['combined'], - limit: 10, - }).map(async (item) => { + go( + focusedOption.value, + await (techTreeOnly ? tankNamesTechTreeOnly : tankNames), + { + keys: ['combined'], + limit: 10, + }, + ).map(async (item) => { let name = item.obj.original; if (name.length > DISCORD_CHOICES_MAX_NAME_SIZE) { diff --git a/src/core/discord/normalizeInteractionReturnable.ts b/src/core/discord/normalizeInteractionReturnable.ts index 5642f5564..cfaa7750c 100644 --- a/src/core/discord/normalizeInteractionReturnable.ts +++ b/src/core/discord/normalizeInteractionReturnable.ts @@ -4,6 +4,8 @@ import { ButtonBuilder, EmbedBuilder, InteractionEditReplyOptions, + InteractionReplyOptions, + MessageEditOptions, } from 'discord.js'; import { InteractionReturnable } from '../../events/interactionCreate'; import jsxToPngThreaded from '../blitzkrieg/jsxToPngThreaded'; @@ -12,7 +14,9 @@ export default async function normalizeInteractionReturnable( returnable: InteractionReturnable, ) { const images: [number, Buffer][] = []; - const reply: InteractionEditReplyOptions = {}; + const reply: InteractionEditReplyOptions & + InteractionReplyOptions & + MessageEditOptions = {}; const awaitedReturnable = await returnable; const normalizedReturnable = Array.isArray(awaitedReturnable) diff --git a/src/events/interactionCreate/handlers/chatInputCommand.ts b/src/events/interactionCreate/handlers/chatInputCommand.ts index a5842327b..a3208c3a2 100644 --- a/src/events/interactionCreate/handlers/chatInputCommand.ts +++ b/src/events/interactionCreate/handlers/chatInputCommand.ts @@ -7,7 +7,7 @@ import { ChatInputCommandInteraction, InteractionReplyOptions, } from 'discord.js'; -import { commands } from '..'; +import { InteractionRawReturnable, commands } from '..'; import buttonLink from '../../../core/discord/buttonLink'; import embedWarning from '../../../core/discord/embedWarning'; import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable'; @@ -15,6 +15,11 @@ import { psa } from '../../../core/discord/psa'; import { translator } from '../../../core/localization/translator'; import { UserError } from '../../../hooks/userError'; +const previewServers = [ + '734786591205359697', // abhi + '460165161156870161', // optml +]; + export default async function handleChatInputCommand( interaction: ChatInputCommandInteraction, ) { @@ -24,74 +29,105 @@ export default async function handleChatInputCommand( await interaction.deferReply(); try { + if ( + registry.inPreview && + !previewServers.includes(`${interaction.guildId}`) + ) { + const { t } = translator(interaction.locale); + interaction.editReply(t`bot.common.errors.preview`); + + return; + } + const returnable = await registry.handler(interaction); if (registry.handlesInteraction) return; - const reply = await normalizeInteractionReturnable(returnable!); + const chunks: InteractionRawReturnable[][] = [[]]; - if ( - psa.data && - !psa.data.secret && - (psa.data.commands === undefined || - psa.data.commands.includes(interaction.commandName)) - ) { - if (psa.data.type === 'embed') { - if (!reply.embeds) reply.embeds = []; - reply.embeds.push(embedWarning(psa.data.title, psa.data.description)); - } else { - if (!reply.files) reply.files = []; - reply.files.push(new AttachmentBuilder(psa.data.image)); - reply.content = reply.content ?? psa.data.title; - } + (Array.isArray(returnable) ? returnable : [returnable]).forEach( + (object) => { + if (typeof object === 'string') { + chunks.push([object]); + } else chunks.at(-1)?.push(object!); + }, + ); - if (psa.data.links) { - if (!reply.components?.[0]) { - reply.components = [new ActionRowBuilder()]; + let index = 0; + for (const chunk of chunks.filter((chunk) => chunk.length > 0)) { + const reply = await normalizeInteractionReturnable(chunk); + + if ( + psa.data && + !psa.data.secret && + (psa.data.commands === undefined || + psa.data.commands.includes(interaction.commandName)) + ) { + if (psa.data.type === 'embed') { + if (!reply.embeds) reply.embeds = []; + reply.embeds.push(embedWarning(psa.data.title, psa.data.description)); + } else { + if (!reply.files) reply.files = []; + reply.files.push(new AttachmentBuilder(psa.data.image)); + reply.content = reply.content ?? psa.data.title; } - (reply.components[0] as ActionRowBuilder).addComponents( - psa.data.links.map(({ title, url }) => buttonLink(url, title)), - ); + if (psa.data.links) { + if (!reply.components?.[0]) { + reply.components = [new ActionRowBuilder()]; + } + + ( + reply.components[0] as ActionRowBuilder + ).addComponents( + psa.data.links.map(({ title, url }) => buttonLink(url, title)), + ); + } } - } - await interaction.editReply(reply); - - if ( - psa.data && - psa.data.secret && - (psa.data.commands === undefined || - psa.data.commands.includes(interaction.commandName)) - ) { - const followUp: InteractionReplyOptions = { - ephemeral: true, - }; - - if (psa.data.type === 'embed') { - if (!followUp.embeds) followUp.embeds = []; - followUp.embeds.push( - embedWarning(psa.data.title, psa.data.description), - ); + if (index === 0) { + await interaction.editReply(reply); } else { - if (!followUp.files) followUp.files = []; - followUp.files.push(new AttachmentBuilder(psa.data.image)); - followUp.content = followUp.content ?? psa.data.title; + await interaction.followUp(reply); } - if (psa.data.links) { - if (!followUp.components?.[0]) { - followUp.components = [new ActionRowBuilder()]; + if ( + psa.data && + psa.data.secret && + (psa.data.commands === undefined || + psa.data.commands.includes(interaction.commandName)) + ) { + const followUp: InteractionReplyOptions = { + ephemeral: true, + }; + + if (psa.data.type === 'embed') { + if (!followUp.embeds) followUp.embeds = []; + followUp.embeds.push( + embedWarning(psa.data.title, psa.data.description), + ); + } else { + if (!followUp.files) followUp.files = []; + followUp.files.push(new AttachmentBuilder(psa.data.image)); + followUp.content = followUp.content ?? psa.data.title; } - ( - followUp.components[0] as ActionRowBuilder - ).addComponents( - psa.data.links.map(({ title, url }) => buttonLink(url, title)), - ); + if (psa.data.links) { + if (!followUp.components?.[0]) { + followUp.components = [new ActionRowBuilder()]; + } + + ( + followUp.components[0] as ActionRowBuilder + ).addComponents( + psa.data.links.map(({ title, url }) => buttonLink(url, title)), + ); + } + + interaction.followUp(followUp); } - interaction.followUp(followUp); + index++; } } catch (error) { const { t } = translator(interaction.locale); diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts index 5e18ebfd6..bcbdcfe95 100644 --- a/src/events/interactionCreate/index.ts +++ b/src/events/interactionCreate/index.ts @@ -26,6 +26,7 @@ import { permissionsCommand } from '../../commands/permissions'; import { pingCommand } from '../../commands/ping'; import { playerInfoCommand } from '../../commands/playerInfo'; import { ratingsCommand } from '../../commands/ratings'; +import { researchCommand } from '../../commands/research'; import { searchClansCommand } from '../../commands/searchClans'; import { searchPlayersCommand } from '../../commands/searchPlayers'; import { statsCommand } from '../../commands/stats'; @@ -54,6 +55,7 @@ export type InteractionReturnable = interface CommandRegistryBase { inProduction: boolean; inPublic: boolean; + inPreview?: boolean; command: | SlashCommandBuilder @@ -92,6 +94,7 @@ export const COMMANDS_RAW: Promise[] = [ statsCommand, ratingsCommand, todayCommand, + researchCommand, ]; export const commands = Promise.allSettled(COMMANDS_RAW).then((rawCommands) => { diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 25fe514a7..c07c3eff5 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -190,11 +190,47 @@ "tank_id_not_found": "# Tank not found\nCould not find tank by the ID \"%s\". Try typing the name of the tank instead of a number.", "no_tank_stats": "# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.", "get_help": "Help Discord server", - "uncaught_error": "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help." + "uncaught_error": "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.", + "preview": "# This command is in preview\n\nYou can only access this command in [the official Discord server](https://discord.gg/nDt7AjGJQH). The command will be generally available soon." } }, "commands": { + "research": { + "$": "research", + "description": "Lists the expenses in researching a line", + + "options": { + "target_tank": { + "$": "target-tank", + "description": "The final tank to research in the line" + }, + "starting_tank": { + "$": "starting-tank", + "description": "The starting tank in the line (inferred automatically if not provided)" + } + }, + + "body": { + "subtitle": "Research", + "total": "Total", + "research": "Research: %s", + "upgrades": "Upgrades: %s", + "purchase": "Purchase: %s", + "equipment": "Equipment: %s", + "estimation": "# This is an estimation!\n\nThis list may include already researched tanks because of the inaccurate information Wargaming provides. Try providing a `starting-tank` from the line when using the command if you can." + }, + + "errors": { + "non_tech_tree": "# %s is not on the tech tree\n\nYou cannot research a premium/collector tank.", + "tier_1": "# %s is a tier I\n\nTier I tanks are always unlocked for free for all players.", + "already_researched": "# %s is already researched\n\nTry providing a `starting-tank` when using the command to view the totals for a line you have already researched.", + "start_end_equal": "# %s was provided twice\n\nMake sure `starting-tank` and `target-tank` are different.", + "tanks_not_on_line": "# %s and %s are not on the same line\n\nMake sure `starting-tank` and `target-tank` are on the same tech-tree line.", + "unordered": "# %s higher tiered to %s\n\nMake sure to provide %s as the `starting-tank` instead as it is the lower tiered tank." + } + }, + "about": { "$": "about", "description": "All the info you need about the bot", @@ -451,7 +487,7 @@ "description": "Shows all own tanks in a tier", "body": { - "subtitle": "Tier %s tanks" + "title": "# %s's owned tier %s tanks\n\n" } }, @@ -461,9 +497,10 @@ "body": { "title": "# Permissions\n\n%s", - "view_channels": "View channels: needed for the refresh button to work", - "read_message_history": "Read message history: needed for the refresh button to find the message to refresh", - "attach_files": "Attach files: needed for image-based commands to work" + "view_channels": "View channels: needed to find the message the refresh button was used on", + "read_message_history": "Read message history: needed to find the message to refresh", + "attach_files": "Attach files: needed to add images to image-based commands", + "external_emojis": "External emojis: needed add national flags and tank class emojis" } }, diff --git a/src/stitches.config.ts b/src/stitches.config.ts index 413f706be..5e769f749 100644 --- a/src/stitches.config.ts +++ b/src/stitches.config.ts @@ -29,6 +29,7 @@ export const theme = { }, borderStyles: { + subtle: `1px solid ${slateDark.slate2}`, ...createBorderStyles(slateDark), ...createBorderStyles(blueDark, undefined, 'blue'), }, diff --git a/yarn.lock b/yarn.lock index 7029eaa43..f02ec5e07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,7 +1156,7 @@ dependencies: "@prisma/debug" "5.9.1" -"@radix-ui/colors@^3.0.0", "@radix-ui/colors@^3.0.0-rc.4": +"@radix-ui/colors@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/colors/-/colors-3.0.0.tgz#e8a591a303c44e503bd1212cacf40a09511165e0" integrity sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==