feat: error handling for /research

This commit is contained in:
TrèsAbhi committed 2024-03-15 21:50:19 -05:00
1 parent 787d3ae6ea
commit d39ab43e74
6 files changed
+337 -96

No files matched your search

+289 -84
View File
@@ -1,4 +1,4 @@
import { escapeMarkdown } from 'discord.js'; import { Locale, escapeMarkdown } from 'discord.js';
import CommandWrapper from '../components/CommandWrapper'; import CommandWrapper from '../components/CommandWrapper';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import { equipmentPriceMatrix } from '../constants/equipmentPrice'; import { equipmentPriceMatrix } from '../constants/equipmentPrice';
@@ -17,62 +17,207 @@ import {
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '../core/blitzkrieg/tankDefinitions/constants'; } from '../core/blitzkrieg/tankDefinitions/constants';
import { tankIconPng } from '../core/blitzkrieg/tankIconPng'; import { tankIconPng } from '../core/blitzkrieg/tankIconPng';
import addTankChoices from '../core/discord/addTankChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
export const researchCommand = new Promise<CommandRegistry>((resolve) => { export const researchCommand = new Promise<CommandRegistry>((resolve) => {
const { t } = translator(Locale.EnglishUS);
resolve({ resolve({
inProduction: true, inProduction: true,
inPublic: 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) { async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const awaitedTankDefinitions = await tankDefinitions; const awaitedTankDefinitions = await tankDefinitions;
const tankId = await resolveTankId( const targetTankId = await resolveTankId(
interaction.options.getString('tank', true), interaction.options.getString('target-tank', true),
interaction.locale, interaction.locale,
true,
); );
const tank = awaitedTankDefinitions[tankId]; const startingTankRaw = interaction.options.getString('starting-tank');
const targetTank = awaitedTankDefinitions[targetTankId];
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 { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const tankStats = await getTankStats(region, id, interaction.locale); let startingTankId: number;
const ancestry = await resolveAncestry(tankId); const targetTankAncestry = await resolveAncestry(targetTankId);
const firstOwnedTechTree = ancestry.find(
(id) =>
awaitedTankDefinitions[id].tier === 1 ||
tankStats.some(({ tank_id }) => tank_id === id),
);
if (firstOwnedTechTree === undefined) { if (startingTankRaw) {
throw new Error('No first owned tech tree found.'); 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 = ( const techTreeLine = await buildTechTreeLine(
await buildTechTreeLine(firstOwnedTechTree, tankId) startingTankId,
).reverse(); targetTankId,
);
const line = [...techTreeLine, startingTankId].reverse();
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; 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<number, number>();
const gunXps = new Map<number, number>();
const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>();
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 = (
<CommandWrapper> <CommandWrapper>
<TitleBar <TitleBar
title={nickname} title={nickname}
image={clanImage} image={clanImage}
description={`Research • ${tank.name}`} description={`${t`bot.commands.research.body.subtitle`} • ${targetTank.name}`}
/> />
<div <div
@@ -82,51 +227,98 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
gap: 8, gap: 8,
}} }}
> >
<div
style={{
display: 'flex',
borderRadius: 4,
backgroundColor: theme.colors.appBackground2,
border: theme.borderStyles.subtle,
}}
>
<div style={{ width: 48 }}></div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
right: 24,
}}
>
<span
style={{
color: theme.colors.textHighContrast,
}}
>
{t`bot.commands.research.body.total`}
</span>
</div>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 8,
padding: '16px 0',
}}
>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await iconPng(asset('icons/currencies/xp.webp'))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{costs
.reduce((a, b) => a + b.purchase + b.equipment, 0)
.toLocaleString(interaction.locale)}
</span>
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await iconPng(asset('icons/currencies/silver.webp'))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{costs
.reduce((a, b) => a + (b.research ?? 0) + b.upgrades, 0)
.toLocaleString(interaction.locale)}
</span>
</div>
</div>
</div>
{await Promise.all( {await Promise.all(
line.map(async (id) => { line.map(async (id, index) => {
const tank = awaitedTankDefinitions[id]; const tank = awaitedTankDefinitions[id];
const turretXps = new Map<number, number>();
const gunXps = new Map<number, number>();
const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>();
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 ( return (
<div <div
@@ -135,7 +327,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
display: 'flex', display: 'flex',
borderRadius: 4, borderRadius: 4,
backgroundColor: theme.colors.appBackground2, backgroundColor: theme.colors.appBackground2,
border: `1px solid ${theme.colors.componentInteractive}`, border: theme.borderStyles.subtle,
}} }}
> >
<div <div
@@ -241,12 +433,15 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
}} }}
> >
Research:{' '} {translate('bot.commands.research.body.research', [
{tank.xp?.toLocaleString(interaction.locale)} `${costs[index].research?.toLocaleString(
interaction.locale,
)}`,
])}
</span> </span>
</div> </div>
{moduleXp > 0 && ( {costs[index].upgrades > 0 && (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -267,7 +462,9 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
}} }}
> >
Upgrades: {moduleXp.toLocaleString()} {translate('bot.commands.research.body.upgrades', [
costs[index].upgrades.toLocaleString(),
])}
</span> </span>
</div> </div>
)} )}
@@ -292,8 +489,11 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
}} }}
> >
Purchase:{' '} {translate('bot.commands.research.body.purchase', [
{tank.price.value.toLocaleString(interaction.locale)} costs[index].purchase.toLocaleString(
interaction.locale,
),
])}
</span> </span>
</div> </div>
@@ -317,10 +517,11 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
}} }}
> >
Equipment:{' '} {translate('bot.commands.research.body.equipment', [
{equipmentPriceMatrix[tank.tier] costs[index].equipment.toLocaleString(
.reduce((a, b) => a + 3 * b, 0) interaction.locale,
.toLocaleString(interaction.locale)} ),
])}
</span> </span>
</div> </div>
</div> </div>
@@ -331,10 +532,14 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
</div> </div>
</CommandWrapper> </CommandWrapper>
); );
return startingTankRaw
? image
: [t`bot.commands.research.body.estimation`, image];
}, },
autocomplete(interaction) { autocomplete(interaction) {
autocompleteTanks(interaction, true); autocompleteTanks(interaction, true, ['target-tank', 'starting-tank']);
}, },
}); });
}); });
+11 -8
View File
@@ -2,24 +2,27 @@ import { Locale } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import { UserError } from '../../hooks/userError'; import { UserError } from '../../hooks/userError';
import { import { tankDefinitions, tankNames } from '../blitzkrieg/tankDefinitions';
tankDefinitions, import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
tanksDefinitionsArray,
} from '../blitzkrieg/tankDefinitions';
import { translator } from '../localization/translator'; import { translator } from '../localization/translator';
export default async function resolveTankId( export default async function resolveTankId(
tank: string | number, tank: string | number,
locale: Locale, locale: Locale,
techTreeOnly = false,
) { ) {
const { translate } = translator(locale); const { translate } = translator(locale);
const number = typeof tank === 'string' ? Number(tank) : tank; const number = typeof tank === 'string' ? Number(tank) : tank;
if (Number.isNaN(number)) { if (Number.isNaN(number)) {
const searchResult = go(`${tank}`, await tanksDefinitionsArray, { const searchResult = go(
keys: ['name'], `${tank}`,
limit: 1, await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
}); {
keys: ['combined'],
limit: 1,
},
);
if (searchResult.length === 0) { if (searchResult.length === 0) {
throw new UserError( throw new UserError(
+2 -1
View File
@@ -10,7 +10,8 @@ export async function buildTechTreeLine(start: number, end: number) {
if (start === end) return line; if (start === end) return line;
for (const ancestorId of endTank.ancestors) { for (const ancestorId of endTank.ancestors) {
if ((await resolveAncestry(ancestorId)).includes(start)) { if ((await resolveAncestry(ancestorId)).includes(start)) {
line.push(...(await buildTechTreeLine(start, ancestorId))); const ancestorTree = await buildTechTreeLine(start, ancestorId);
line.push(...ancestorTree);
break; break;
} }
} }
+3 -2
View File
@@ -10,16 +10,17 @@ import {
OVERFLOW_SUFFIX, OVERFLOW_SUFFIX,
} from './autocompleteClan/constants'; } from './autocompleteClan/constants';
const tankNamesTechTreeOnly = tankNames.then((names) => export const tankNamesTechTreeOnly = tankNames.then((names) =>
names.filter((tank) => tank.treeType === 'researchable'), names.filter((tank) => tank.treeType === 'researchable'),
); );
export default async function autocompleteTanks( export default async function autocompleteTanks(
interaction: AutocompleteInteraction<CacheType>, interaction: AutocompleteInteraction<CacheType>,
techTreeOnly = false, techTreeOnly = false,
fields = ['tank'],
) { ) {
const focusedOption = interaction.options.getFocused(true); const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'tank') return; if (!fields.includes(focusedOption.name)) return;
await interaction.respond( await interaction.respond(
focusedOption.value focusedOption.value
+31 -1
View File
@@ -197,7 +197,37 @@
"commands": { "commands": {
"research": { "research": {
"$": "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": { "about": {
+1
View File
@@ -29,6 +29,7 @@ export const theme = {
}, },
borderStyles: { borderStyles: {
subtle: `1px solid ${slateDark.slate2}`,
...createBorderStyles(slateDark), ...createBorderStyles(slateDark),
...createBorderStyles(blueDark, undefined, 'blue'), ...createBorderStyles(blueDark, undefined, 'blue'),
}, },