diff --git a/.github/workflows/cd-host.yml b/.github/workflows/cd-host.yml index 9fb0336e5..d9fd59e25 100644 --- a/.github/workflows/cd-host.yml +++ b/.github/workflows/cd-host.yml @@ -2,7 +2,7 @@ name: CD Host on: schedule: - - cron: '0 */6 * * *' + - cron: '0 */5 * * *' workflow_dispatch: concurrency: diff --git a/.github/workflows/cd-ratings-asia.yml b/.github/workflows/cd-ratings-asia.yml index 1c237bcb2..339680037 100644 --- a/.github/workflows/cd-ratings-asia.yml +++ b/.github/workflows/cd-ratings-asia.yml @@ -35,4 +35,4 @@ jobs: run: yarn - name: Generate leaderboard - run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} + run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} diff --git a/.github/workflows/cd-ratings-eu.yml b/.github/workflows/cd-ratings-eu.yml index 1f699cfa4..dec750984 100644 --- a/.github/workflows/cd-ratings-eu.yml +++ b/.github/workflows/cd-ratings-eu.yml @@ -35,4 +35,4 @@ jobs: run: yarn - name: Generate leaderboard - run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} + run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} diff --git a/.github/workflows/cd-ratings-na.yml b/.github/workflows/cd-ratings-na.yml index 89b801ce0..12d0b2d55 100644 --- a/.github/workflows/cd-ratings-na.yml +++ b/.github/workflows/cd-ratings-na.yml @@ -35,4 +35,4 @@ jobs: run: yarn - name: Generate leaderboard - run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} + run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} diff --git a/.vscode/settings.json b/.vscode/settings.json index a23ae5f27..420f12534 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -52,6 +52,7 @@ "glsl", "GLTF", "iife", + "haha", "immer", "Inclusivity", "JOINTINDEX", @@ -88,6 +89,7 @@ "POSITIVEY", "POSITIVEZ", "Pyogenics", + "Promisable", "ratingsleaderboard", "ratingsplot", "raycaster", diff --git a/docs/changelogs/1.15.10.md b/docs/changelogs/1.15.10.md new file mode 100644 index 000000000..231d0f2f2 --- /dev/null +++ b/docs/changelogs/1.15.10.md @@ -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 diff --git a/docs/changelogs/1.15.11.md b/docs/changelogs/1.15.11.md new file mode 100644 index 000000000..fdebb263e --- /dev/null +++ b/docs/changelogs/1.15.11.md @@ -0,0 +1,11 @@ +# Blitzkrieg 1.15.11 + +There's an easter egg somewhere in this update 🤔 + +## Features + +- Added `/permissions` which lists all permissions required to run the bot and checks if the bot has them or not + +## Changes + +- Error messages now do no use embeds, instead utilize simple headings and paragraphs diff --git a/docs/changelogs/1.15.12.md b/docs/changelogs/1.15.12.md new file mode 100644 index 000000000..422d90329 --- /dev/null +++ b/docs/changelogs/1.15.12.md @@ -0,0 +1,8 @@ +# Blitzkrieg 1.15.12 + +Sometimes embeds need love too. + +## Changes + +- Session embeds are now translucent by default + - Session embed titles are now the username of the player by default diff --git a/docs/changelogs/1.15.13.md b/docs/changelogs/1.15.13.md new file mode 100644 index 000000000..cb930846c --- /dev/null +++ b/docs/changelogs/1.15.13.md @@ -0,0 +1,7 @@ +# Blitzkrieg 1.15.13 + +I have run out of things to say here. This patch fixes like 1 thing haha. + +# Technical Fixes + +- Tank deltas are now filtered by last battle time diff --git a/docs/changelogs/1.15.9.md b/docs/changelogs/1.15.9.md new file mode 100644 index 000000000..f3c58e41e --- /dev/null +++ b/docs/changelogs/1.15.9.md @@ -0,0 +1,13 @@ +# Blitzkrieg 1.15.9 + +~~Curiosity killed the cat~~ Ratings killed the Blitzkrieg + +## Fixes + +- Deleted ratings players are now handled properly + +## Technical Fixes + +- Latest archived season number + - Is now cached properly + - Is now fetched in a chaining fashion correctly diff --git a/docs/guide/permissions.md b/docs/guide/permissions.md deleted file mode 100644 index 5a21542e3..000000000 --- a/docs/guide/permissions.md +++ /dev/null @@ -1,7 +0,0 @@ -# Permissions - -The following Discord permissions are required by the bot to ensure full functionality: - -- Manage nicknames -- Read messages / view channels -- Attach files diff --git a/package.json b/package.json index 363a0cd27..4d98da2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitzkrieg", - "version": "1.15.8", + "version": "1.15.13", "description": "🎉 Tools for everything World of Tanks Blitz", "main": "src/server.ts", "type": "module", diff --git a/public/google7348c0b85956d981.html b/public/google7348c0b85956d981.html new file mode 100644 index 000000000..4d4a98af4 --- /dev/null +++ b/public/google7348c0b85956d981.html @@ -0,0 +1 @@ +google-site-verification: google7348c0b85956d981.html \ No newline at end of file diff --git a/src/app/embeds/session/page.tsx b/src/app/embeds/session/page.tsx index 49c1eb83f..c4b81a6f9 100644 --- a/src/app/embeds/session/page.tsx +++ b/src/app/embeds/session/page.tsx @@ -13,14 +13,21 @@ import { tankAverages } from '../../../core/blitzstars/tankAverages'; import calculateWN8 from '../../../core/statistics/calculateWN8'; import { deltaTankStats } from '../../../core/statistics/deltaTankStats'; import getWN8Percentile from '../../../core/statistics/getWN8Percentile'; -import { resetSession, useSession } from '../../../stores/session'; +import mutateSession, { + resetSession, + useSession, +} from '../../../stores/session'; import { IndividualTankStats, TanksStats } from '../../../types/tanksStats'; import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn'; import { Menu } from '../../tools/session/components/Menu'; const REFRESH_RATE = 1 / 5; -export default function SessionPage() { +interface SessionPageProps { + naked?: boolean; +} + +export default function SessionPage({ naked = true }: SessionPageProps) { const session = useSession(); const since = session.isTracking ? new Date(session.time) : undefined; const [diff, setDiff] = useState< @@ -71,6 +78,14 @@ export default function SessionPage() { 0, ) / careerBattles; + useEffect(() => { + if (session.title === 'Untitled session') { + mutateSession((draft) => { + if (draft.isTracking) draft.title = draft.nickname; + }); + } + }, [session.title]); + useEffect(() => { async function recalculateDiff() { if (session.isTracking) { @@ -113,7 +128,7 @@ export default function SessionPage() { list: ( Object.values( - deltaTankStats(session.tankStats, career), + deltaTankStats(session.tankStats, career, session.time / 1000), ) as IndividualTankStats[] ).map((stats) => { return { @@ -206,6 +221,7 @@ export default function SessionPage() { since !== undefined && diff !== undefined && ( - + ); } diff --git a/src/commands/about.ts b/src/commands/about.ts index 94b60fbf0..fd97d6ed7 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -6,7 +6,6 @@ import { COMMANDS_RAW, CommandRegistry } from '../events/interactionCreate'; const RAW_PATH = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/`; const DOCS = { - permissions: 'guide/permissions', embeds: 'guide/embeds', introduction: 'guide/introduction', invite: 'guide/invite', @@ -14,7 +13,6 @@ const DOCS = { creators: 'guide/creators', }; const DOC_DESCRIPTIONS: Record = { - permissions: 'The permissions needed for the bot to function', embeds: 'How embeds work', introduction: 'About the bot', invite: 'How to invite the bot', diff --git a/src/commands/breakdown.tsx b/src/commands/breakdown.tsx index 725ea64c7..ce0a90f73 100644 --- a/src/commands/breakdown.tsx +++ b/src/commands/breakdown.tsx @@ -13,7 +13,7 @@ import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; 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 autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -33,12 +33,12 @@ import resolvePlayerFromCommand, { import calculateWN8 from '../core/statistics/calculateWN8'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; import getWN8Percentile from '../core/statistics/getWN8Percentile'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; const ROWS_PER_PAGE = 8; const MAX_PAGES = 10; -async function render( +export async function renderBreakdown( { region, id }: ResolvedPlayer, { start, end, name }: ResolvedPeriod, filters: StatFilters, @@ -290,9 +290,9 @@ async function render( } } -export const breakdownCommand = new Promise( +export const breakdownCommand = new Promise( async (resolve) => { - const command = await addFilterOptions( + const command = await addPeriodicFilterOptions( new SlashCommandBuilder() .setName('breakdown') .setDescription("A period's breakdown by tanks played"), @@ -316,7 +316,7 @@ export const breakdownCommand = new Promise( }); return [ - ...(await render(player, period, filters)), + ...(await renderBreakdown(player, period, filters)), buttonPrimary(path, 'Refresh'), await getBlitzStarsLinkButton(player.region, player.id), ]; @@ -332,7 +332,7 @@ export const breakdownCommand = new Promise( const period = resolvePeriodFromButton(player.region, interaction); const filters = getFiltersFromButton(interaction); - return await render(player, period, filters); + return await renderBreakdown(player, period, filters); }, }); }, diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 70ac62ad9..ef768d4d6 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -13,7 +13,7 @@ export const debugCommand: CommandRegistry = { .setName('debug') .setDescription('Debug information about the bot'), - async handler(interaction) { + async handler() { const currentTime = new Date().getTime(); const uptime = currentTime - executionStart; const list = [ diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx index b495076e4..5b8cbd213 100644 --- a/src/commands/evolution.tsx +++ b/src/commands/evolution.tsx @@ -29,7 +29,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; import resolvePlayerFromCommand, { ResolvedPlayer, } from '../core/discord/resolvePlayerFromCommand'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; type EvolutionStatType = 'player' | 'tank'; @@ -107,7 +107,7 @@ async function render( ); } -export const evolutionCommand = new Promise( +export const evolutionCommand = new Promise( async (resolve) => { let playerGroup: SlashCommandSubcommandGroupBuilder; let tankGroup: SlashCommandSubcommandGroupBuilder; diff --git a/src/commands/fullStats.tsx b/src/commands/fullStats.tsx index f9262602a..01ca868a8 100644 --- a/src/commands/fullStats.tsx +++ b/src/commands/fullStats.tsx @@ -10,7 +10,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; 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 autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -29,7 +29,7 @@ import resolvePlayerFromCommand, { } from '../core/discord/resolvePlayerFromCommand'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { getTierWeights } from '../core/statistics/getTierWeights'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; async function render( { region, id }: ResolvedPlayer, @@ -64,9 +64,9 @@ async function render( ); } -export const fullStatsCommand = new Promise( +export const fullStatsCommand = new Promise( async (resolve) => { - const command = await addFilterOptions( + const command = await addPeriodicFilterOptions( new SlashCommandBuilder() .setName('full-stats') .setDescription('Full in-game statistics'), diff --git a/src/commands/link.tsx b/src/commands/link.tsx index 9db82aaae..46110c6ed 100644 --- a/src/commands/link.tsx +++ b/src/commands/link.tsx @@ -1,25 +1,21 @@ import { teal } from '@radix-ui/colors'; -import { - AttachmentBuilder, - GuildMemberRoleManager, - SlashCommandBuilder, -} from 'discord.js'; +import { GuildMemberRoleManager, SlashCommandBuilder } from 'discord.js'; import markdownEscape from 'markdown-escape'; import discord from '../../discord.json' assert { type: 'json' }; import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow'; 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 { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; +import embedInfo from '../core/discord/embedInfo'; import embedNegative from '../core/discord/embedNegative'; +import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; import { theme } from '../stitches.config'; -const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/; - export const verifyCommand: CommandRegistry = { inProduction: true, inPublic: true, @@ -30,193 +26,183 @@ export const verifyCommand: CommandRegistry = { .addStringOption((option) => addUsernameChoices(option).setRequired(true)), 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)) { - 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, id); - await linkBlitzAndDiscord(discordId, region as Region, id); - - if (interaction.guildId === discord.sklld_guild_id) { - if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { - return embedNegative( - `${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, + if (interaction.guildId === discord.sklld_guild_id) { + if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { + return embedNegative( + `${markdownEscape(interaction.user.username)} failed to verify`, + "I don't have the permission to change your manage roles.", ); } - return ( - + await (interaction.member!.roles as GuildMemberRoleManager).remove( + discord.sklld_verify_role, + ); + await (interaction.member!.roles as GuildMemberRoleManager).add( + discord.sklld_peasant_role, + ); + } + + return [ + +
+ +
- +
+ + +
+ + {interaction.user.displayName} + + + @{interaction.user.username} + +
+
-
+ /> + Accounts linked +
+ +
+ {clanAccountInfo?.clan ? ( - -
- - {interaction.user.displayName} - - - @{interaction.user.username} - -
-
- + ) : null}
- - Accounts linked -
- -
- {clanAccountInfo?.clan ? ( - - ) : null} -
- - {accountInfo.nickname} - - - {clanAccountInfo?.clan - ? `[${clanAccountInfo?.clan?.tag}] - ` - : ''} - {REGION_NAMES_SHORT[region as Region]} - -
+ {accountInfo.nickname} + + + {clanAccountInfo?.clan + ? `[${clanAccountInfo?.clan?.tag}] - ` + : ''} + {REGION_NAMES[region]} +
- -
- - ); - } else { - return [ - embedNegative( - 'Please select from the options', - 'Follow the procedure like the video above. The search results may take a while to show up.', - ), - new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'), - ]; - } + + +
+
, + + embedInfo( + 'Incorrect account?', + 'Use the command again and select a username from the search results.', + ), + ]; }, autocomplete: autocompleteUsername, diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts new file mode 100644 index 000000000..b8f29f563 --- /dev/null +++ b/src/commands/permissions.ts @@ -0,0 +1,42 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { CommandRegistry } from '../events/interactionCreate'; + +export const permissionsCommand: CommandRegistry = { + inProduction: true, + inPublic: true, + + command: new SlashCommandBuilder() + .setName('permissions') + .setDescription('Checks wether the bot has the required permissions'), + + async handler(interaction) { + // friday the 13th permissions easter egg + const isFridayThe13th = + new Date().getDay() === 5 && new Date().getDate() === 13; + const permissions = [ + [ + interaction.appPermissions?.has('ViewChannel'), + 'View channels: needed for the refresh button to work', + ], + [ + interaction.appPermissions?.has('ReadMessageHistory'), + 'Read message history: also needed for the refresh button to work', + ], + [ + interaction.appPermissions?.has('AttachFiles'), + 'Attach files: needed for image-based commands to work', + ], + ]; + const body = permissions + .map(([hasPermission, description]) => { + if (hasPermission) { + return `${isFridayThe13th ? '😇' : '✅'} ${description}`; + } else { + return `${isFridayThe13th ? '💀' : '❌'} ${description}`; + } + }) + .join('\n'); + + return `# Permissions\n${body}`; + }, +}; diff --git a/src/commands/ratings.tsx b/src/commands/ratings.tsx index 187972163..73564f0ef 100644 --- a/src/commands/ratings.tsx +++ b/src/commands/ratings.tsx @@ -2,9 +2,9 @@ import { APIApplicationCommandOptionChoice, SlashCommandBuilder, SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, } from 'discord.js'; import { range } from 'lodash'; +import markdownEscape from 'markdown-escape'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import * as Leaderboard from '../components/Leaderboard'; import TitleBar from '../components/TitleBar'; @@ -17,16 +17,16 @@ import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import getRatingsInfo from '../core/blitz/getRatingsInfo'; import { getRatingsLeague } from '../core/blitz/getRatingsLeague'; import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors'; +import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason'; import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber'; import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo'; import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard'; import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard'; import addRegionChoices from '../core/discord/addRegionChoices'; -import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import embedNegative from '../core/discord/embedNegative'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; import { UserError } from '../hooks/userError'; export interface RatingsPlayer { @@ -116,18 +116,22 @@ export interface RatingsNeighbors { neighbors: RatingsPlayer[]; } -function addSubcommands( - option: SlashCommandSubcommandGroupBuilder, - addOptions = (option: SlashCommandSubcommandBuilder) => option, -) { - return option - .addSubcommand((option) => - addOptions( - option - .setName('neighbors') - .setDescription('Your position and neighbors'), - ) - .addStringOption(addUsernameChoices) +const noOngoingSeason = embedNegative( + 'No ongoing season', + "Wargaming didn't provide any data for this season.", +); + +export const ratingsCommand = new Promise( + async (resolve) => { + const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber(); + const onGoingSeason = await isOnGoingRatingsSeason(); + const seasonNumbers = range( + FIRST_ARCHIVED_RATINGS_SEASON, + latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1, + ); + + function addSubcommands(option: SlashCommandSubcommandBuilder) { + return option .addIntegerOption((option) => option .setName('limit') @@ -135,77 +139,57 @@ function addSubcommands( .setRequired(false) .setMinValue(5) .setMaxValue(30), - ), - ) - .addSubcommand((option) => - addOptions(option.setName('league').setDescription('Top league players')) + ) .addStringOption((option) => option - .setName('league') - .setDescription('The league to display') - .addChoices( - ...LEAGUES.map( - ({ name }, value) => - ({ - name, - value: `${value}`, - }) satisfies APIApplicationCommandOptionChoice, - ), - ) - .setRequired(true), - ) - .addStringOption(addRegionChoices) - .addIntegerOption((option) => - option - .setName('limit') - .setDescription('How many rows to display (default: 10)') + .setName('season') + .setDescription('Season number') .setRequired(false) - .setMinValue(5) - .setMaxValue(30), - ), - ); -} - -const noOngoingSeason = embedNegative( - 'No ongoing season', - "Wargaming didn't provide any data for this season.", -); - -export const ratingsCommand = new Promise( - async (resolve) => { - const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber(); - const seasonChoices = range( - FIRST_ARCHIVED_RATINGS_SEASON, - latestArchivedSeasonNumber + 1, - ).map( - (number) => - ({ - name: `Season ${number}`, - value: `${number}`, - }) satisfies APIApplicationCommandOptionChoice, - ); + .addChoices( + ...seasonNumbers + .map( + (number, index) => + ({ + name: `${number}${index === seasonNumbers.length - 1 && onGoingSeason ? ' (current)' : ''}`, + value: `${number}`, + }) satisfies APIApplicationCommandOptionChoice, + ) + .reverse(), + ), + ); + } const command = new SlashCommandBuilder() .setName('ratings') .setDescription('Ratings battles statistics') - .addSubcommandGroup((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) => + .addSubcommand((option) => addSubcommands( option - .setName('current') - .setDescription('The current running Ratings season'), + .setName('neighbors') + .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, + ), + ) + .setRequired(true), + ), ), ); @@ -224,18 +208,19 @@ export const ratingsCommand = new Promise( nickname: string; } - const subcommandGroup = interaction.options.getSubcommandGroup(true) as - | 'archive' - | 'current'; const subcommand = interaction.options.getSubcommand(true) as | 'league' | 'neighbors'; const limit = interaction.options.getInteger('limit') ?? 10; 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 titleImage: string | undefined; - let titleNameDiscriminator: string | undefined; let titleDescription: string; let playersBefore: number; let playersAfter: number; @@ -249,69 +234,66 @@ export const ratingsCommand = new Promise( interaction.options.getString('league')!, ); const region = interaction.options.getString('region') as Region; - regionRatingsInfo = await (subcommandGroup === 'current' + regionRatingsInfo = await (isCurrentSeason ? getRatingsInfo(region) : getArchivedRatingsInfo(region, season!)); if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; - const result = - subcommandGroup === 'current' - ? await getRatingsLeague(region, leagueIndex).then( - async (result) => - result.slice(0, limit).map( - (player) => - ({ - id: player.spa_id, - score: player.score, - position: player.number, - clan: player.clan_tag, - nickname: player.nickname, - }) satisfies SimplifiedPlayer, - ), - ) - : await getArchivedRatingsLeaderboard(region, season!).then( - async (players) => { - const firstPlayerIndex = - leagueIndex === 0 - ? 0 - : players.findIndex( - (player) => - player.score < LEAGUES[leagueIndex - 1].minScore, - ); - const lastPlayerIndex = firstPlayerIndex + limit - 1; - const trimmed = players.slice( - firstPlayerIndex, - lastPlayerIndex + 1, - ); - const clanData = await getClanAccountInfo( - region, - trimmed.map(({ id }) => id), - ['clan'], - ); + const result = isCurrentSeason + ? await getRatingsLeague(region, leagueIndex).then(async (result) => + result.slice(0, limit).map( + (player) => + ({ + id: player.spa_id, + score: player.score, + position: player.number, + clan: player.clan_tag, + nickname: player.nickname, + }) satisfies SimplifiedPlayer, + ), + ) + : await getArchivedRatingsLeaderboard(region, season!).then( + async (players) => { + const firstPlayerIndex = + leagueIndex === 0 + ? 0 + : players.findIndex( + (player) => + player.score < LEAGUES[leagueIndex - 1].minScore, + ); + const lastPlayerIndex = firstPlayerIndex + limit - 1; + const trimmed = players.slice( + firstPlayerIndex, + lastPlayerIndex + 1, + ); + const clanData = await getClanAccountInfo( + region, + trimmed.map(({ id }) => id), + ['clan'], + ); - return trimmed.map( - (player, index) => - ({ - id: player.id, - score: player.score, - position: firstPlayerIndex + index + 1, - clan: clanData[player.id]?.clan?.tag, - nickname: - clanData[player.id]?.account_name ?? - `Unknown Player ${player.id}`, - }) satisfies SimplifiedPlayer, - ); - }, - ); + return trimmed.map( + (player, index) => + ({ + id: player.id, + score: player.score, + position: firstPlayerIndex + index + 1, + clan: clanData[player.id]?.clan?.tag, + nickname: + clanData[index]?.account_name ?? + `Deleted ${player.id}`, + }) satisfies SimplifiedPlayer, + ); + }, + ); const leagueInfo = regionRatingsInfo.leagues[leagueIndex]; - midnightLeaderboard = - subcommandGroup === 'current' - ? await getArchivedRatingsMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ) - : await getArchivedRatingsMidnightLeaderboard(region, season!); + midnightLeaderboard = isCurrentSeason + ? await getArchivedRatingsMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) + : await getArchivedRatingsMidnightLeaderboard(region, season!); players = result; playersBefore = result ? result[0].position - 1 : 0; @@ -326,22 +308,17 @@ export const ratingsCommand = new Promise( titleDescription = `Top ${limit} players`; } else { const { region, id } = await resolvePlayerFromCommand(interaction); - regionRatingsInfo = - subcommandGroup === 'current' - ? await getRatingsInfo(region) - : await getArchivedRatingsInfo(region, season!); + regionRatingsInfo = isCurrentSeason + ? await getRatingsInfo(region) + : await getArchivedRatingsInfo(region, season!); if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; const accountInfo = await getAccountInfo(region, id); const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; - const neighbors = - subcommandGroup === 'current' - ? await getRatingsNeighbors( - region, - id, - Math.round(limit / 2), - ).then((players) => + const neighbors = isCurrentSeason + ? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then( + (players) => players.neighbors.map( (player) => ({ @@ -352,52 +329,47 @@ export const ratingsCommand = new Promise( nickname: player.nickname, }) satisfies SimplifiedPlayer, ), - ) - : await getArchivedRatingsLeaderboard(region, season!).then( - async (players) => { - const playerIndex = players.findIndex( - (player) => player.id === id, + ) + : await getArchivedRatingsLeaderboard(region, season!).then( + async (players) => { + const playerIndex = players.findIndex( + (player) => player.id === id, + ); + + if (playerIndex === -1) { + throw new UserError( + `# ${markdownEscape(accountInfo.nickname)} didn't play ratings in season ${season}\nThis player did not participate in this season or did not get past calibration.`, ); + } - if (playerIndex === -1) { - throw new UserError( - `${accountInfo.nickname} didn't player ratings in season ${season}`, - { - cause: - 'This player did not participate in this season or did not get past calibration.', - }, - ); - } + const halfRange = Math.round(limit / 2); + const trimmed = players.slice( + playerIndex - halfRange, + playerIndex + halfRange + 1, + ); + const clanData = await getClanAccountInfo( + region, + trimmed.map((player) => player.id), + ['clan'], + ); - const halfRange = Math.round(limit / 2); - const trimmed = players.slice( - playerIndex - halfRange, - playerIndex + halfRange + 1, - ); - const clanData = await getClanAccountInfo( - region, - trimmed.map((player) => player.id), - ['clan'], - ); - - return trimmed.map((player, index) => ({ - id: player.id, - score: player.score, - position: playerIndex + index + 1, - clan: clanData[player.id]!.clan?.tag, - nickname: clanData[player.id]!.account_name, - })); - }, - ); - midnightLeaderboard = - subcommandGroup === 'current' - ? await getArchivedRatingsMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ) - : await getArchivedRatingsMidnightLeaderboard(region, season!); - - if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`; + return trimmed.map((player, index) => ({ + id: player.id, + score: player.score, + position: playerIndex + index + 1, + clan: clanData[index]?.clan?.tag, + nickname: clanData[index] + ? clanData[index]!.account_name + : `Deleted ${player.id}`, + })); + }, + ); + midnightLeaderboard = isCurrentSeason + ? await getArchivedRatingsMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) + : await getArchivedRatingsMidnightLeaderboard(region, season!); players = neighbors; titleImage = clan diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx index 3f2e8cf60..bf6146259 100644 --- a/src/commands/stats.tsx +++ b/src/commands/stats.tsx @@ -9,7 +9,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; 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 autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -27,7 +27,7 @@ import resolvePlayerFromCommand, { ResolvedPlayer, } from '../core/discord/resolvePlayerFromCommand'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; async function render( { region, id }: ResolvedPlayer, @@ -57,48 +57,50 @@ async function render( ); } -export const statsCommand = new Promise(async (resolve) => { - const command = await addFilterOptions( - new SlashCommandBuilder() - .setName('stats') - .setDescription('Regular battles statistics'), - (option) => option.addStringOption(addUsernameChoices), - ); +export const statsCommand = new Promise( + async (resolve) => { + const command = await addPeriodicFilterOptions( + new SlashCommandBuilder() + .setName('stats') + .setDescription('Regular battles statistics'), + (option) => option.addStringOption(addUsernameChoices), + ); - resolve({ - inProduction: true, - inPublic: true, + resolve({ + inProduction: true, + inPublic: true, - command, + command, - async handler(interaction) { - const player = await resolvePlayerFromCommand(interaction); - const period = resolvePeriodFromCommand(player.region, interaction); - const filters = await getFiltersFromCommand(interaction); - const path = commandToURL(interaction, { - ...player, - ...getCustomPeriodParams(interaction), - ...filters, - }); + async handler(interaction) { + const player = await resolvePlayerFromCommand(interaction); + const period = resolvePeriodFromCommand(player.region, interaction); + const filters = await getFiltersFromCommand(interaction); + const path = commandToURL(interaction, { + ...player, + ...getCustomPeriodParams(interaction), + ...filters, + }); - return Promise.all([ - render(player, period, filters), - buttonRefresh(interaction, path), - getBlitzStarsLinkButton(player.region, player.id), - ]); - }, + return Promise.all([ + render(player, period, filters), + buttonRefresh(interaction, path), + getBlitzStarsLinkButton(player.region, player.id), + ]); + }, - autocomplete: (interaction) => { - autocompleteUsername(interaction); - autocompleteTanks(interaction); - }, + autocomplete: (interaction) => { + autocompleteUsername(interaction); + autocompleteTanks(interaction); + }, - async button(interaction) { - const player = await resolvePlayerFromButton(interaction); - const period = resolvePeriodFromButton(player.region, interaction); - const filters = getFiltersFromButton(interaction); + async button(interaction) { + const player = await resolvePlayerFromButton(interaction); + const period = resolvePeriodFromButton(player.region, interaction); + const filters = getFiltersFromButton(interaction); - return await render(player, period, filters); - }, - }); -}); + return await render(player, period, filters); + }, + }); + }, +); diff --git a/src/commands/today.ts b/src/commands/today.ts new file mode 100644 index 000000000..81e82405b --- /dev/null +++ b/src/commands/today.ts @@ -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( + 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); + }, + }); + }, +); diff --git a/src/components/AllStatsOverview/components/WN8Display/index.tsx b/src/components/AllStatsOverview/components/WN8Display/index.tsx index 6e8fe8aba..3e33af43b 100644 --- a/src/components/AllStatsOverview/components/WN8Display/index.tsx +++ b/src/components/AllStatsOverview/components/WN8Display/index.tsx @@ -37,7 +37,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) { gap: -16, }} > - +
- + ); } diff --git a/src/components/Breakdown/components/Row.tsx b/src/components/Breakdown/components/Row.tsx index 869fe5875..32d846eea 100644 --- a/src/components/Breakdown/components/Row.tsx +++ b/src/components/Breakdown/components/Row.tsx @@ -18,6 +18,7 @@ export interface RowStatItem { interface RowProps { type?: 'tank' | 'summary'; + naked?: boolean; title: string; minimized?: boolean; treeType?: TreeType; @@ -33,18 +34,23 @@ export function Row({ minimized, tankType, treeType, + naked = false, type, }: RowProps) { - const theme = createColors(PALETTES[`${color}Dark`]); + const palette = PALETTES[`${color}Dark`]; + const theme = createColors(palette); return (
@@ -55,7 +61,9 @@ export function Row({ justifyContent: 'center', padding: 8, gap: 4, - backgroundColor: theme.componentInteractive, + backgroundColor: naked + ? `${theme.componentInteractive}c0` + : theme.componentInteractive, }} > {type === 'tank' && tankType !== undefined && ( diff --git a/src/components/PercentileIndicator.tsx b/src/components/PercentileIndicator.tsx index c0427b61c..25fe22e6c 100644 --- a/src/components/PercentileIndicator.tsx +++ b/src/components/PercentileIndicator.tsx @@ -1,4 +1,5 @@ import { + amber, blue, green, lime, @@ -8,7 +9,6 @@ import { red, slate, teal, - yellow, } from '@radix-ui/colors'; import { Percentile } from '../constants/percentiles'; @@ -20,7 +20,7 @@ export const PERCENTILE_COLORS = { [Percentile.VeryBad]: slate.slate9, [Percentile.Bad]: red.red9, [Percentile.BelowAverage]: orange.orange9, - [Percentile.Average]: yellow.yellow9, + [Percentile.Average]: amber.amber10, [Percentile.AboveAverage]: lime.lime8, [Percentile.Good]: green.green9, [Percentile.VeryGood]: teal.teal9, diff --git a/src/components/Tanks/components/Item.tsx b/src/components/Tanks/components/Item.tsx index bc97a30e8..da22e336f 100644 --- a/src/components/Tanks/components/Item.tsx +++ b/src/components/Tanks/components/Item.tsx @@ -12,7 +12,6 @@ export const TANK_TYPES = [ 'tankDestroyer', ] as const; -// TODO: remove hyphen for underscore export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const; export type TankType = (typeof TANK_TYPES)[number]; diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index b3e4da675..7449da129 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -36,6 +36,8 @@ export default function TitleBar({ name, image, description }: TitleBarProps) { style={{ display: 'flex', gap: 4, + alignItems: 'center', + justifyContent: 'center', }} >
- - {description} - +
+ + {description} + +
diff --git a/src/constants/tools.ts b/src/constants/tools.ts index b311ca9e9..32299b1c5 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -16,6 +16,7 @@ export const TOOLS: Tool[] = [ id: 'tankopedia', title: 'Tankopedia', description: 'Blitz tank encyclopedia', + disabled: true, }, { id: 'ratings', diff --git a/src/core/blitz/getRatingsInfo.ts b/src/core/blitz/getRatingsInfo.ts index 6c77df9f7..ef1616f12 100644 --- a/src/core/blitz/getRatingsInfo.ts +++ b/src/core/blitz/getRatingsInfo.ts @@ -4,7 +4,11 @@ import { context } from '../blitzkrieg/context'; import { patientFetch } from '../blitzkrieg/patientFetch'; import regionToRegionSubdomain from './regionToRegionSubdomain'; +const ratingsInfoCache: Partial> = {}; + export default async function getRatingsInfo(region: Region) { + if (ratingsInfoCache[region]) return ratingsInfoCache[region]!; + const response = await patientFetch( context === 'website' ? `/api/${region}/ratings/current/info` @@ -14,6 +18,9 @@ export default async function getRatingsInfo(region: Region) { undefined, { cache: 'no-store' }, ); + const data = (await response.json()) as RatingsInfo; - return (await response.json()) as Promise; + ratingsInfoCache[region] = data; + + return data; } diff --git a/src/core/blitz/getTankStats.ts b/src/core/blitz/getTankStats.ts index f68736233..be11ffe10 100644 --- a/src/core/blitz/getTankStats.ts +++ b/src/core/blitz/getTankStats.ts @@ -10,10 +10,9 @@ export default async function getTankStats(region: Region, id: number) { ); if (tankStats[id] === null) { - throw new UserError('No tank stats available', { - cause: - "This player doesn't have any stats for tanks available. This may not be the player you are looking for.", - }); + throw new UserError( + "# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.", + ); } return tankStats[id]; diff --git a/src/core/blitz/isOnGoingRatingsSeason.ts b/src/core/blitz/isOnGoingRatingsSeason.ts new file mode 100644 index 000000000..ef6f1e2d5 --- /dev/null +++ b/src/core/blitz/isOnGoingRatingsSeason.ts @@ -0,0 +1,6 @@ +import getRatingsInfo from './getRatingsInfo'; + +export async function isOnGoingRatingsSeason() { + const ratingsInfo = await getRatingsInfo('com'); + return ratingsInfo.detail === undefined; +} diff --git a/src/core/blitz/resolveTankId.ts b/src/core/blitz/resolveTankId.ts index 15bc84f8e..c9d555d98 100644 --- a/src/core/blitz/resolveTankId.ts +++ b/src/core/blitz/resolveTankId.ts @@ -1,5 +1,6 @@ import { go } from 'fuzzysort'; import { UserError } from '../../hooks/userError'; +import markdownEscape from 'markdown-escape'; import { tankDefinitions, tanksDefinitionsArray, @@ -15,9 +16,9 @@ export default async function resolveTankId(tank: string | number) { }); if (searchResult.length === 0) { - throw new UserError('Tank not found', { - cause: `Could not find tank by the name "${tank}".`, - }); + throw new UserError( + `# Tank not found\nCould not find tank by the name "${markdownEscape(`${tank}`)}".`, + ); } else { return searchResult[0].obj.id; } @@ -25,9 +26,9 @@ export default async function resolveTankId(tank: string | number) { if ((await tankDefinitions)[number]) { return number; } else { - throw new UserError('Tank not found', { - cause: `Could not find tank by the ID "${number}".`, - }); + throw new UserError( + `# Tank not found\nCould not find tank by the ID "${number}". Try typing the name of the tank instead of a number.`, + ); } } } diff --git a/src/core/blitzkrieg/getArchivedLatestSeasonNumber.ts b/src/core/blitzkrieg/getArchivedLatestSeasonNumber.ts index ba4a1a808..3e7cca876 100644 --- a/src/core/blitzkrieg/getArchivedLatestSeasonNumber.ts +++ b/src/core/blitzkrieg/getArchivedLatestSeasonNumber.ts @@ -1,17 +1,28 @@ import getRatingsInfo from '../blitz/getRatingsInfo'; +interface GitHubTrees { + tree: { path: string; url: string }[]; +} + let cachedLatestArchivedSeasonNumber: number | null = null; export async function getArchivedLatestSeasonNumber() { + if (cachedLatestArchivedSeasonNumber !== null) { + return cachedLatestArchivedSeasonNumber; + } + const ratingsInfo = await getRatingsInfo('com'); if (ratingsInfo.detail) { - const comURL = ( + const regionsURL = ( (await fetch( - 'https://api.github.com/repos/tresabhi/blitzkrieg-assets/git/trees/main/regions', - ).then((response) => response.json())) as { - tree: { path: string; url: string }[]; - } + 'https://api.github.com/repos/tresabhi/blitzkrieg-assets/git/trees/main', + ).then((response) => response.json())) as GitHubTrees + ).tree.find(({ path }) => path === 'regions')!.url; + const comURL = ( + (await fetch(regionsURL).then((response) => + response.json(), + )) as GitHubTrees ).tree.find(({ path }) => path === 'com')!.url; const ratingsURL = ( (await fetch(comURL).then((response) => response.json())) as { @@ -20,14 +31,16 @@ export async function getArchivedLatestSeasonNumber() { ).tree[0].url; const latestSeason = Math.max( ...( - (await fetch(ratingsURL).then((response) => { - response.json(); - })) as { tree: { path: string }[] } - ).tree.map(({ path }) => parseInt(path)), + (await fetch(ratingsURL).then((response) => response.json())) as { + tree: { path: string }[]; + } + ).tree.map(({ path }) => Number(path)), ); - return latestSeason; + cachedLatestArchivedSeasonNumber = latestSeason; } else { - return ratingsInfo.current_season - 1; + cachedLatestArchivedSeasonNumber = ratingsInfo.current_season - 1; } + + return cachedLatestArchivedSeasonNumber; } diff --git a/src/core/discord/addPeriodicFilterOptions.ts b/src/core/discord/addPeriodicFilterOptions.ts new file mode 100644 index 000000000..7840258d3 --- /dev/null +++ b/src/core/discord/addPeriodicFilterOptions.ts @@ -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 { + const awaitedEncyclopediaInfo = await encyclopediaInfo; + + addPeriodSubCommands(option, (option) => { + return addFilterOptions(extra(option), awaitedEncyclopediaInfo); + }); + + return option; +} diff --git a/src/core/discord/getCustomPeriodParams.ts b/src/core/discord/getCustomPeriodParams.ts index 55b7b8aa0..1b35e96e8 100644 --- a/src/core/discord/getCustomPeriodParams.ts +++ b/src/core/discord/getCustomPeriodParams.ts @@ -3,12 +3,17 @@ import { PeriodType } from './addPeriodSubCommands'; export function getCustomPeriodParams( interaction: ChatInputCommandInteraction, + forcedNonCustomPeriod = false, ) { - if ((interaction.options.getSubcommand() as PeriodType) === 'custom') + if ( + !forcedNonCustomPeriod && + (interaction.options.getSubcommand() as PeriodType) === 'custom' + ) { return { start: interaction.options.getInteger('start', true), end: interaction.options.getInteger('end', true), }; + } return undefined; } diff --git a/src/core/discord/resolveClanFromCommand.ts b/src/core/discord/resolveClanFromCommand.ts index 1e03c9090..1efe29028 100644 --- a/src/core/discord/resolveClanFromCommand.ts +++ b/src/core/discord/resolveClanFromCommand.ts @@ -1,4 +1,5 @@ import { ChatInputCommandInteraction } from 'discord.js'; +import markdownEscape from 'markdown-escape'; import { Region } from '../../constants/regions'; import { UserError } from '../../hooks/userError'; import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions'; @@ -18,9 +19,9 @@ export default async function resolveClanFromCommand( if (accounts[0]) { return { region: accounts[0].region, id: accounts[0].clan_id }; } else { - throw new UserError('Could not find clan', { - cause: `I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`, - }); + throw new UserError( + `# Could not find clan\nCouldn't find "${markdownEscape(clan)}". Try selecting a clan from the search result.`, + ); } } } diff --git a/src/core/discord/resolvePeriodFromCommand.ts b/src/core/discord/resolvePeriodFromCommand.ts index a09da237d..fc09987aa 100644 --- a/src/core/discord/resolvePeriodFromCommand.ts +++ b/src/core/discord/resolvePeriodFromCommand.ts @@ -20,11 +20,14 @@ export interface ResolvedPeriod { export default function resolvePeriodFromCommand( region: Region, interaction: ChatInputCommandInteraction, + forcedPeriod?: PeriodType, ) { let name: string; let start: number; let end: number; - const periodSubcommand = interaction.options.getSubcommand() as PeriodType; + const periodSubcommand = forcedPeriod + ? forcedPeriod + : (interaction.options.getSubcommand() as PeriodType); if (periodSubcommand === 'custom') { const startOption = interaction.options.getInteger('start', true); diff --git a/src/core/discord/resolvePlayerFromCommand.ts b/src/core/discord/resolvePlayerFromCommand.ts index 54e0e2caf..93d6bc3b9 100644 --- a/src/core/discord/resolvePlayerFromCommand.ts +++ b/src/core/discord/resolvePlayerFromCommand.ts @@ -34,11 +34,11 @@ export default async function resolvePlayerFromCommand( id: accounts[0].account_id, } satisfies ResolvedPlayer; } else { - throw new UserError('Could not find user', { - cause: `I couldn't find user "${markdownEscape( + throw new UserError( + `# Could not find user\nI couldn't find user "${markdownEscape( commandUsername, )}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`, - }); + ); } } } else { @@ -47,9 +47,9 @@ export default async function resolvePlayerFromCommand( if (account) { return { region: account.region, id: account.blitz }; } else { - throw new UserError('Use the `/link` command', { - cause: 'Link your Blitz and Discord accounts to get started.', - }); + throw new UserError( + "# I don't know who you are\nUse the `/link` command to get started.", + ); } } } diff --git a/src/core/statistics/deltaTankStats.ts b/src/core/statistics/deltaTankStats.ts index 83c017a43..a1996edbe 100644 --- a/src/core/statistics/deltaTankStats.ts +++ b/src/core/statistics/deltaTankStats.ts @@ -4,12 +4,19 @@ import { emptyIndividualTankStats, } from '../../types/tanksStats'; -export function deltaTankStats(a: NormalizedTankStats, b: NormalizedTankStats) { +export function deltaTankStats( + a: NormalizedTankStats, + b: NormalizedTankStats, + time?: number, +) { const bTanks = Object.keys(b); const changedTanks = bTanks.filter((id) => { return ( a[id as unknown as number]?.all.battles !== - b[id as unknown as number].all.battles + b[id as unknown as number].all.battles && + (time === undefined + ? true + : b[id as unknown as number].last_battle_time > time) ); }); const diff = changedTanks.map((id) => { @@ -24,7 +31,10 @@ export function deltaTankStats(a: NormalizedTankStats, b: NormalizedTankStats) { in_garage: bTank.in_garage, max_xp: Math.max(aTank.max_xp, bTank.max_xp), in_garage_updated: bTank.in_garage_updated, - last_battle_time: bTank.last_battle_time - aTank.last_battle_time, + last_battle_time: Math.max( + bTank.last_battle_time, + aTank.last_battle_time, + ), mark_of_mastery: bTank.mark_of_mastery, tank_id: bTank.tank_id, diff --git a/src/events/interactionCreate/handlers/chatInputCommand.ts b/src/events/interactionCreate/handlers/chatInputCommand.ts index 6cfbd7557..93e93ea4c 100644 --- a/src/events/interactionCreate/handlers/chatInputCommand.ts +++ b/src/events/interactionCreate/handlers/chatInputCommand.ts @@ -10,7 +10,6 @@ import { import { commands } from '..'; import discord from '../../../../discord.json' assert { type: 'json' }; import buttonLink from '../../../core/discord/buttonLink'; -import embedNegative from '../../../core/discord/embedNegative'; import embedWarning from '../../../core/discord/embedWarning'; import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable'; import { psa } from '../../../core/discord/psa'; @@ -19,7 +18,8 @@ import { UserError } from '../../../hooks/userError'; export default async function handleChatInputCommand( interaction: ChatInputCommandInteraction, ) { - const registry = (await commands)[interaction.commandName]; + const awaitedCommands = await commands; + const registry = awaitedCommands[interaction.commandName]; await interaction.deferReply(); @@ -118,25 +118,15 @@ export default async function handleChatInputCommand( if (error instanceof UserError) { interaction.editReply({ - embeds: [ - embedNegative( - error.message, - (error.cause as string | undefined) ?? - 'No further information is available.', - ), - ], + content: error.message, components, }); } else { console.error(interaction.commandName, error); interaction.editReply({ - embeds: [ - embedNegative( - 'Blitzkrieg ran into an error!', - "We're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.", - ), - ], + content: + "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.", components, }); } diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts index 0b7aeadcb..390055141 100644 --- a/src/events/interactionCreate/index.ts +++ b/src/events/interactionCreate/index.ts @@ -23,6 +23,7 @@ import { fullStatsCommand } from '../../commands/fullStats'; import { inactiveCommand } from '../../commands/inactive'; import { verifyCommand } from '../../commands/link'; import { ownedTanksCommand } from '../../commands/ownedTanks'; +import { permissionsCommand } from '../../commands/permissions'; import { pingCommand } from '../../commands/ping'; import { playerAchievementsCommand } from '../../commands/playerAchievements'; import { playerInfoCommand } from '../../commands/playerInfo'; @@ -31,6 +32,7 @@ import { searchClansCommand } from '../../commands/searchClans'; import { searchPlayersCommand } from '../../commands/searchPlayers'; import { searchTanksCommand } from '../../commands/searchTanks'; import { statsCommand } from '../../commands/stats'; +import { todayCommand } from '../../commands/today'; import getClientId from '../../core/blitzkrieg/getClientId'; import isDev from '../../core/blitzkrieg/isDev'; import { secrets } from '../../core/blitzkrieg/secrets'; @@ -52,38 +54,44 @@ export type InteractionReturnable = | InteractionIterableReturnable | Promise; -export interface Registry { - inProduction: boolean; -} - -export type CommandRegistryRaw = Registry & { - inPublic: boolean; - inPreview?: boolean; - +type CommandRegistryBase = { command: | SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | Omit; +}; +type CommandRegistryDefinitionBase = { + inProduction: boolean; + inPublic: boolean; + inPreview?: boolean; autocomplete?: (interaction: AutocompleteInteraction) => void; button?: (interaction: ButtonInteraction) => InteractionReturnable; -} & ( - | { - handlesInteraction: true; - handler: (interaction: ChatInputCommandInteraction) => void; - } - | { - handlesInteraction?: false; - handler: ( - interaction: ChatInputCommandInteraction, - ) => InteractionReturnable; - } - ); -export type CommandRegistry = CommandRegistryRaw | Promise; +}; +type CommandRegistryDefinitionHandlesInteraction = { + handlesInteraction: true; + handler: (interaction: ChatInputCommandInteraction) => void; +}; +type CommandRegistryDefinitionDoesHandleInteraction = { + handlesInteraction?: false; + handler: ( + interaction: ChatInputCommandInteraction, + ) => InteractionReturnable; +}; +export type CommandRegistryPromisable = CommandRegistryBase & + (CommandRegistryDefinitionBase & + ( + | CommandRegistryDefinitionHandlesInteraction + | CommandRegistryDefinitionDoesHandleInteraction + )); +export type CommandRegistry = + | CommandRegistryPromisable + | Promise; const rest = new REST().setToken(secrets.DISCORD_TOKEN); export const COMMANDS_RAW: CommandRegistry[] = [ + permissionsCommand, debugCommand, eligibleCommand, aboutCommand, @@ -101,10 +109,11 @@ export const COMMANDS_RAW: CommandRegistry[] = [ evolutionCommand, statsCommand, ratingsCommand, + todayCommand, ]; export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) => - rawCommands.reduce>( + rawCommands.reduce>( (accumulator, registry) => { if (isDev()) registry.command.setDefaultMemberPermissions(0); @@ -123,7 +132,7 @@ export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; commands.then((awaitedCommands) => { - Object.entries(awaitedCommands).forEach(([, registry]) => { + Object.values(awaitedCommands).forEach((registry) => { const json = registry.command.toJSON(); if (registry.inPublic) { diff --git a/src/hooks/userError.ts b/src/hooks/userError.ts index 06b0ee65c..44a647f15 100644 --- a/src/hooks/userError.ts +++ b/src/hooks/userError.ts @@ -1,4 +1,6 @@ /** * Discriminator for user caused errors to not report in logs */ -export class UserError extends Error {} +export class UserError { + constructor(public message: string) {} +}