diff --git a/src/commands/research.tsx b/src/commands/research.tsx index cc479c280..246a28d60 100644 --- a/src/commands/research.tsx +++ b/src/commands/research.tsx @@ -1,4 +1,4 @@ -import { escapeMarkdown } from 'discord.js'; +import { Locale, escapeMarkdown } from 'discord.js'; import CommandWrapper from '../components/CommandWrapper'; import TitleBar from '../components/TitleBar'; import { equipmentPriceMatrix } from '../constants/equipmentPrice'; @@ -17,62 +17,207 @@ import { TIER_ROMAN_NUMERALS, } from '../core/blitzkrieg/tankDefinitions/constants'; import { tankIconPng } from '../core/blitzkrieg/tankIconPng'; -import addTankChoices from '../core/discord/addTankChoices'; 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, - command: createLocalizedCommand('research').addStringOption(addTankChoices), + 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 tankId = await resolveTankId( - interaction.options.getString('tank', true), + const targetTankId = await resolveTankId( + interaction.options.getString('target-tank', true), interaction.locale, + true, ); - const tank = awaitedTankDefinitions[tankId]; - - if (tank.treeType !== 'researchable') { - return `# ${escapeMarkdown(tank.name)} is not researchable\n\nThis tank is not on the tech tree.`; - } - - if (tank.tier === 1) { - return `# ${escapeMarkdown(tank.name)} is not researchable\n\nThis tank is a tier I and is always unlocked.`; - } - + const startingTankRaw = interaction.options.getString('starting-tank'); + const targetTank = awaitedTankDefinitions[targetTankId]; const { id, region } = await resolvePlayerFromCommand(interaction); - const tankStats = await getTankStats(region, id, interaction.locale); - const ancestry = await resolveAncestry(tankId); - const firstOwnedTechTree = ancestry.find( - (id) => - awaitedTankDefinitions[id].tier === 1 || - tankStats.some(({ tank_id }) => tank_id === id), - ); + let startingTankId: number; + const targetTankAncestry = await resolveAncestry(targetTankId); - if (firstOwnedTechTree === undefined) { - throw new Error('No first owned tech tree found.'); + 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 line = ( - await buildTechTreeLine(firstOwnedTechTree, tankId) - ).reverse(); + 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(); - return ( + 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 = (
((resolve) => { gap: 8, }} > +
+
+ +
+ + {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) => { + line.map(async (id, index) => { 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 (
((resolve) => { display: 'flex', borderRadius: 4, backgroundColor: theme.colors.appBackground2, - border: `1px solid ${theme.colors.componentInteractive}`, + border: theme.borderStyles.subtle, }} >
((resolve) => { color: theme.colors.textHighContrast, }} > - Research:{' '} - {tank.xp?.toLocaleString(interaction.locale)} + {translate('bot.commands.research.body.research', [ + `${costs[index].research?.toLocaleString( + interaction.locale, + )}`, + ])}
- {moduleXp > 0 && ( + {costs[index].upgrades > 0 && (
((resolve) => { color: theme.colors.textHighContrast, }} > - Upgrades: {moduleXp.toLocaleString()} + {translate('bot.commands.research.body.upgrades', [ + costs[index].upgrades.toLocaleString(), + ])}
)} @@ -292,8 +489,11 @@ export const researchCommand = new Promise((resolve) => { color: theme.colors.textHighContrast, }} > - Purchase:{' '} - {tank.price.value.toLocaleString(interaction.locale)} + {translate('bot.commands.research.body.purchase', [ + costs[index].purchase.toLocaleString( + interaction.locale, + ), + ])}
@@ -317,10 +517,11 @@ export const researchCommand = new Promise((resolve) => { color: theme.colors.textHighContrast, }} > - Equipment:{' '} - {equipmentPriceMatrix[tank.tier] - .reduce((a, b) => a + 3 * b, 0) - .toLocaleString(interaction.locale)} + {translate('bot.commands.research.body.equipment', [ + costs[index].equipment.toLocaleString( + interaction.locale, + ), + ])}
@@ -331,10 +532,14 @@ export const researchCommand = new Promise((resolve) => {
); + + return startingTankRaw + ? image + : [t`bot.commands.research.body.estimation`, image]; }, autocomplete(interaction) { - autocompleteTanks(interaction, true); + autocompleteTanks(interaction, true, ['target-tank', 'starting-tank']); }, }); }); 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 index d3d83bcfb..5f7a56aea 100644 --- a/src/core/blitzkrieg/buildTechTreeLine.ts +++ b/src/core/blitzkrieg/buildTechTreeLine.ts @@ -10,7 +10,8 @@ export async function buildTechTreeLine(start: number, end: number) { if (start === end) return line; for (const ancestorId of endTank.ancestors) { if ((await resolveAncestry(ancestorId)).includes(start)) { - line.push(...(await buildTechTreeLine(start, ancestorId))); + const ancestorTree = await buildTechTreeLine(start, ancestorId); + line.push(...ancestorTree); break; } } diff --git a/src/core/discord/autocompleteTanks.ts b/src/core/discord/autocompleteTanks.ts index 1e15ec13f..312ab7c91 100644 --- a/src/core/discord/autocompleteTanks.ts +++ b/src/core/discord/autocompleteTanks.ts @@ -10,16 +10,17 @@ import { OVERFLOW_SUFFIX, } from './autocompleteClan/constants'; -const tankNamesTechTreeOnly = tankNames.then((names) => +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 diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 3d236abd6..9fbb91c0d 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -197,7 +197,37 @@ "commands": { "research": { "$": "research", - "description": "Lists the expenses in researching a line" + "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 researchable\n\nThis tank is not on the tech tree.", + "tier_1": "# %s is not researchable\n\nThis tank is a tier I and is always unlocked.", + "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": { 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'), },