diff --git a/docs/changelogs/1.12.3.md b/docs/changelogs/1.12.3.md new file mode 100644 index 000000000..ffe67eb72 --- /dev/null +++ b/docs/changelogs/1.12.3.md @@ -0,0 +1,13 @@ +# Blitzkrieg 1.12.3 + +3-day downtime fixed! + +## Changes + +- Split `/ratings` into `/ratings current` and `/ratings archive` + - `current` now properly handles when there is no ongoing ratings season + - `archive` displays older archived seasons + +## Fixes + +- The bot is now no ratings seasons tolerant diff --git a/src/commands/ratings.tsx b/src/commands/ratings.tsx index 4e56a16ec..620df28bb 100644 --- a/src/commands/ratings.tsx +++ b/src/commands/ratings.tsx @@ -1,21 +1,28 @@ import { APIApplicationCommandOptionChoice, SlashCommandBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, } from 'discord.js'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import * as Leaderboard from '../components/Leaderboard'; import TitleBar from '../components/TitleBar'; import Wrapper from '../components/Wrapper'; import { REGION_NAMES_SHORT, Region } from '../constants/regions'; +import getArchivedRatingsInfo from '../core/blitz/getArchivedRatingsInfo'; import getRatingsInfo from '../core/blitz/getRatingsInfo'; import getWargamingResponse from '../core/blitz/getWargamingResponse'; import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain'; -import getMidnightLeaderboard from '../core/database/getMidnightLeaderboard'; +import getMidnightLeaderboard, { + DATABASE_REPO, +} from '../core/database/getMidnightLeaderboard'; import addRegionChoices from '../core/discord/addRegionChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import { octokit } from '../core/express/octokit'; import { secrets } from '../core/node/secrets'; +import throwError from '../core/node/throwError'; import { CommandRegistryRaw } from '../events/interactionCreate'; import { AccountInfo } from '../types/accountInfo'; import { PlayerClanData } from '../types/playerClanData'; @@ -86,78 +93,127 @@ export interface RatingsLeague { percentile: number; } -export interface RatingsInfo { - title: string; - icon: string | null; - start_at: string; - finish_at: string; - current_season: number; - updated_at: string; - count: number; +export type RatingsInfo = + | { + detail: undefined; - rewards: RatingsReward[]; - leagues: RatingsLeague[]; -} + title: string; + icon: string | null; + start_at: string; + finish_at: string; + current_season: number; + updated_at: string; + count: number; + + rewards: RatingsReward[]; + leagues: RatingsLeague[]; + } + | { detail: { error: string } }; export interface RatingsNeighbors { neighbors: RatingsPlayer[]; } -console.log('Caching ratings info...'); -const ratingsInfo = getRatingsInfo('com').then((info) => { - console.log('Cached ratings info'); - return info; -}); +const LEAGUES = [ + { name: 'Diamond', minScore: 5000 }, + { name: 'Platinum', minScore: 4000 }, + { name: 'Gold', minScore: 3000 }, + { name: 'Silver', minScore: 2000 }, + { name: 'Bronze', minScore: 0 }, +]; + +function addSubcommands( + option: SlashCommandSubcommandGroupBuilder, + addOptions = (option: SlashCommandSubcommandBuilder) => option, +) { + return option + .addSubcommand((option) => + addOptions( + option + .setName('neighbours') + .setDescription('Your position and neighbours'), + ) + .addStringOption(addUsernameChoices) + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('How many neighbours to display (default: 10)') + .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)') + .setRequired(false) + .setMinValue(5) + .setMaxValue(30), + ), + ); +} export const ratingsCommand = new Promise( async (resolve) => { - const awaitedRatingsInfo = await ratingsInfo; + const seasonChoices = await octokit.repos + .getContent({ + ...DATABASE_REPO, + path: 'com/ratings', + }) + .then(({ data }) => { + if (!Array.isArray(data)) { + throw throwError('Archived ratings data is malformed'); + } + return data.map( + (folder) => + ({ + name: `Season ${folder.name}`, + value: folder.name, + }) satisfies APIApplicationCommandOptionChoice, + ); + }); const command = new SlashCommandBuilder() .setName('ratings') .setDescription('Ratings battles statistics') - .addSubcommand((option) => - option - .setName('league') - .setDescription('Top league players') - .addStringOption((option) => - option - .setName('league') - .setDescription('The league to display') - .addChoices( - ...awaitedRatingsInfo.leagues.map( - (league) => - ({ - name: league.title.split(' ')[0].toLowerCase(), - value: `${league.index}`, - }) satisfies APIApplicationCommandOptionChoice, - ), - ) - .setRequired(true), - ) - .addStringOption(addRegionChoices) - .addIntegerOption((option) => - option - .setName('limit') - .setDescription('How many rows to display (default: 10)') - .setRequired(false) - .setMinValue(5) - .setMaxValue(30), - ), + .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), + ), + ), ) - .addSubcommand((option) => - option - .setName('neighbours') - .setDescription('Your position and neighbours') - .addStringOption(addUsernameChoices) - .addIntegerOption((option) => - option - .setName('limit') - .setDescription('How many neighbours to display (default: 10)') - .setRequired(false) - .setMinValue(5) - .setMaxValue(30), - ), + .addSubcommandGroup((option) => + addSubcommands( + option + .setName('current') + .setDescription('The current running Ratings season'), + ), ); resolve({ @@ -167,15 +223,30 @@ export const ratingsCommand = new Promise( command, async handler(interaction) { - const subcommand = interaction.options.getSubcommand(true); + interface SimplifiedPlayer { + id: number; + score: number; + position: number; + clan: string | undefined; + nickname: string; + } + + const subcommandGroup = interaction.options.getSubcommandGroup(true) as + | 'archive' + | 'current'; + const subcommand = interaction.options.getSubcommand(true) as + | 'league' + | 'neighbours'; const limit = interaction.options.getInteger('limit') ?? 10; + const seasonOption = interaction.options.getString('season'); + const season = seasonOption ? parseInt(seasonOption) : undefined; let titleName: string; let titleImage: string | undefined; let titleNameDiscriminator: string | undefined; let titleDescription: string; let playersBefore: number; let playersAfter: number; - let players: RatingsPlayer[]; + let players: SimplifiedPlayer[] | undefined; let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined; let playerId: number | undefined; let regionRatingsInfo: RatingsInfo; @@ -185,31 +256,120 @@ export const ratingsCommand = new Promise( interaction.options.getString('league')!, ); const region = interaction.options.getString('region') as Region; - regionRatingsInfo = await getRatingsInfo(region); + regionRatingsInfo = await (subcommandGroup === 'current' + ? getRatingsInfo(region) + : getArchivedRatingsInfo(season!, region)); const regionSubdomain = regionToRegionSubdomain(region); - const result = await fetch( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`, - ) - .then((response) => response.json() as Promise) - .then(({ result }) => result.slice(0, limit)); - const leagueInfo = regionRatingsInfo.leagues[leagueIndex]; - midnightLeaderboard = await getMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ); + const result = + subcommandGroup === 'current' + ? regionRatingsInfo.detail === undefined + ? await fetch( + `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`, + ) + .then((response) => response.json() as Promise) + .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, + ), + ) + : undefined + : await octokit.repos + .getContent({ + ...DATABASE_REPO, + path: `${region}/ratings/${season}/latest.json`, + }) + .then(async ({ data }) => { + if (Array.isArray(data) || data.type !== 'file') { + throw throwError('Archived ratings data is malformed'); + } + + const players = (await fetch(data.download_url!).then( + (response) => response.json(), + )) as BlitzkriegRatingsLeaderboard; + 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 ids = trimmed.map((player) => player.id).join(','); + const clanData = await getWargamingResponse( + `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=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, + }) satisfies SimplifiedPlayer, + ); + }); + const leagueInfo = + regionRatingsInfo.detail === undefined + ? regionRatingsInfo.leagues[leagueIndex] + : undefined; + midnightLeaderboard = + subcommandGroup === 'current' + ? regionRatingsInfo.detail === undefined + ? await getMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) + : undefined + : await getMidnightLeaderboard(region, season!); players = result; - playersBefore = result[0].number - 1; + playersBefore = result ? result[0].position - 1 : 0; playersAfter = - regionRatingsInfo.count - result[result.length - 1].number; - titleName = `${leagueInfo.title} - ${REGION_NAMES_SHORT[region]}`; - titleImage = leagueInfo.big_icon.startsWith('http') - ? leagueInfo.big_icon - : `https:${leagueInfo.big_icon}`; + regionRatingsInfo.detail === undefined && result !== undefined + ? regionRatingsInfo.count - result[result.length - 1].position + : 0; + titleName = `${ + leagueInfo ? leagueInfo.title : 'No Ongoing Season' + } - ${REGION_NAMES_SHORT[region]}`; + titleImage = leagueInfo + ? leagueInfo.big_icon.startsWith('http') + ? leagueInfo.big_icon + : `https:${leagueInfo.big_icon}` + : undefined; titleDescription = `Top ${limit} players`; } else { const { region, id } = await resolvePlayerFromCommand(interaction); - regionRatingsInfo = await getRatingsInfo(region); + regionRatingsInfo = + subcommandGroup === 'current' + ? await getRatingsInfo(region) + : await octokit.repos + .getContent({ + ...DATABASE_REPO, + path: `${region}/ratings/${season}/info.json`, + }) + .then(async ({ data }) => { + if (Array.isArray(data) || data.type !== 'file') { + throw throwError('Archived ratings info is malformed'); + } + + const response = fetch(data.download_url!); + return (await response.then((response) => + response.json(), + )) as RatingsInfo; + }); const accountInfo = await getWargamingResponse( `https://api.wotblitz.${region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`, ); @@ -219,57 +379,139 @@ export const ratingsCommand = new Promise( ) )[id]?.clan; const regionSubdomain = regionToRegionSubdomain(region); - const { neighbors } = await fetch( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round( - limit / 2, - )}`, - ).then((response) => response.json() as Promise); - midnightLeaderboard = await getMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ); + const neighbors = + subcommandGroup === 'current' + ? regionRatingsInfo.detail === undefined + ? await fetch( + `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round( + limit / 2, + )}`, + ) + .then( + (response) => + response.json() as Promise, + ) + .then((players) => + players.neighbors.map( + (player) => + ({ + id: player.spa_id, + position: player.number, + score: player.score, + clan: player.clan_tag, + nickname: player.nickname, + }) satisfies SimplifiedPlayer, + ), + ) + : undefined + : await octokit.repos + .getContent({ + ...DATABASE_REPO, + path: `${region}/ratings/${season}/latest.json`, + }) + .then(async ({ data }) => { + if (Array.isArray(data) || data.type !== 'file') { + throw throwError( + 'Archived ratings latest data is malformed', + ); + } - if (clan) titleNameDiscriminator = `[${clan.tag}]`; + const response = fetch(data.download_url!); + const players = (await response.then((response) => + response.json(), + )) as BlitzkriegRatingsLeaderboard; + const playerIndex = players.findIndex( + (player) => player.id === id, + ); + + if (playerIndex === -1) { + throw throwError( + `${accountInfo[id].nickname} didn't player ratings in season ${season}`, + '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 ids = trimmed.map((player) => player.id).join(','); + const clanData = await getWargamingResponse( + `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=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' + ? regionRatingsInfo.detail === undefined + ? await getMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) + : undefined + : await getMidnightLeaderboard(region, season!); + + if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`; players = neighbors; - titleImage = clan - ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` + titleImage = neighbors + ? clan + ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` + : undefined : undefined; - titleName = accountInfo[id].nickname; + titleName = neighbors + ? accountInfo[id].nickname + : `No Ongoing Season - ${REGION_NAMES_SHORT[region]}`; titleDescription = 'Ratings neighbours'; - playersBefore = neighbors[0].number - 1; + playersBefore = neighbors ? neighbors[0].position - 1 : 0; playersAfter = - regionRatingsInfo.count - neighbors[neighbors.length - 1].number; + regionRatingsInfo.detail === undefined + ? neighbors + ? regionRatingsInfo.count - + neighbors[neighbors.length - 1].position + : 0 + : 0; playerId = id; } - const items = players.map((player) => { - const reward = regionRatingsInfo.rewards.find( - (reward) => - player.number >= reward.from_position && - player.number <= reward.to_position, - ); + const items = players?.map((player) => { + const reward = + regionRatingsInfo.detail === undefined + ? regionRatingsInfo.rewards.find( + (reward) => + player.position >= reward.from_position && + player.position <= reward.to_position, + ) + : undefined; const midnightIndex = midnightLeaderboard?.findIndex( - (item) => item.id === player.spa_id, + (item) => item.id === player.id, ); const midnightScore = midnightIndex === undefined ? player.score : midnightLeaderboard![midnightIndex].score; const midnightPosition = - midnightIndex === undefined ? player.number : midnightIndex + 1; + midnightIndex === undefined ? player.position : midnightIndex + 1; return ( ); }); @@ -283,11 +525,15 @@ export const ratingsCommand = new Promise( description={`${titleDescription} • ${new Date().toDateString()}`} /> - - {playersBefore > 0 && } - {items} - {playersAfter > 0 && } - + {items && ( + + {playersBefore > 0 && ( + + )} + {items} + {playersAfter > 0 && } + + )} ); }, diff --git a/src/core/blitz/getArchivedRatingsInfo.ts b/src/core/blitz/getArchivedRatingsInfo.ts new file mode 100644 index 000000000..40be6a267 --- /dev/null +++ b/src/core/blitz/getArchivedRatingsInfo.ts @@ -0,0 +1,25 @@ +import { RatingsInfo } from '../../commands/ratings'; +import { Region } from '../../constants/regions'; +import { DATABASE_REPO } from '../database/getMidnightLeaderboard'; +import { octokit } from '../express/octokit'; +import throwError from '../node/throwError'; + +export default async function getArchivedRatingsInfo( + season: number, + region: Region, +) { + const { data } = await octokit.repos.getContent({ + ...DATABASE_REPO, + path: `${region}/ratings/${season}/info.json`, + }); + + if (Array.isArray(data) || data.type !== 'file') + throw throwError('Archived ratings info is malformed'); + + const content = Buffer.from(data.content, 'base64').toString(); + const jsonContent = JSON.parse(content) as RatingsInfo & { + detail: undefined; + }; + + return jsonContent; +} diff --git a/src/core/blitz/getRatingsInfo.ts b/src/core/blitz/getRatingsInfo.ts index bf3b809ef..56588cdac 100644 --- a/src/core/blitz/getRatingsInfo.ts +++ b/src/core/blitz/getRatingsInfo.ts @@ -8,5 +8,6 @@ export default async function getRatingsInfo(region: Region) { region, )}.wotblitz.com/en/api/rating-leaderboards/season/`, ); + return (await response.json()) as Promise; } diff --git a/src/core/database/getMidnightLeaderboard.ts b/src/core/database/getMidnightLeaderboard.ts index b8a686a61..87b56d70f 100644 --- a/src/core/database/getMidnightLeaderboard.ts +++ b/src/core/database/getMidnightLeaderboard.ts @@ -15,12 +15,13 @@ export default async function getMidnightLeaderboard( }); if (!Array.isArray(data) && data.type === 'file') { - const content = Buffer.from(data.content, 'base64').toString(); - const jsonContent = JSON.parse(content) as BlitzkriegRatingsLeaderboard; + const response = await fetch(data.download_url!); - return jsonContent; + return (await response.json()) as BlitzkriegRatingsLeaderboard; } } catch (error) { - console.warn('No midnight leaderboard found'); + console.warn( + `No midnight leaderboard found for season ${season}, region ${region}`, + ); } }