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 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<CommandRegistry>((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<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>
<TitleBar
title={nickname}
image={clanImage}
description={`Research • ${tank.name}`}
description={`${t`bot.commands.research.body.subtitle`} • ${targetTank.name}`}
/>
<div
@@ -82,51 +227,98 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
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(
line.map(async (id) => {
line.map(async (id, index) => {
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 (
<div
@@ -135,7 +327,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
display: 'flex',
borderRadius: 4,
backgroundColor: theme.colors.appBackground2,
border: `1px solid ${theme.colors.componentInteractive}`,
border: theme.borderStyles.subtle,
}}
>
<div
@@ -241,12 +433,15 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast,
}}
>
Research:{' '}
{tank.xp?.toLocaleString(interaction.locale)}
{translate('bot.commands.research.body.research', [
`${costs[index].research?.toLocaleString(
interaction.locale,
)}`,
])}
</span>
</div>
{moduleXp > 0 && (
{costs[index].upgrades > 0 && (
<div
style={{
display: 'flex',
@@ -267,7 +462,9 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast,
}}
>
Upgrades: {moduleXp.toLocaleString()}
{translate('bot.commands.research.body.upgrades', [
costs[index].upgrades.toLocaleString(),
])}
</span>
</div>
)}
@@ -292,8 +489,11 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
color: theme.colors.textHighContrast,
}}
>
Purchase:{' '}
{tank.price.value.toLocaleString(interaction.locale)}
{translate('bot.commands.research.body.purchase', [
costs[index].purchase.toLocaleString(
interaction.locale,
),
])}
</span>
</div>
@@ -317,10 +517,11 @@ export const researchCommand = new Promise<CommandRegistry>((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,
),
])}
</span>
</div>
</div>
@@ -331,10 +532,14 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
</div>
</CommandWrapper>
);
return startingTankRaw
? image
: [t`bot.commands.research.body.estimation`, image];
},
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 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(
+2 -1
View File
@@ -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;
}
}
+3 -2
View File
@@ -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<CacheType>,
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
+31 -1
View File
@@ -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": {
+1
View File
@@ -29,6 +29,7 @@ export const theme = {
},
borderStyles: {
subtle: `1px solid ${slateDark.slate2}`,
...createBorderStyles(slateDark),
...createBorderStyles(blueDark, undefined, 'blue'),
},