Merge pull request #62 from tresabhi/1.15.10

This commit is contained in:
TrèsAbhi authored and GitHub committed 2024-01-19 21:48:33 -06:00
commit 0644c9a67f
29 files changed
+645 -528

No files matched your search

+1
View File
@@ -39,6 +39,7 @@
"ownedtanks", "ownedtanks",
"playerachievements", "playerachievements",
"playerinfo", "playerinfo",
"Promisable",
"ratingsleaderboard", "ratingsleaderboard",
"ratingsplot", "ratingsplot",
"researchable", "researchable",
+23
View File
@@ -0,0 +1,23 @@
# Blitzkrieg 1.15.10
The 10th patch for the `1.15` update is here, breaking the record of the most patches for a single update!
## Features
- Added `/today`
- This is a direct alias for `/breakdown today`
- Drastically simplified ratings command
- `/ratings current` and `/ratings archive` have been now merged
- Where the data is fetched from is now decided directly from the chosen season number
- Simplified many error messages
- Title bar description text is now centered when there is no image
- It always felt awkward when there was no image on the left but the left still aligned to the right
- Removed `/link`'s requirement to select a username explicitly from the search results
- Full names for regions are now used in the `/link` command
- So "NA" turns into "North America"
## Fixes
- Fixed contrast issues with WN8 percentile colors behind white text
- Archived ratings neighbors now functions once again
- Disabled tankopedia; this feature isn't finished yet and the changes were accidentally leaked
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.15.9", "version": "1.15.10",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
+7 -7
View File
@@ -13,7 +13,7 @@ import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages'; import { tankAverages } from '../core/blitzstars/tankAverages';
import addFilterOptions from '../core/discord/addFilterOptions'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -33,12 +33,12 @@ import resolvePlayerFromCommand, {
import calculateWN8 from '../core/statistics/calculateWN8'; import calculateWN8 from '../core/statistics/calculateWN8';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import getWN8Percentile from '../core/statistics/getWN8Percentile'; import getWN8Percentile from '../core/statistics/getWN8Percentile';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
const ROWS_PER_PAGE = 8; const ROWS_PER_PAGE = 8;
const MAX_PAGES = 10; const MAX_PAGES = 10;
async function render( export async function renderBreakdown(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod, { start, end, name }: ResolvedPeriod,
filters: StatFilters, filters: StatFilters,
@@ -290,9 +290,9 @@ async function render(
} }
} }
export const breakdownCommand = new Promise<CommandRegistryRaw>( export const breakdownCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
const command = await addFilterOptions( const command = await addPeriodicFilterOptions(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('breakdown') .setName('breakdown')
.setDescription("A period's breakdown by tanks played"), .setDescription("A period's breakdown by tanks played"),
@@ -316,7 +316,7 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
}); });
return [ return [
...(await render(player, period, filters)), ...(await renderBreakdown(player, period, filters)),
buttonPrimary(path, 'Refresh'), buttonPrimary(path, 'Refresh'),
await getBlitzStarsLinkButton(player.region, player.id), await getBlitzStarsLinkButton(player.region, player.id),
]; ];
@@ -332,7 +332,7 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
const period = resolvePeriodFromButton(player.region, interaction); const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction); const filters = getFiltersFromButton(interaction);
return await render(player, period, filters); return await renderBreakdown(player, period, filters);
}, },
}); });
}, },
+1 -1
View File
@@ -13,7 +13,7 @@ export const debugCommand: CommandRegistry = {
.setName('debug') .setName('debug')
.setDescription('Debug information about the bot'), .setDescription('Debug information about the bot'),
async handler(interaction) { async handler() {
const currentTime = new Date().getTime(); const currentTime = new Date().getTime();
const uptime = currentTime - executionStart; const uptime = currentTime - executionStart;
const list = [ const list = [
+2 -2
View File
@@ -29,7 +29,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand, { import resolvePlayerFromCommand, {
ResolvedPlayer, ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
type EvolutionStatType = 'player' | 'tank'; type EvolutionStatType = 'player' | 'tank';
@@ -107,7 +107,7 @@ async function render(
); );
} }
export const evolutionCommand = new Promise<CommandRegistryRaw>( export const evolutionCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
let playerGroup: SlashCommandSubcommandGroupBuilder; let playerGroup: SlashCommandSubcommandGroupBuilder;
let tankGroup: SlashCommandSubcommandGroupBuilder; let tankGroup: SlashCommandSubcommandGroupBuilder;
+4 -4
View File
@@ -10,7 +10,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -29,7 +29,7 @@ import resolvePlayerFromCommand, {
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { getTierWeights } from '../core/statistics/getTierWeights'; import { getTierWeights } from '../core/statistics/getTierWeights';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
async function render( async function render(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
@@ -64,9 +64,9 @@ async function render(
); );
} }
export const fullStatsCommand = new Promise<CommandRegistryRaw>( export const fullStatsCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
const command = await addFilterOptions( const command = await addPeriodicFilterOptions(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('full-stats') .setName('full-stats')
.setDescription('Full in-game statistics'), .setDescription('Full in-game statistics'),
+140 -154
View File
@@ -1,25 +1,21 @@
import { teal } from '@radix-ui/colors'; import { teal } from '@radix-ui/colors';
import { import { GuildMemberRoleManager, SlashCommandBuilder } from 'discord.js';
AttachmentBuilder,
GuildMemberRoleManager,
SlashCommandBuilder,
} from 'discord.js';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' }; import discord from '../../discord.json' assert { type: 'json' };
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow'; import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions'; import { REGION_NAMES } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import embedNegative from '../core/discord/embedNegative'; import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
export const verifyCommand: CommandRegistry = { export const verifyCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inPublic: true, inPublic: true,
@@ -30,193 +26,183 @@ export const verifyCommand: CommandRegistry = {
.addStringOption((option) => addUsernameChoices(option).setRequired(true)), .addStringOption((option) => addUsernameChoices(option).setRequired(true)),
async handler(interaction) { async handler(interaction) {
const username = interaction.options.getString('username', true); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
if (serverAndIdPattern.test(username)) { await linkBlitzAndDiscord(discordId, region, id);
const [region, idString] = username.split('/');
const id = parseInt(idString);
const discordId = parseInt(interaction.user.id);
const accountInfo = await getAccountInfo(region as Region, id);
const clanAccountInfo = await getClanAccountInfo(region as Region, id, [
'clan',
]);
await linkBlitzAndDiscord(discordId, region as Region, id); if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
if (interaction.guildId === discord.sklld_guild_id) { return embedNegative(
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { `${markdownEscape(interaction.user.username)} failed to verify`,
return embedNegative( "I don't have the permission to change your manage roles.",
`${markdownEscape(interaction.user.username)} failed to verify`,
"I don't have the permission to change your manage roles.",
);
}
await (interaction.member!.roles as GuildMemberRoleManager).remove(
discord.sklld_verify_role,
);
await (interaction.member!.roles as GuildMemberRoleManager).add(
discord.sklld_peasant_role,
); );
} }
return ( await (interaction.member!.roles as GuildMemberRoleManager).remove(
<Wrapper fat> discord.sklld_verify_role,
);
await (interaction.member!.roles as GuildMemberRoleManager).add(
discord.sklld_peasant_role,
);
}
return [
<Wrapper fat>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Glow color={teal.teal9} rotation={90} />
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'center',
gap: 16,
}} }}
> >
<Glow color={teal.teal9} rotation={90} /> <div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
}
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{interaction.user.displayName}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
@{interaction.user.username}
</span>
</div>
</div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 16, gap: 4,
color: theme.colors.textLowContrast_teal,
}} }}
> >
<div <img
src="https://i.imgur.com/jIcRgog.png"
style={{ style={{
display: 'flex', width: 16,
gap: 8, height: 16,
alignItems: 'center',
justifyContent: 'center',
}} }}
> />
<span style={{ fontSize: 16 }}>Accounts linked</span>
</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{clanAccountInfo?.clan ? (
<img <img
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
}
style={{ style={{
width: 64, width: 64,
height: 64, height: 64,
borderRadius: 32, borderRadius: 32,
objectFit: 'cover', objectFit: 'cover',
}} }}
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
/> />
) : null}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{interaction.user.displayName}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
@{interaction.user.username}
</span>
</div>
</div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'center',
gap: 4, gap: 4,
color: theme.colors.textLowContrast_teal,
}} }}
> >
<img <span
src="https://i.imgur.com/jIcRgog.png"
style={{ style={{
width: 16, fontSize: 32,
height: 16, color: theme.colors.textHighContrast,
}} fontWeight: 900,
/> maxWidth: 240,
<span style={{ fontSize: 16 }}>Accounts linked</span> overflow: 'hidden',
</div> textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{clanAccountInfo?.clan ? (
<img
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
/>
) : null}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}} }}
> >
<span {accountInfo.nickname}
style={{ </span>
fontSize: 32, <span
color: theme.colors.textHighContrast, style={{
fontWeight: 900, fontSize: 16,
maxWidth: 240, color: theme.colors.textLowContrast,
overflow: 'hidden', }}
textOverflow: 'ellipsis', >
whiteSpace: 'nowrap', {clanAccountInfo?.clan
}} ? `[${clanAccountInfo?.clan?.tag}] - `
> : ''}
{accountInfo.nickname} {REGION_NAMES[region]}
</span> </span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
{clanAccountInfo?.clan
? `[${clanAccountInfo?.clan?.tag}] - `
: ''}
{REGION_NAMES_SHORT[region as Region]}
</span>
</div>
</div> </div>
</div> </div>
<Glow color={teal.teal9} rotation={-90} />
</div> </div>
</Wrapper>
); <Glow color={teal.teal9} rotation={-90} />
} else { </div>
return [ </Wrapper>,
embedNegative(
'Please select from the options', embedInfo(
'Follow the procedure like the video above. The search results may take a while to show up.', 'Incorrect account?',
), 'Use the command again and select a username from the search results.',
new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'), ),
]; ];
}
}, },
autocomplete: autocompleteUsername, autocomplete: autocompleteUsername,
+164 -192
View File
@@ -2,7 +2,6 @@ import {
APIApplicationCommandOptionChoice, APIApplicationCommandOptionChoice,
SlashCommandBuilder, SlashCommandBuilder,
SlashCommandSubcommandBuilder, SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js'; } from 'discord.js';
import { range } from 'lodash'; import { range } from 'lodash';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
@@ -17,17 +16,17 @@ import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo'; import getRatingsInfo from '../core/blitz/getRatingsInfo';
import { getRatingsLeague } from '../core/blitz/getRatingsLeague'; import { getRatingsLeague } from '../core/blitz/getRatingsLeague';
import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors'; import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors';
import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber'; import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo'; import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard'; import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard'; import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
import { UserError } from '../core/blitzkrieg/userError'; import { UserError } from '../core/blitzkrieg/userError';
import addRegionChoices from '../core/discord/addRegionChoices'; import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative'; import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
export interface RatingsPlayer { export interface RatingsPlayer {
spa_id: number; spa_id: number;
@@ -116,18 +115,22 @@ export interface RatingsNeighbors {
neighbors: RatingsPlayer[]; neighbors: RatingsPlayer[];
} }
function addSubcommands( const noOngoingSeason = embedNegative(
option: SlashCommandSubcommandGroupBuilder, 'No ongoing season',
addOptions = (option: SlashCommandSubcommandBuilder) => option, "Wargaming didn't provide any data for this season.",
) { );
return option
.addSubcommand((option) => export const ratingsCommand = new Promise<CommandRegistryPromisable>(
addOptions( async (resolve) => {
option const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
.setName('neighbors') const onGoingSeason = await isOnGoingRatingsSeason();
.setDescription('Your position and neighbors'), const seasonNumbers = range(
) FIRST_ARCHIVED_RATINGS_SEASON,
.addStringOption(addUsernameChoices) latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addSubcommands(option: SlashCommandSubcommandBuilder) {
return option
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('limit') .setName('limit')
@@ -135,77 +138,57 @@ function addSubcommands(
.setRequired(false) .setRequired(false)
.setMinValue(5) .setMinValue(5)
.setMaxValue(30), .setMaxValue(30),
), )
)
.addSubcommand((option) =>
addOptions(option.setName('league').setDescription('Top league players'))
.addStringOption((option) => .addStringOption((option) =>
option option
.setName('league') .setName('season')
.setDescription('The league to display') .setDescription('Season number')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false) .setRequired(false)
.setMinValue(5) .addChoices(
.setMaxValue(30), ...seasonNumbers
), .map(
); (number, index) =>
} ({
name: `${number}${index === seasonNumbers.length - 1 && onGoingSeason ? ' (current)' : ''}`,
const noOngoingSeason = embedNegative( value: `${number}`,
'No ongoing season', }) satisfies APIApplicationCommandOptionChoice<string>,
"Wargaming didn't provide any data for this season.", )
); .reverse(),
),
export const ratingsCommand = new Promise<CommandRegistryRaw>( );
async (resolve) => { }
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const seasonChoices = range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + 1,
).map(
(number) =>
({
name: `Season ${number}`,
value: `${number}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
);
const command = new SlashCommandBuilder() const command = new SlashCommandBuilder()
.setName('ratings') .setName('ratings')
.setDescription('Ratings battles statistics') .setDescription('Ratings battles statistics')
.addSubcommandGroup((option) => .addSubcommand((option) =>
addSubcommands(
option.setName('archive').setDescription('Any previous season'),
(option) =>
option.addStringOption((option) =>
option
.setName('season')
.setDescription('The season number')
.setChoices(...seasonChoices)
.setRequired(true),
),
),
)
.addSubcommandGroup((option) =>
addSubcommands( addSubcommands(
option option
.setName('current') .setName('neighbors')
.setDescription('The current running Ratings season'), .setDescription('Shows neighboring players on the leaderboard'),
),
)
.addSubcommand((option) =>
addSubcommands(
option
.setName('league')
.setDescription('Shows top players from a league')
.addStringOption(addRegionChoices)
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
),
), ),
); );
@@ -224,18 +207,19 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
nickname: string; nickname: string;
} }
const subcommandGroup = interaction.options.getSubcommandGroup(true) as
| 'archive'
| 'current';
const subcommand = interaction.options.getSubcommand(true) as const subcommand = interaction.options.getSubcommand(true) as
| 'league' | 'league'
| 'neighbors'; | 'neighbors';
const limit = interaction.options.getInteger('limit') ?? 10; const limit = interaction.options.getInteger('limit') ?? 10;
const seasonOption = interaction.options.getString('season'); const seasonOption = interaction.options.getString('season');
const season = seasonOption ? parseInt(seasonOption) : undefined; const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string; let titleName: string;
let titleImage: string | undefined; let titleImage: string | undefined;
let titleNameDiscriminator: string | undefined;
let titleDescription: string; let titleDescription: string;
let playersBefore: number; let playersBefore: number;
let playersAfter: number; let playersAfter: number;
@@ -249,69 +233,66 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
interaction.options.getString('league')!, interaction.options.getString('league')!,
); );
const region = interaction.options.getString('region') as Region; const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await (subcommandGroup === 'current' regionRatingsInfo = await (isCurrentSeason
? getRatingsInfo(region) ? getRatingsInfo(region)
: getArchivedRatingsInfo(region, season!)); : getArchivedRatingsInfo(region, season!));
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const result = const result = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsLeague(region, leagueIndex).then(async (result) =>
? await getRatingsLeague(region, leagueIndex).then( result.slice(0, limit).map(
async (result) => (player) =>
result.slice(0, limit).map( ({
(player) => id: player.spa_id,
({ score: player.score,
id: player.spa_id, position: player.number,
score: player.score, clan: player.clan_tag,
position: player.number, nickname: player.nickname,
clan: player.clan_tag, }) satisfies SimplifiedPlayer,
nickname: player.nickname, ),
}) satisfies SimplifiedPlayer, )
), : await getArchivedRatingsLeaderboard(region, season!).then(
) async (players) => {
: await getArchivedRatingsLeaderboard(region, season!).then( const firstPlayerIndex =
async (players) => { leagueIndex === 0
const firstPlayerIndex = ? 0
leagueIndex === 0 : players.findIndex(
? 0 (player) =>
: players.findIndex( player.score < LEAGUES[leagueIndex - 1].minScore,
(player) => );
player.score < LEAGUES[leagueIndex - 1].minScore, const lastPlayerIndex = firstPlayerIndex + limit - 1;
); const trimmed = players.slice(
const lastPlayerIndex = firstPlayerIndex + limit - 1; firstPlayerIndex,
const trimmed = players.slice( lastPlayerIndex + 1,
firstPlayerIndex, );
lastPlayerIndex + 1, const clanData = await getClanAccountInfo(
); region,
const clanData = await getClanAccountInfo( trimmed.map(({ id }) => id),
region, ['clan'],
trimmed.map(({ id }) => id), );
['clan'],
);
return trimmed.map( return trimmed.map(
(player, index) => (player, index) =>
({ ({
id: player.id, id: player.id,
score: player.score, score: player.score,
position: firstPlayerIndex + index + 1, position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag, clan: clanData[player.id]?.clan?.tag,
nickname: nickname:
clanData[index]?.account_name ?? clanData[index]?.account_name ??
`Deleted ${player.id}`, `Deleted ${player.id}`,
}) satisfies SimplifiedPlayer, }) satisfies SimplifiedPlayer,
); );
}, },
); );
const leagueInfo = regionRatingsInfo.leagues[leagueIndex]; const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = midnightLeaderboard = isCurrentSeason
subcommandGroup === 'current' ? await getArchivedRatingsMidnightLeaderboard(
? await getArchivedRatingsMidnightLeaderboard( region,
region, regionRatingsInfo.current_season,
regionRatingsInfo.current_season, )
) : await getArchivedRatingsMidnightLeaderboard(region, season!);
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = result; players = result;
playersBefore = result ? result[0].position - 1 : 0; playersBefore = result ? result[0].position - 1 : 0;
@@ -326,22 +307,17 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
titleDescription = `Top ${limit} players`; titleDescription = `Top ${limit} players`;
} else { } else {
const { region, id } = await resolvePlayerFromCommand(interaction); const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = regionRatingsInfo = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsInfo(region)
? await getRatingsInfo(region) : await getArchivedRatingsInfo(region, season!);
: await getArchivedRatingsInfo(region, season!);
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = const neighbors = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
? await getRatingsNeighbors( (players) =>
region,
id,
Math.round(limit / 2),
).then((players) =>
players.neighbors.map( players.neighbors.map(
(player) => (player) =>
({ ({
@@ -352,52 +328,48 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
nickname: player.nickname, nickname: player.nickname,
}) satisfies SimplifiedPlayer, }) satisfies SimplifiedPlayer,
), ),
) )
: await getArchivedRatingsLeaderboard(region, season!).then( : await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => { async (players) => {
const playerIndex = players.findIndex( const playerIndex = players.findIndex(
(player) => player.id === id, (player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
`${accountInfo.nickname} didn't play ratings in season ${season}`,
'This player did not participate in this season or did not get past calibration.',
); );
}
if (playerIndex === -1) { const halfRange = Math.round(limit / 2);
throw new UserError( const trimmed = players.slice(
`${accountInfo.nickname} didn't player ratings in season ${season}`, playerIndex - halfRange,
{ playerIndex + halfRange + 1,
cause: );
'This player did not participate in this season or did not get past calibration.', const clanData = await getClanAccountInfo(
}, region,
); trimmed.map((player) => player.id),
} ['clan'],
);
const halfRange = Math.round(limit / 2); return trimmed.map((player, index) => ({
const trimmed = players.slice( id: player.id,
playerIndex - halfRange, score: player.score,
playerIndex + halfRange + 1, position: playerIndex + index + 1,
); clan: clanData[index]?.clan?.tag,
const clanData = await getClanAccountInfo( nickname: clanData[index]
region, ? clanData[index]!.account_name
trimmed.map((player) => player.id), : `Deleted ${player.id}`,
['clan'], }));
); },
);
return trimmed.map((player, index) => ({ midnightLeaderboard = isCurrentSeason
id: player.id, ? await getArchivedRatingsMidnightLeaderboard(
score: player.score, region,
position: playerIndex + index + 1, regionRatingsInfo.current_season,
clan: clanData[player.id]!.clan?.tag, )
nickname: clanData[player.id]!.account_name, : await getArchivedRatingsMidnightLeaderboard(region, season!);
}));
},
);
midnightLeaderboard =
subcommandGroup === 'current'
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors; players = neighbors;
titleImage = clan titleImage = clan
+42 -40
View File
@@ -9,7 +9,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -27,7 +27,7 @@ import resolvePlayerFromCommand, {
ResolvedPlayer, ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
async function render( async function render(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
@@ -57,48 +57,50 @@ async function render(
); );
} }
export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => { export const statsCommand = new Promise<CommandRegistryPromisable>(
const command = await addFilterOptions( async (resolve) => {
new SlashCommandBuilder() const command = await addPeriodicFilterOptions(
.setName('stats') new SlashCommandBuilder()
.setDescription('Regular battles statistics'), .setName('stats')
(option) => option.addStringOption(addUsernameChoices), .setDescription('Regular battles statistics'),
); (option) => option.addStringOption(addUsernameChoices),
);
resolve({ resolve({
inProduction: true, inProduction: true,
inPublic: true, inPublic: true,
command, command,
async handler(interaction) { async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction); const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction); const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction); const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, { const path = commandToURL(interaction, {
...player, ...player,
...getCustomPeriodParams(interaction), ...getCustomPeriodParams(interaction),
...filters, ...filters,
}); });
return Promise.all([ return Promise.all([
render(player, period, filters), render(player, period, filters),
buttonRefresh(interaction, path), buttonRefresh(interaction, path),
getBlitzStarsLinkButton(player.region, player.id), getBlitzStarsLinkButton(player.region, player.id),
]); ]);
}, },
autocomplete: (interaction) => { autocomplete: (interaction) => {
autocompleteUsername(interaction); autocompleteUsername(interaction);
autocompleteTanks(interaction); autocompleteTanks(interaction);
}, },
async button(interaction) { async button(interaction) {
const player = await resolvePlayerFromButton(interaction); const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction); const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction); const filters = getFiltersFromButton(interaction);
return await render(player, period, filters); return await render(player, period, filters);
}, },
}); });
}); },
);
+71
View File
@@ -0,0 +1,71 @@
import { SlashCommandBuilder } from 'discord.js';
import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import { addFilterOptions } from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import buttonPrimary from '../core/discord/buttonPrimary';
import commandToURL from '../core/discord/commandToURL';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryPromisable } from '../events/interactionCreate';
import { renderBreakdown } from './breakdown';
export const todayCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => {
const awaitedEncyclopediaInfo = await encyclopediaInfo;
const command = addFilterOptions(
new SlashCommandBuilder()
.setName('today')
.setDescription("Today's played tanks and statistics"),
awaitedEncyclopediaInfo,
).addStringOption(addUsernameChoices);
resolve({
inProduction: true,
inPublic: true,
command,
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(
player.region,
interaction,
'today',
);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction, true),
...filters,
});
return [
...(await renderBreakdown(player, period, filters)),
buttonPrimary(path, 'Refresh'),
await getBlitzStarsLinkButton(player.region, player.id),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await renderBreakdown(player, period, filters);
},
});
},
);
@@ -37,7 +37,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
gap: -16, gap: -16,
}} }}
> >
<Glow color={PERCENTILE_COLORS[percentile]} /> <Glow color={color} />
<div <div
style={{ style={{
@@ -71,7 +71,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
</span> </span>
</div> </div>
<Glow color={PERCENTILE_COLORS[percentile]} direction="reverse" /> <Glow color={color} direction="reverse" />
</div> </div>
); );
} }
+3 -3
View File
@@ -1,4 +1,5 @@
import { import {
amber,
blue, blue,
green, green,
lime, lime,
@@ -8,7 +9,6 @@ import {
red, red,
slate, slate,
teal, teal,
yellow,
} from '@radix-ui/colors'; } from '@radix-ui/colors';
import { Percentile } from '../constants/percentiles'; import { Percentile } from '../constants/percentiles';
@@ -20,8 +20,8 @@ export const PERCENTILE_COLORS = {
[Percentile.VeryBad]: slate.slate9, [Percentile.VeryBad]: slate.slate9,
[Percentile.Bad]: red.red9, [Percentile.Bad]: red.red9,
[Percentile.BelowAverage]: orange.orange9, [Percentile.BelowAverage]: orange.orange9,
[Percentile.Average]: yellow.yellow9, [Percentile.Average]: amber.amber10,
[Percentile.AboveAverage]: lime.lime9, [Percentile.AboveAverage]: lime.lime8,
[Percentile.Good]: green.green9, [Percentile.Good]: green.green9,
[Percentile.VeryGood]: teal.teal9, [Percentile.VeryGood]: teal.teal9,
[Percentile.Great]: blue.blue9, [Percentile.Great]: blue.blue9,
-1
View File
@@ -18,7 +18,6 @@ export const TANK_TYPES = [
'heavy', 'heavy',
] as const; ] as const;
// TODO: remove hyphen for underscore
export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const; export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const;
export type TankType = (typeof TANK_TYPES)[number]; export type TankType = (typeof TANK_TYPES)[number];
+17 -3
View File
@@ -36,6 +36,8 @@ export default function TitleBar({ name, image, description }: TitleBarProps) {
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
<span <span
@@ -51,9 +53,21 @@ export default function TitleBar({ name, image, description }: TitleBarProps) {
</span> </span>
</div> </div>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}> <div
{description} style={{
</span> display: 'flex',
justifyContent: image ? 'flex-start' : 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{description}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -20,6 +20,7 @@ export const TOOLS: Tool[] = [
id: 'tankopedia', id: 'tankopedia',
title: 'Tankopedia', title: 'Tankopedia',
description: 'Blitz tank encyclopedia', description: 'Blitz tank encyclopedia',
disabled: true,
}, },
{ {
id: 'inactive', id: 'inactive',
+8 -1
View File
@@ -4,7 +4,11 @@ import { context } from '../blitzkrieg/context';
import { patientFetch } from '../blitzkrieg/patientFetch'; import { patientFetch } from '../blitzkrieg/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain'; import regionToRegionSubdomain from './regionToRegionSubdomain';
const ratingsInfoCache: Partial<Record<Region, RatingsInfo>> = {};
export default async function getRatingsInfo(region: Region) { export default async function getRatingsInfo(region: Region) {
if (ratingsInfoCache[region]) return ratingsInfoCache[region]!;
const response = await patientFetch( const response = await patientFetch(
context === 'website' context === 'website'
? `/api/${region}/ratings/current/info` ? `/api/${region}/ratings/current/info`
@@ -14,6 +18,9 @@ export default async function getRatingsInfo(region: Region) {
undefined, undefined,
{ cache: 'no-store' }, { cache: 'no-store' },
); );
const data = (await response.json()) as RatingsInfo;
return (await response.json()) as Promise<RatingsInfo>; ratingsInfoCache[region] = data;
return data;
} }
+4 -4
View File
@@ -10,10 +10,10 @@ export default async function getTankStats(region: Region, id: number) {
); );
if (tankStats[id] === null) { if (tankStats[id] === null) {
throw new UserError('No tank stats available', { throw new UserError(
cause: 'No tank stats available',
"This player doesn't have any stats for tanks available. This may not be the player you are looking for.", "This player doesn't have any stats available. This may not be the player you are looking for.",
}); );
} }
return tankStats[id]; return tankStats[id];
+6
View File
@@ -0,0 +1,6 @@
import getRatingsInfo from './getRatingsInfo';
export async function isOnGoingRatingsSeason() {
const ratingsInfo = await getRatingsInfo('com');
return ratingsInfo.detail === undefined;
}
+8 -6
View File
@@ -15,9 +15,10 @@ export default async function resolveTankId(tank: string | number) {
}); });
if (searchResult.length === 0) { if (searchResult.length === 0) {
throw new UserError('Tank not found', { throw new UserError(
cause: `Could not find tank by the name "${tank}".`, 'Tank not found',
}); `Could not find tank by the name "${tank}".`,
);
} else { } else {
return searchResult[0].obj.id; return searchResult[0].obj.id;
} }
@@ -25,9 +26,10 @@ export default async function resolveTankId(tank: string | number) {
if ((await tankDefinitions)[number]) { if ((await tankDefinitions)[number]) {
return number; return number;
} else { } else {
throw new UserError('Tank not found', { throw new UserError(
cause: `Could not find tank by the ID "${number}".`, 'Tank not found',
}); `Could not find tank by the ID "${number}". Try typing the name of the tank instead of a number.`,
);
} }
} }
} }
+6 -1
View File
@@ -1,4 +1,9 @@
/** /**
* Discriminator for user caused errors to not report in logs * Discriminator for user caused errors to not report in logs
*/ */
export class UserError extends Error {} export class UserError {
constructor(
public message: string,
public cause: string,
) {}
}
+55 -71
View File
@@ -1,76 +1,60 @@
import { import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from 'discord.js';
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks'; import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks';
import { encyclopediaInfo } from '../blitz/encyclopediaInfo'; import { EncyclopediaInfo } from '../blitz/encyclopediaInfo';
import addPeriodSubCommands from './addPeriodSubCommands';
import addTankChoices from './addTankChoices'; import addTankChoices from './addTankChoices';
import addTierChoices from './addTierChoices'; import addTierChoices from './addTierChoices';
export default async function addFilterOptions< export function addFilterOptions<
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, Option extends SlashCommandBuilder | SlashCommandSubcommandBuilder,
>( >(option: Option, encyclopediaInfo: EncyclopediaInfo) {
option: OptionType, return option
extra: ( .addStringOption((option) =>
option: SlashCommandSubcommandBuilder, option
) => SlashCommandSubcommandBuilder = (option) => option, .setName('nation')
): Promise<OptionType> { .setDescription('Nation')
const awaitedEncyclopediaInfo = await encyclopediaInfo; .addChoices(
...Object.entries(encyclopediaInfo.vehicle_nations).map(
addPeriodSubCommands(option, (option) => { ([type, name]) => ({ name, value: type }),
extra(option); ),
)
return option .setRequired(false),
.addStringOption((option) => )
option .addStringOption((option) => addTierChoices(option).setRequired(false))
.setName('nation') .addStringOption((option) =>
.setDescription('Nation') option
.addChoices( .setName('tank-type')
...Object.entries(awaitedEncyclopediaInfo.vehicle_nations).map( .setDescription('Tank type')
([type, name]) => ({ name, value: type }), .addChoices(
), ...Object.entries(encyclopediaInfo.vehicle_types).map(
) ([type, name]) => ({
.setRequired(false), name: name.replace(' Tank', ''),
) value: type,
.addStringOption((option) => addTierChoices(option).setRequired(false)) }),
.addStringOption((option) => ),
option )
.setName('tank-type') .setRequired(false),
.setDescription('Tank type') )
.addChoices( .addStringOption((option) =>
...Object.entries(awaitedEncyclopediaInfo.vehicle_types).map( option
([type, name]) => ({ .setName('tree-type')
name: name.replace(' Tank', ''), .setDescription('Tech tree type')
value: type, .addChoices(
}), {
), name: TREE_TYPE_NAMES['researchable'],
) value: 'researchable' satisfies TreeTypeString,
.setRequired(false), },
) {
.addStringOption((option) => name: TREE_TYPE_NAMES.premium,
option value: 'premium' satisfies TreeTypeString,
.setName('tree-type') },
.setDescription('Tech tree type') {
.addChoices( name: TREE_TYPE_NAMES.collector,
{ value: 'collector' satisfies TreeTypeString,
name: TREE_TYPE_NAMES['researchable'], },
value: 'researchable' satisfies TreeTypeString, )
}, .setRequired(false),
{ )
name: TREE_TYPE_NAMES.premium, .addStringOption((option) =>
value: 'premium' satisfies TreeTypeString, addTankChoices(option).setRequired(false),
}, ) as Option;
{
name: TREE_TYPE_NAMES.collector,
value: 'collector' satisfies TreeTypeString,
},
)
.setRequired(false),
)
.addStringOption((option) => addTankChoices(option).setRequired(false));
});
return option;
} }
@@ -0,0 +1,25 @@
import {
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { encyclopediaInfo } from '../blitz/encyclopediaInfo';
import { addFilterOptions } from './addFilterOptions';
import addPeriodSubCommands from './addPeriodSubCommands';
export default async function addPeriodicFilterOptions<
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
>(
option: OptionType,
extra: (
option: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder = (option) => option,
): Promise<OptionType> {
const awaitedEncyclopediaInfo = await encyclopediaInfo;
addPeriodSubCommands(option, (option) => {
return addFilterOptions(extra(option), awaitedEncyclopediaInfo);
});
return option;
}
+6 -1
View File
@@ -3,12 +3,17 @@ import { PeriodType } from './addPeriodSubCommands';
export function getCustomPeriodParams( export function getCustomPeriodParams(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
forcedNonCustomPeriod = false,
) { ) {
if ((interaction.options.getSubcommand() as PeriodType) === 'custom') if (
!forcedNonCustomPeriod &&
(interaction.options.getSubcommand() as PeriodType) === 'custom'
) {
return { return {
start: interaction.options.getInteger('start', true), start: interaction.options.getInteger('start', true),
end: interaction.options.getInteger('end', true), end: interaction.options.getInteger('end', true),
}; };
}
return undefined; return undefined;
} }
+4 -3
View File
@@ -18,9 +18,10 @@ export default async function resolveClanFromCommand(
if (accounts[0]) { if (accounts[0]) {
return { region: accounts[0].region, id: accounts[0].clan_id }; return { region: accounts[0].region, id: accounts[0].clan_id };
} else { } else {
throw new UserError('Could not find clan', { throw new UserError(
cause: `I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`, 'Could not find clan',
}); `\`${clan}\` was not found. Try selecting a clan from the search result.`,
);
} }
} }
} }
+4 -1
View File
@@ -20,11 +20,14 @@ export interface ResolvedPeriod {
export default function resolvePeriodFromCommand( export default function resolvePeriodFromCommand(
region: Region, region: Region,
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
forcedPeriod?: PeriodType,
) { ) {
let name: string; let name: string;
let start: number; let start: number;
let end: number; let end: number;
const periodSubcommand = interaction.options.getSubcommand() as PeriodType; const periodSubcommand = forcedPeriod
? forcedPeriod
: (interaction.options.getSubcommand() as PeriodType);
if (periodSubcommand === 'custom') { if (periodSubcommand === 'custom') {
const startOption = interaction.options.getInteger('start', true); const startOption = interaction.options.getInteger('start', true);
+8 -6
View File
@@ -34,11 +34,12 @@ export default async function resolvePlayerFromCommand(
id: accounts[0].account_id, id: accounts[0].account_id,
} satisfies ResolvedPlayer; } satisfies ResolvedPlayer;
} else { } else {
throw new UserError('Could not find user', { throw new UserError(
cause: `I couldn't find user "${markdownEscape( 'Could not find user',
`I couldn't find user "${markdownEscape(
commandUsername, commandUsername,
)}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`, )}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`,
}); );
} }
} }
} else { } else {
@@ -47,9 +48,10 @@ export default async function resolvePlayerFromCommand(
if (account) { if (account) {
return { region: account.region, id: account.blitz }; return { region: account.region, id: account.blitz };
} else { } else {
throw new UserError('Use the `/link` command', { throw new UserError(
cause: 'Link your Blitz and Discord accounts to get started.', "You're account isn't linked with Blitzkrieg yet",
}); 'Use the `/link` command to get started.',
);
} }
} }
} }
@@ -19,7 +19,8 @@ import { psa } from '../../../core/discord/psa';
export default async function handleChatInputCommand( export default async function handleChatInputCommand(
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) { ) {
const registry = (await commands)[interaction.commandName]; const awaitedCommands = await commands;
const registry = awaitedCommands[interaction.commandName];
await interaction.deferReply(); await interaction.deferReply();
+30 -23
View File
@@ -31,6 +31,7 @@ import { searchClansCommand } from '../../commands/searchClans';
import { searchPlayersCommand } from '../../commands/searchPlayers'; import { searchPlayersCommand } from '../../commands/searchPlayers';
import { searchTanksCommand } from '../../commands/searchTanks'; import { searchTanksCommand } from '../../commands/searchTanks';
import { statsCommand } from '../../commands/stats'; import { statsCommand } from '../../commands/stats';
import { todayCommand } from '../../commands/today';
import getClientId from '../../core/blitzkrieg/getClientId'; import getClientId from '../../core/blitzkrieg/getClientId';
import isDev from '../../core/blitzkrieg/isDev'; import isDev from '../../core/blitzkrieg/isDev';
import { secrets } from '../../core/blitzkrieg/secrets'; import { secrets } from '../../core/blitzkrieg/secrets';
@@ -52,34 +53,39 @@ export type InteractionReturnable =
| InteractionIterableReturnable | InteractionIterableReturnable
| Promise<InteractionIterableReturnable>; | Promise<InteractionIterableReturnable>;
export interface Registry { type CommandRegistryBase = {
inProduction: boolean;
}
export type CommandRegistryRaw = Registry & {
inPublic: boolean;
inPreview?: boolean;
command: command:
| SlashCommandBuilder | SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>; | Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
};
type CommandRegistryDefinitionBase = {
inProduction: boolean;
inPublic: boolean;
inPreview?: boolean;
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void; autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable; button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable;
} & ( };
| { type CommandRegistryDefinitionHandlesInteraction = {
handlesInteraction: true; handlesInteraction: true;
handler: (interaction: ChatInputCommandInteraction<CacheType>) => void; handler: (interaction: ChatInputCommandInteraction<CacheType>) => void;
} };
| { type CommandRegistryDefinitionDoesHandleInteraction = {
handlesInteraction?: false; handlesInteraction?: false;
handler: ( handler: (
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) => InteractionReturnable; ) => InteractionReturnable;
} };
); export type CommandRegistryPromisable = CommandRegistryBase &
export type CommandRegistry = CommandRegistryRaw | Promise<CommandRegistryRaw>; (CommandRegistryDefinitionBase &
(
| CommandRegistryDefinitionHandlesInteraction
| CommandRegistryDefinitionDoesHandleInteraction
));
export type CommandRegistry =
| CommandRegistryPromisable
| Promise<CommandRegistryPromisable>;
const rest = new REST().setToken(secrets.DISCORD_TOKEN); const rest = new REST().setToken(secrets.DISCORD_TOKEN);
@@ -101,10 +107,11 @@ export const COMMANDS_RAW: CommandRegistry[] = [
evolutionCommand, evolutionCommand,
statsCommand, statsCommand,
ratingsCommand, ratingsCommand,
todayCommand,
]; ];
export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) => export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) =>
rawCommands.reduce<Record<string, CommandRegistryRaw>>( rawCommands.reduce<Record<string, CommandRegistryPromisable>>(
(accumulator, registry) => { (accumulator, registry) => {
if (isDev()) registry.command.setDefaultMemberPermissions(0); if (isDev()) registry.command.setDefaultMemberPermissions(0);
@@ -123,7 +130,7 @@ export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
[]; [];
commands.then((awaitedCommands) => { commands.then((awaitedCommands) => {
Object.entries(awaitedCommands).forEach(([, registry]) => { Object.values(awaitedCommands).forEach((registry) => {
const json = registry.command.toJSON(); const json = registry.command.toJSON();
if (registry.inPublic) { if (registry.inPublic) {