diff --git a/.github/workflows/cd-host.yml b/.github/workflows/cd-host.yml index bda094d6d..a87109476 100644 --- a/.github/workflows/cd-host.yml +++ b/.github/workflows/cd-host.yml @@ -2,7 +2,7 @@ name: CD Host on: schedule: - - cron: '0 */5 * * *' + - cron: '0 */6 * * *' workflow_dispatch: concurrency: @@ -29,4 +29,4 @@ jobs: unzip bot.zip - name: Run bot - run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} node --enable-source-maps --trace-warnings index.cjs + run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} node --enable-source-maps index.cjs diff --git a/.vscode/settings.json b/.vscode/settings.json index e63c24f60..c04fd7c61 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "bepaint", "blitzstars", "cleandist", + "cockroackdb", "customperiod", "deburr", "diacriticless", @@ -14,6 +15,7 @@ "kranvagn", "Leaderboard", "leaderboards", + "outdir", "outfile", "ownedtanks", "playerachievements", @@ -42,6 +44,7 @@ "wargaming", "webp", "winrate", + "wotblitz", "zustand" ], "deno.enable": false, diff --git a/discord.json b/discord.json index d169bf4c9..d3ffa5749 100644 --- a/discord.json +++ b/discord.json @@ -8,5 +8,7 @@ "sklld_peasant_role": "896878529512370228", "tres_guild_id": "734786591205359697", - "tres_log_channel": "1109262265850544148" + "tres_log_channel": "1109262265850544148", + + "test_guild_id": "1124729294829142116" } diff --git a/docs/changelogs/1.14.0.md b/docs/changelogs/1.14.0.md new file mode 100644 index 000000000..d1729fe45 --- /dev/null +++ b/docs/changelogs/1.14.0.md @@ -0,0 +1,45 @@ +# Blitzkrieg 1.14.0 + +Elegance has never been met like this before + +## Features + +- Added `/link` + - Unlike `/verify`, `/link` links you Discord and Blitz accounts across all servers + - This command does not change your nickname + - `/link` now simply displays a deprecation message + - Usernames can still be inputted per-command to override your linked account +- `/stats` and `/full-stats` are now way simpler + - `/stats ` has been replaced with `/stats <30|60|90|...>` (likewise, `/full-stats`) + - These commands now have filters without having to opt into `multi-tank` +- Added `/breakdown` + - Unlike `/today`, `/breakdown` lets you select any period of time + - `/breakdown` now simply displays a deprecation message +- `/about commands` are now sorted alphabetically +- Added a `/reset` button to the options dropdown and embed context-menu + +## Changes + +- Simplified no ongoing ratings season edge case +- Updated welcome message for Skilled server to use `/link` and other up-to-date information +- Tier weights are now reported in Arabic numerals instead of Roman +- Removed tank images from `/owned-tanks` + - Wargaming messed up their API and almost no tanks have any images anymore + +## Fixes + +- Banners now use a more economical `.webp` format just like the home page +- Removed unused color palettes + +## Technical changes + +- Guild commands are now handled more efficiently +- Removed nickname-based Blitz username inference +- Code split a lot of common functionalities +- Re-arranged `core` folder +- Added midnight and latest ratings leaderboard caches +- Merged renderers into their primary user files +- Added sharding + - Disabled for now but good for future proofing +- Switched to a way cleaner logging schema +- Hosting is now done in 6 hour periods instead of 5 diff --git a/package.dist.json b/package.dist.json index a4aa082e6..9e205ed74 100644 --- a/package.dist.json +++ b/package.dist.json @@ -2,6 +2,6 @@ "main": "server.js", "type": "module", "scripts": { - "start": "node --enable-source-maps --trace-warnings server.cjs" + "start": "node --enable-source-maps server.cjs" } } diff --git a/package.json b/package.json index 49a527f52..837c75fd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitzkrieg", - "version": "1.13.3", + "version": "1.14.0", "description": "🎉 Tools for everything World of Tanks Blitz", "main": "src/server.ts", "type": "module", @@ -12,7 +12,7 @@ "dev:bot": "yarn build:bot && yarn start:bot", "dev:docs": "vitepress dev docs", "dev:website": "next dev", - "start:bot": "node --enable-source-maps --trace-warnings dist/bot/index.cjs", + "start:bot": "node --enable-source-maps dist/bot/index.cjs", "start:website": "next start", "host:docs": "vitepress preview docs", "publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish", @@ -26,6 +26,7 @@ "license": "MIT", "dependencies": { "@octokit/rest": "^20.0.1", + "@prisma/client": "^5.4.1", "@radix-ui/colors": "^3.0.0-rc.4", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/themes": "^1.1.2", @@ -62,6 +63,7 @@ "compare-versions": "^6.1.0", "knip": "^2.27.1", "prettier": "^3.0.3", + "prisma": "^5.4.1", "typescript": "^5.2.2" } } diff --git a/prisma/migrations/20231009152251_use_big_int/migration.sql b/prisma/migrations/20231009152251_use_big_int/migration.sql new file mode 100644 index 000000000..2fdbde59a --- /dev/null +++ b/prisma/migrations/20231009152251_use_big_int/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "discord_blitz" ( + "discord" INT8 NOT NULL, + "region" STRING NOT NULL, + "blitz" INT4 NOT NULL, + + CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord") +); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..7106c8a84 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "cockroachdb" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 000000000..20719dbc2 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,14 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "cockroachdb" + url = env("DISCORD_BLITZ_DB") +} + +model discord_blitz { + discord BigInt @id + region String + blitz Int +} diff --git a/scripts/build.ts b/scripts/build.ts index 9905dda95..f5deea4fb 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,10 +1,18 @@ import { build } from 'esbuild'; +import { readdir } from 'fs/promises'; -console.log('Building bot...'); +const FILES = [ + 'bot', + 'index', + + (await readdir('src/workers')).map( + (file) => `workers/${file.replace('.ts', '')}`, + ), +]; await build({ - entryPoints: ['src/bot.ts'], - outfile: 'dist/bot/index.cjs', + entryPoints: FILES.map((file) => `src/${file}.ts`), + outdir: 'dist/bot', platform: 'node', tsconfig: 'tsconfig.esbuild.json', @@ -14,6 +22,9 @@ await build({ }, format: 'cjs', jsx: 'transform', + outExtension: { + '.js': '.cjs', + }, bundle: true, sourcemap: true, @@ -23,5 +34,3 @@ await build({ logLevel: 'info', }); - -console.log('Built bot'); diff --git a/src/app/api/tankopedia/route.ts b/src/app/api/tankopedia/route.ts index 665728654..1ee08729c 100644 --- a/src/app/api/tankopedia/route.ts +++ b/src/app/api/tankopedia/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { tankopedia } from '../../../core/blitz/tankopedia'; +import { tankopedia } from '../../../core/blitzstars/tankopedia'; export async function GET() { return NextResponse.json(await tankopedia); diff --git a/src/app/embeds/session/page.tsx b/src/app/embeds/session/page.tsx index 96fadb885..728f84710 100644 --- a/src/app/embeds/session/page.tsx +++ b/src/app/embeds/session/page.tsx @@ -5,13 +5,16 @@ import { useEffect, useState } from 'react'; import * as Breakdown from '../../../components/Breakdown'; import { TreeTypeEnum } from '../../../components/Tanks'; import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; -import calculateWN8 from '../../../core/blitz/calculateWN8'; -import { diffNormalizedTankStats } from '../../../core/blitz/diffNormalizedTankStats'; -import getWN8Percentile from '../../../core/blitz/getWN8Percentile'; -import getWargamingResponse from '../../../core/blitz/getWargamingResponse'; -import { TankopediaEntry, tankopedia } from '../../../core/blitz/tankopedia'; +import fetchBlitz from '../../../core/blitz/fetchWargaming'; import { tankAverages } from '../../../core/blitzstars/tankAverages'; -import { useSession } from '../../../stores/session'; +import { + TankopediaEntry, + tankopedia, +} from '../../../core/blitzstars/tankopedia'; +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 { IndividualTankStats, TanksStats } from '../../../types/tanksStats'; import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn'; import { Menu } from '../../tools/session/components/Menu'; @@ -73,7 +76,7 @@ export default function SessionPage() { async function recalculateDiff() { if (session.isTracking) { const { id, region } = session; - const careerRaw = await getWargamingResponse( + const careerRaw = await fetchBlitz( `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, ); const career = careerRaw[id].reduce< @@ -111,7 +114,7 @@ export default function SessionPage() { list: ( Object.values( - diffNormalizedTankStats(session.tankStats, career), + deltaTankStats(session.tankStats, career), ) as IndividualTankStats[] ).map((stats) => { return { @@ -321,7 +324,7 @@ export default function SessionPage() { - + ); } diff --git a/src/app/tools/layout.tsx b/src/app/tools/layout.tsx index 348899b48..2f281b63f 100644 --- a/src/app/tools/layout.tsx +++ b/src/app/tools/layout.tsx @@ -19,17 +19,32 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
+
+ void; } -export function Menu({ Builder }: ContextMenuProps) { +export function Menu({ Builder, reset }: ContextMenuProps) { const session = useSession(); return ( @@ -34,6 +35,12 @@ export function Menu({ Builder }: ContextMenuProps) { > Show career + + + + + Reset + ); } diff --git a/src/app/tools/session/page.tsx b/src/app/tools/session/page.tsx index 906988e7f..a6eb1e323 100644 --- a/src/app/tools/session/page.tsx +++ b/src/app/tools/session/page.tsx @@ -14,19 +14,15 @@ import { Flex, Text, TextField, - Tooltip, } from '@radix-ui/themes'; import { debounce } from 'lodash'; -import { ChangeEvent, useRef, useState } from 'react'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; import PageWrapper from '../../../components/PageWrapper'; -import { REGION_NAMES, Region } from '../../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; -import getWargamingResponse from '../../../core/blitz/getWargamingResponse'; -import listPlayers, { +import { REGION_NAMES } from '../../../constants/regions'; +import searchPlayersAcrossRegions, { AccountListWithServer, -} from '../../../core/blitz/listPlayers'; -import { useSession } from '../../../stores/session'; -import { NormalizedTankStats, TanksStats } from '../../../types/tanksStats'; +} from '../../../core/blitz/searchPlayersAcrossRegions'; +import { resetSession, setSession, useSession } from '../../../stores/session'; import SessionPage from '../../embeds/session/page'; import { Menu } from './components/Menu'; import * as styles from './page.css'; @@ -40,7 +36,7 @@ export default function Page() { const handleChange = debounce( async (event: ChangeEvent) => { if (event.target.value) { - setSearchResults(await listPlayers(event.target.value)); + setSearchResults(await searchPlayersAcrossRegions(event.target.value)); } else { setSearchResults(undefined); setShowSearchResults(false); @@ -50,37 +46,9 @@ export default function Page() { ); const session = useSession(); - async function setSession(region: Region, id: number, nickname: string) { - input.current!.value = nickname; - - const rawTankStats = ( - await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ) - )[id]; - const tankStats = rawTankStats.reduce( - (accumulator, tank) => ({ - ...accumulator, - [tank.tank_id]: tank, - }), - {}, - ); - - useSession.setState({ - isTracking: true, - id, - region, - nickname, - tankStats, - time: Date.now(), - }); - } - - function reset() { - const session = useSession.getState(); - if (!session.isTracking) return; - setSession(session.region, session.id, session.nickname); - } + useEffect(() => { + if (session.isTracking) input.current!.value = session.nickname; + }, [session]); return ( @@ -158,7 +126,7 @@ export default function Page() { onClick={(event) => { if (!session.promptBeforeReset) { event.preventDefault(); - reset(); + resetSession(); } }} className={styles.toolbarButton} @@ -201,7 +169,7 @@ export default function Page() { - @@ -209,19 +177,60 @@ export default function Page() { - - - + + + + + + + + Copied link to your clipboard + + + Keep in mind: +
    +
  • Paste this link into your browser to view the embed
  • +
  • Embeds abide your custom options
  • +
  • + Options do not transfer across websites/streaming softwares +
  • +
+
+ + + + useSession.setState({ + showEmbedPrompt: !checked, + }) + } + /> + Don't show this again + + + + + + + +
+
@@ -231,7 +240,7 @@ export default function Page() { - +
diff --git a/src/bot.ts b/src/bot.ts index 55f661aa1..709d7d44b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,6 +1,8 @@ +import { registerProcesses } from './core/blitzkrieg/processes'; +import { secrets } from './core/blitzkrieg/secrets'; import { client } from './core/discord/client'; -import { secrets } from './core/node/secrets'; -import { registerErrorHandlers } from './events/error'; -registerErrorHandlers(); +// console.log(`🟡 Launching bot ${client.shard?.ids[0]}`); + +registerProcesses(); client.login(secrets.DISCORD_TOKEN); diff --git a/src/commands/about.ts b/src/commands/about.ts index d0d28ec5c..63fbc6aa0 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -51,6 +51,7 @@ export const aboutCommand: CommandRegistry = { await Promise.all(COMMANDS_RAW) ) .filter((registry) => registry.inPublic && registry.inProduction) + .sort((a, b) => (a.command.name < b.command.name ? -1 : 1)) .map( (registry) => `- \`/${registry.command.name}\`: ${registry.command.description}`, diff --git a/src/commands/breakdown.tsx b/src/commands/breakdown.tsx new file mode 100644 index 000000000..477b158f4 --- /dev/null +++ b/src/commands/breakdown.tsx @@ -0,0 +1,310 @@ +import { SlashCommandBuilder } from 'discord.js'; +import * as Breakdown from '../components/Breakdown'; +import NoData, { NoDataType } from '../components/NoData'; +import TitleBar from '../components/TitleBar'; +import Wrapper from '../components/Wrapper'; +import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; +import getTankStats from '../core/blitz/getTankStats'; +import getTreeType from '../core/blitz/getTreeType'; +import resolveTankName from '../core/blitz/resolveTankName'; +import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; +import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; +import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; +import { tankAverages } from '../core/blitzstars/tankAverages'; +import { tankopedia } from '../core/blitzstars/tankopedia'; +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, { + ResolvedPeriod, +} from '../core/discord/resolvePeriodFromCommand'; +import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; +import resolvePlayerFromCommand, { + ResolvedPlayer, +} from '../core/discord/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'; + +async function render( + { region, id }: ResolvedPlayer, + { start, end, name }: ResolvedPeriod, + filters: StatFilters, +) { + const awaitedTankopedia = await tankopedia; + const awaitedTankAverages = await tankAverages; + const statsInPeriod = await getStatsInPeriod(region, id, start, end); + const { filteredOrder } = await filterStats(statsInPeriod, filters); + const accountInfo = await getAccountInfo(region, id); + const clanData = await getClanAccountInfo(region, id, ['clan']); + const tankStats = await getTankStats(region, id); + const filterDescriptions = await filtersToDescription(filters); + const orderedCurrentStats: AllStats[] = []; + const orderedCareerStats: AllStats[] = []; + const orderedCurrentWN8: (number | undefined)[] = []; + const orderedCareerWN8: (number | undefined)[] = []; + const orderedCareerWN8Full: (number | undefined)[] = []; + + filteredOrder.forEach((id) => { + const tankAveragesEntry = awaitedTankAverages[id]; + const current = statsInPeriod.diff[id]; + const career = tankStats.find((tank) => tank.tank_id === id)!.all; + const currentWN8 = tankAveragesEntry + ? calculateWN8(tankAveragesEntry.all, current) + : undefined; + const careerWN8 = tankAveragesEntry + ? calculateWN8(tankAveragesEntry.all, career) + : undefined; + + orderedCurrentStats.push(current); + orderedCareerStats.push(career); + orderedCurrentWN8.push(currentWN8); + orderedCareerWN8.push(careerWN8); + }); + tankStats.forEach((tank) => { + const tankAveragesEntry = awaitedTankAverages[tank.tank_id]; + + orderedCareerWN8Full.push( + tankAveragesEntry + ? calculateWN8(tankAveragesEntry.all, tank.all) + : undefined, + ); + }); + + const currentBattles = orderedCurrentStats.reduce( + (accumulator, current) => accumulator + current.battles, + 0, + ); + const careerBattles = tankStats.reduce( + (accumulator, current) => accumulator + current.all.battles, + 0, + ); + const currentWinrate = + orderedCurrentStats.reduce( + (accumulator, current) => accumulator + current.wins, + 0, + ) / currentBattles; + const careerWinrate = + tankStats.reduce( + (accumulator, current) => accumulator + current.all.wins, + 0, + ) / careerBattles; + const currentWN8 = + orderedCurrentWN8.reduce( + (accumulator, current, index) => + accumulator + (current ?? 0) * orderedCurrentStats[index].battles, + 0, + ) / + orderedCurrentStats.reduce( + (accumulator, current, index) => + accumulator + + (orderedCurrentWN8[index] === undefined ? 0 : current.battles), + 0, + ); + const careerWN8 = + orderedCareerWN8Full.reduce( + (accumulator, current) => accumulator + (current ?? 0), + 0, + ) / + tankStats.reduce( + (accumulator, current, index) => + accumulator + + (orderedCareerWN8Full[index] === undefined ? 0 : current.all.battles), + 0, + ); + const currentDamage = + orderedCurrentStats.reduce( + (accumulator, current) => accumulator + current.damage_dealt, + 0, + ) / currentBattles; + const careerDamage = + tankStats.reduce( + (accumulator, current) => accumulator + current.all.damage_dealt, + 0, + ) / careerBattles; + + return ( + + + + {filteredOrder.length === 0 && ( + + )} + {filteredOrder.length > 0 && ( + + + + {await Promise.all( + filteredOrder.map(async (id, index) => { + const tankopediaEntry = awaitedTankopedia[id]; + const current = orderedCurrentStats[index]; + const career = orderedCareerStats[index]; + const currentWN8 = orderedCurrentWN8[index]; + const careerWN8 = orderedCareerWN8[index]; + + return ( + + ); + }), + )} + + )} + + ); +} + +export const breakdownCommand = new Promise( + async (resolve) => { + const command = await addFilterOptions( + new SlashCommandBuilder() + .setName('breakdown') + .setDescription("A period's breakdown by tanks played"), + (option) => option.addStringOption(addUsernameChoices), + ); + + resolve({ + inProduction: true, + inPublic: true, + + 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, + }); + + return Promise.all([ + render(player, period, filters), + buttonPrimary(path, 'Refresh'), + 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 render(player, period, filters); + }, + }); + }, +); diff --git a/src/commands/clanEvent.ts b/src/commands/clanEvent.ts index 43e5b446d..6be992a47 100644 --- a/src/commands/clanEvent.ts +++ b/src/commands/clanEvent.ts @@ -1,97 +1,93 @@ -import { SlashCommandBuilder, escapeMarkdown } from 'discord.js'; -import markdownEscape from 'markdown-escape'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import { PlayerHistoriesRaw } from '../core/blitzstars/getPlayerHistories'; -import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo'; -import addClanChoices from '../core/discord/addClanChoices'; -import autocompleteClan from '../core/discord/autocompleteClan'; -import embedWarning from '../core/discord/embedWarning'; -import resolveClanFromCommand from '../core/discord/resolveClanFromCommand'; -import { CommandRegistry } from '../events/interactionCreate'; -import { AccountAchievements } from '../types/accountAchievements'; -import { AccountInfo } from '../types/accountInfo'; -import { ClanInfo } from '../types/clanInfo'; +// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __ +// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ / +// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' / +// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | < +// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \ +// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\ -export const clanEventCommand: CommandRegistry = { - inProduction: false, - inPublic: true, +// import { SlashCommandBuilder, escapeMarkdown } from 'discord.js'; +// import markdownEscape from 'markdown-escape'; +// import { getAccountAchievements } from '../core/blitz/getAccountAchievements'; +// import { getAccountInfo } from '../core/blitz/getAccountInfo'; +// import { getClanInfo } from '../core/blitz/getClanInfo'; +// import { PlayerHistoriesRaw } from '../LEGACY_core/blitzstars/getPlayerHistories'; +// import getTimeDaysAgo from '../LEGACY_core/blitzstars/getTimeDaysAgo'; +// import addClanChoices from '../LEGACY_core/discord/addClanChoices'; +// import autocompleteClan from '../LEGACY_core/discord/autocompleteClan'; +// import embedWarning from '../LEGACY_core/discord/embedWarning'; +// import resolveClanFromCommand from '../LEGACY_core/discord/resolveClanFromCommand'; +// import { CommandRegistry } from '../events/interactionCreate'; - command: new SlashCommandBuilder() - .setName('clan-event') - .setDescription('Lists all inactive players') - .addStringOption(addClanChoices), +// export const clanEventCommand: CommandRegistry = { +// inProduction: false, +// inPublic: true, - async handler(interaction) { - const { region, id } = await resolveClanFromCommand(interaction); - const clan = ( - await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${id}`, - ) - )[id]; - const memberIds = clan.members_ids.join(','); - const [players, achievements, previousJointVictories] = await Promise.all([ - getWargamingResponse( - `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds}`, - ), - getWargamingResponse( - `https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds}`, - ), - Promise.all( - clan.members_ids.map((id) => - fetch(`https://www.blitzstars.com/api/playerstats/${id}`) - .then((response) => response.json() as Promise) - .then((data) => ({ - id, - jointVictory: data[0]?.achievements.jointVictoryCount as - | number - | undefined, - })), - ), - ).then((data) => - data.reduce>( - (accumulator, value) => ({ - ...accumulator, - [value.id]: value.jointVictory, - }), - {}, - ), - ), - ]); - const jointVictories = clan.members_ids.map((id) => ({ - id, - joinVictory: - players[id].last_battle_time > getTimeDaysAgo(region, 1) - ? previousJointVictories[id] === undefined - ? 0 - : achievements[id].max_series.jointVictory - - previousJointVictories[id]! - : 0, - })); +// command: new SlashCommandBuilder() +// .setName('clan-event') +// .setDescription('Lists all inactive players') +// .addStringOption(addClanChoices), - return [ - `# ${escapeMarkdown(clan.name)} [${escapeMarkdown( - clan.tag, - )}]'s platoon wins today: ${( - jointVictories.reduce( - (accumulator, { joinVictory }) => accumulator + joinVictory, - 0, - ) / 2 - ).toFixed(0)}\n${jointVictories - .filter(({ joinVictory }) => joinVictory) - .sort((a, b) => b.joinVictory - a.joinVictory) - .map( - ({ id, joinVictory }) => - `- ${markdownEscape(players[id].nickname)}: ${joinVictory}`, - ) - .join('\n')}`, +// async handler(interaction) { +// const { region, id } = await resolveClanFromCommand(interaction); +// const clan = await getClanInfo(region, id); +// const [players, achievements, previousJointVictories] = await Promise.all([ +// getAccountInfo(region, clan.members_ids), +// getAccountAchievements(region, clan.members_ids), +// Promise.all( +// clan.members_ids.map((id) => +// fetch(`https://www.blitzstars.com/api/playerstats/${id}`) +// .then((response) => response.json() as Promise) +// .then((data) => ({ +// id, +// jointVictory: data[0]?.achievements.jointVictoryCount as +// | number +// | undefined, +// })), +// ), +// ).then((data) => +// data.reduce>( +// (accumulator, value) => ({ +// ...accumulator, +// [value.id]: value.jointVictory, +// }), +// {}, +// ), +// ), +// ]); +// const jointVictories = clan.members_ids.map((id) => ({ +// id, +// joinVictory: +// players[id].last_battle_time > getTimeDaysAgo(region, 1) +// ? previousJointVictories[id] === undefined +// ? 0 +// : achievements[id].max_series.jointVictory - +// previousJointVictories[id]! +// : 0, +// })); - embedWarning( - 'This is an approximation!', - "Wargaming provides very little information about platoons publicly. Caveats:\n- Players in two different clans platooning artificially inflates the total count\n- Game-mode battles aren't counted since Wargaming doesn't publicly share that data\n- This command is also subject to Blitzkrieg's reset times (use `/about timezones` for more info)", - ), - ]; - }, +// return [ +// `# ${escapeMarkdown(clan.name)} [${escapeMarkdown( +// clan.tag, +// )}]'s platoon wins today: ${( +// jointVictories.reduce( +// (accumulator, { joinVictory }) => accumulator + joinVictory, +// 0, +// ) / 2 +// ).toFixed(0)}\n${jointVictories +// .filter(({ joinVictory }) => joinVictory) +// .sort((a, b) => b.joinVictory - a.joinVictory) +// .map( +// ({ id, joinVictory }) => +// `- ${markdownEscape(players[id].nickname)}: ${joinVictory}`, +// ) +// .join('\n')}`, - autocomplete: autocompleteClan, -}; +// embedWarning( +// 'This is an approximation!', +// "Wargaming provides very little information about platoons publicly. Caveats:\n- Players in two different clans platooning artificially inflates the total count\n- Game-mode battles aren't counted since Wargaming doesn't publicly share that data\n- This command is also subject to Blitzkrieg's reset times (use `/about timezones` for more info)", +// ), +// ]; +// }, + +// autocomplete: autocompleteClan, +// }; diff --git a/src/commands/debug.ts b/src/commands/debug.ts index e3afbe119..7b1254add 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -1,11 +1,11 @@ import { SlashCommandBuilder } from 'discord.js'; import packageJSON from '../../package.json' assert { type: 'json' }; -import { tankopedia } from '../core/blitz/tankopedia'; +import getClientId from '../core/blitzkrieg/getClientId'; import { tankAverages } from '../core/blitzstars/tankAverages'; +import { tankopedia } from '../core/blitzstars/tankopedia'; import { client } from '../core/discord/client'; import embedInfo from '../core/discord/embedInfo'; import markdownTable from '../core/discord/markdownTable'; -import getClientId from '../core/node/getClientId'; import { CommandRegistry } from '../events/interactionCreate'; const executionStart = new Date().getTime(); diff --git a/src/commands/eligible.ts b/src/commands/eligible.ts index bd89200d5..9dcf864a2 100644 --- a/src/commands/eligible.ts +++ b/src/commands/eligible.ts @@ -1,20 +1,18 @@ import { SlashCommandBuilder } from 'discord.js'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import calculateWN8 from '../core/blitz/calculateWN8'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import sumStats from '../core/blitz/sumStats'; -import { tankopedia } from '../core/blitz/tankopedia'; -import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats'; -import getPeriodNow from '../core/blitzstars/getPeriodNow'; -import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import getPeriodNow from '../core/blitzkrieg/getPeriodNow'; +import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo'; +import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import { tankAverages } from '../core/blitzstars/tankAverages'; +import { tankopedia } from '../core/blitzstars/tankopedia'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import embedNegative from '../core/discord/embedNegative'; import embedPositive from '../core/discord/embedPositive'; import markdownTable, { TableInput } from '../core/discord/markdownTable'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import calculateWN8 from '../core/statistics/calculateWN8'; +import sumStats from '../core/statistics/sumStats'; import { CommandRegistry } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; import { PossiblyPromise } from '../types/possiblyPromise'; export type SkilledClan = 'SKLLD' | 'SMRI'; @@ -48,20 +46,18 @@ export const eligibleCommand: CommandRegistry = { async handler(interaction) { const clan = interaction.options.getString('clan') as SkilledClan; - const { id, region: server } = await resolvePlayerFromCommand(interaction); - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); + const { id, region: region } = await resolvePlayerFromCommand(interaction); + const accountInfo = await getAccountInfo(region, id); const problems: TableInput = []; - const title = `${accountInfo[id].nickname}'s eligibility for ${SKILLED_CLANS[clan]}`; + const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`; let isEligible = false; let body = ''; if (clan === 'SKLLD') { - const { diffed } = await getDiffedTankStats( - server, + const { diff: diffed } = await getStatsInPeriod( + region, id, - getTimeDaysAgo(server, 30), + getTimeDaysAgo(region, 30), getPeriodNow(), ); const entries = Object.entries(diffed); @@ -171,7 +167,7 @@ export const eligibleCommand: CommandRegistry = { problems, )}`; } else if (clan === 'SMRI') { - const stats = accountInfo[id].statistics.all; + const stats = accountInfo.statistics.all; if (stats.battles < 1500) { problems.push([ diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx index 1b39ef3b9..b495076e4 100644 --- a/src/commands/evolution.tsx +++ b/src/commands/evolution.tsx @@ -1,31 +1,142 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { CYCLIC_API } from '../constants/cyclic'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; +import { + SlashCommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import * as Graph from '../components/Graph'; +import NoData, { NoDataType } from '../components/NoData'; +import TitleBar from '../components/TitleBar'; +import Wrapper from '../components/Wrapper'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import resolveTankId from '../core/blitz/resolveTankId'; -import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups'; +import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; +import getTankHistories from '../core/blitzkrieg/getTankHistories'; +import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; +import getPlayerHistories from '../core/blitzstars/getPlayerHistories'; +import addPeriodSubCommands from '../core/discord/addPeriodSubCommands'; +import addTankChoices from '../core/discord/addTankChoices'; +import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; -import interactionToURL from '../core/discord/interactionToURL'; -import linkButton from '../core/discord/linkButton'; -import primaryButton from '../core/discord/primaryButton'; +import buttonPrimary from '../core/discord/buttonPrimary'; +import commandToURL from '../core/discord/commandToURL'; +import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams'; import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton'; -import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand'; +import resolvePeriodFromCommand, { + ResolvedPeriod, +} from '../core/discord/resolvePeriodFromCommand'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; -import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import resolvePlayerFromCommand, { + ResolvedPlayer, +} from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistryRaw } from '../events/interactionCreate'; -import evolution from '../renderers/evolution'; -import { StatType } from '../renderers/stats'; -import { AccountInfo } from '../types/accountInfo'; + +type EvolutionStatType = 'player' | 'tank'; + +async function render( + { region, id }: ResolvedPlayer, + { start, end, name }: ResolvedPeriod, + type: 'player' | 'tank', + tankId: number | null, +) { + const accountInfo = await getAccountInfo(region, id); + const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; + const logo = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; + const histories = await (type === 'player' + ? getPlayerHistories(region, id, { + start, + end, + includeLatestHistories: true, + includePreviousHistories: true, + }) + : getTankHistories(region, id, { + tankId: tankId!, + start, + end, + includeLatestHistories: true, + includePreviousHistories: true, + })); + const plot = histories.map( + (history) => + [ + history.all.battles, + (history.all.wins / history.all.battles) * 100, + ] as Graph.PlotItem, + ); + const times = histories.map((history) => history.last_battle_time); + const minTime = Math.min(...times); + const maxTime = Math.max(...times); + const ys = plot.map(([, y]) => y); + const xs = plot.map(([x]) => x); + const maxY = Math.max(...ys); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const minX = Math.min(...xs); + + return ( + + + + {plot.length > 0 && ( + + {Graph.Line({ + color: Graph.LineColor.Red, + plot: plot, + minY, + maxY, + })} + + )} + + {plot.length === 0 && } + + ); +} export const evolutionCommand = new Promise( async (resolve) => { - const command = await addStatTypeSubCommandGroups( - new SlashCommandBuilder() - .setName('evolution') - .setDescription('Evolution of statistics'), - false, - ); + let playerGroup: SlashCommandSubcommandGroupBuilder; + let tankGroup: SlashCommandSubcommandGroupBuilder; + const command = new SlashCommandBuilder() + .setName('evolution') + .setDescription('Evolution of statistics') + .addSubcommandGroup((option) => { + playerGroup = option + .setName('player') + .setDescription('Player statistics'); + + return playerGroup; + }) + .addSubcommandGroup((option) => { + tankGroup = option.setName('tank').setDescription('Tank statistics'); + + return tankGroup; + }); + + await Promise.all([ + addPeriodSubCommands(playerGroup!, (option) => + option.addStringOption(addUsernameChoices), + ), + addPeriodSubCommands(tankGroup!, (option) => + option + .addStringOption(addTankChoices) + .addStringOption(addUsernameChoices), + ), + ]); resolve({ inProduction: true, @@ -34,37 +145,28 @@ export const evolutionCommand = new Promise( command, async handler(interaction) { - const commandGroup = interaction.options.getSubcommandGroup( - true, - ) as StatType; + const commandGroup = + interaction.options.getSubcommandGroup() as EvolutionStatType; const player = await resolvePlayerFromCommand(interaction); const period = resolvePeriodFromCommand(player.region, interaction); const tankIdRaw = interaction.options.getString('tank')!; const tankId = commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null; - const start = interaction.options.getInteger('start'); - const end = interaction.options.getInteger('end'); - const { nickname } = ( - await getWargamingResponse( - `https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`, - ) - )[player.id]; - const path = interactionToURL(interaction, { + const path = commandToURL(interaction, { ...player, + ...getCustomPeriodParams(interaction), tankId, - start, - end, }); + getBlitzStarsLinkButton; + return [ - await evolution(commandGroup, period, player, tankId), - primaryButton(path, 'Refresh'), - // linkButton(`${CYCLIC_API}/${path}`, 'Embed'), - linkButton( - `https://www.blitzstars.com/player/${player.region}/${nickname}${ - commandGroup === 'tank' ? `/tank/${tankId!}` : '' - }`, - 'BlitzStars', + await render(player, period, commandGroup, tankId), + buttonPrimary(path, 'Refresh'), + await getBlitzStarsLinkButton( + player.region, + player.id, + tankId ?? undefined, ), ]; }, @@ -75,17 +177,17 @@ export const evolutionCommand = new Promise( }, async button(interaction) { - const url = new URL(`${CYCLIC_API}/${interaction.customId}`); - const path = url.pathname.split('/').filter(Boolean); - const commandGroup = path[1] as StatType; + const url = new URL(`https://example.com/${interaction.customId}`); + const path = url.pathname.split('/'); + const commandGroup = path[2] as EvolutionStatType; const player = await resolvePlayerFromButton(interaction); const period = resolvePeriodFromButton(player.region, interaction); - return await evolution( - commandGroup, - period, + return await render( player, - parseInt(url.searchParams.get('tankId')!), + period, + commandGroup, + parseInt(url.searchParams.get('tankId') ?? '0') || null, ); }, }); diff --git a/src/commands/fullStats.tsx b/src/commands/fullStats.tsx index d1c10afb7..f9262602a 100644 --- a/src/commands/fullStats.tsx +++ b/src/commands/fullStats.tsx @@ -1,30 +1,76 @@ import { SlashCommandBuilder } from 'discord.js'; -import { TreeTypeString } from '../components/Tanks'; -import { CYCLIC_API } from '../constants/cyclic'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankId from '../core/blitz/resolveTankId'; -import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups'; +import GenericAllStats from '../components/GenericAllStats'; +import NoData, { NoDataType } from '../components/NoData'; +import TierWeights from '../components/TierWeights'; +import TitleBar from '../components/TitleBar'; +import Wrapper from '../components/Wrapper'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; +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 addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; -import interactionToURL from '../core/discord/interactionToURL'; -import linkButton from '../core/discord/linkButton'; -import primaryButton from '../core/discord/primaryButton'; +import { buttonRefresh } from '../core/discord/buttonRefresh'; +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 resolvePeriodFromCommand, { + ResolvedPeriod, +} from '../core/discord/resolvePeriodFromCommand'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; -import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import resolvePlayerFromCommand, { + ResolvedPlayer, +} from '../core/discord/resolvePlayerFromCommand'; +import { StatFilters, filterStats } from '../core/statistics/filterStats'; +import { getTierWeights } from '../core/statistics/getTierWeights'; import { CommandRegistryRaw } from '../events/interactionCreate'; -import fullStats from '../renderers/fullStats'; -import { MultiTankFilters, StatType } from '../renderers/stats'; -import { AccountInfo } from '../types/accountInfo'; + +async function render( + { region, id }: ResolvedPlayer, + { start, end, name }: ResolvedPeriod, + filters: StatFilters, +) { + const { nickname } = await getAccountInfo(region, id); + const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; + const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; + const diffedTankStats = await getStatsInPeriod(region, id, start, end); + const { stats, supplementary, filteredOrder } = await filterStats( + diffedTankStats, + filters, + ); + const filterDescriptions = await filtersToDescription(filters); + const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder); + + return ( + + + + {stats.battles === 0 && } + {stats.battles > 0 && } + {stats.battles > 0 && ( + + )} + + ); +} export const fullStatsCommand = new Promise( async (resolve) => { - const command = await addStatTypeSubCommandGroups( + const command = await addFilterOptions( new SlashCommandBuilder() .setName('full-stats') .setDescription('Full in-game statistics'), + (option) => option.addStringOption(addUsernameChoices), ); resolve({ @@ -34,60 +80,20 @@ export const fullStatsCommand = new Promise( command, async handler(interaction) { - const commandGroup = interaction.options.getSubcommandGroup( - true, - ) as StatType; const player = await resolvePlayerFromCommand(interaction); - const resolvedPeriod = resolvePeriodFromCommand( - player.region, - interaction, - ); - const period = interaction.options.getString('period'); - const tankIdRaw = interaction.options.getString('tank')!; - const tankId = - commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null; - const start = interaction.options.getInteger('start'); - const end = interaction.options.getInteger('end'); - const path = interactionToURL(interaction, { + const period = resolvePeriodFromCommand(player.region, interaction); + const filters = await getFiltersFromCommand(interaction); + const path = commandToURL(interaction, { ...player, - tankId, - start, - end, - period, + ...getCustomPeriodParams(interaction), + ...filters, }); - const { nickname } = ( - await getWargamingResponse( - `https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`, - ) - )[player.id]; - const tierOption = interaction.options.getString('tier'); - - return [ - await fullStats( - commandGroup, - resolvedPeriod, - player, - commandGroup === 'tank' - ? tankId - : commandGroup === 'multi-tank' - ? ({ - 'tank-type': - interaction.options.getString('tank-type') ?? undefined, - nation: interaction.options.getString('nation') ?? undefined, - tier: tierOption ? parseInt(tierOption) : undefined, - 'tree-type': (interaction.options.getString('tree-type') ?? - undefined) as TreeTypeString | undefined, - } satisfies Partial) - : null, - ), - primaryButton(path, 'Refresh'), - // linkButton(`${CYCLIC_API}/${path}`, 'Embed'), - linkButton( - `https://www.blitzstars.com/player/${player.region}/${nickname}`, - 'BlitzStars', - ), - ]; + return Promise.all([ + render(player, period, filters), + buttonRefresh(interaction, path), + getBlitzStarsLinkButton(player.region, player.id), + ]); }, autocomplete: (interaction) => { @@ -96,18 +102,11 @@ export const fullStatsCommand = new Promise( }, async button(interaction) { - const url = new URL(`${CYCLIC_API}/${interaction.customId}`); - const path = url.pathname.split('/').filter(Boolean); - const commandGroup = path[1] as StatType; const player = await resolvePlayerFromButton(interaction); const period = resolvePeriodFromButton(player.region, interaction); + const filters = getFiltersFromButton(interaction); - return await fullStats( - commandGroup, - period, - player, - parseInt(url.searchParams.get('tankId')!), - ); + return await render(player, period, filters); }, }); }, diff --git a/src/commands/inactive.tsx b/src/commands/inactive.tsx index 7b14b5ea5..aabc824aa 100644 --- a/src/commands/inactive.tsx +++ b/src/commands/inactive.tsx @@ -3,14 +3,12 @@ import GenericStats from '../components/GenericStats'; import NoData, { NoDataType } from '../components/NoData'; import TitleBar from '../components/TitleBar'; import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanInfo } from '../core/blitz/getClanInfo'; import addClanChoices from '../core/discord/addClanChoices'; import autocompleteClan from '../core/discord/autocompleteClan'; import resolveClanFromCommand from '../core/discord/resolveClanFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; -import { ClanInfo } from '../types/clanInfo'; const DEFAULT_THRESHOLD = 7; @@ -36,24 +34,16 @@ export const inactiveCommand: CommandRegistry = { const threshold = interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD; const time = new Date().getTime() / 1000; - const clanData = ( - await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${id}`, + const clanInfo = await getClanInfo(region, id); + const accountInfo = await getAccountInfo(region, clanInfo.members_ids); + const inactive = accountInfo + .map( + (account) => + [ + account.nickname, + (time - account.last_battle_time) / 60 / 60 / 24, + ] as [string, number], ) - )[id]; - const memberIds = clanData.members_ids; - const accounts = await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds.join( - ',', - )}`, - ); - const inactive = memberIds - .map((memberId) => { - const member = accounts[memberId]; - const inactiveDays = (time - member.last_battle_time) / 60 / 60 / 24; - - return [member.nickname, inactiveDays] as [string, number]; - }) .filter(([, inactiveDays]) => inactiveDays >= threshold) .sort((a, b) => b[1] - a[1]) .map( @@ -64,8 +54,8 @@ export const inactiveCommand: CommandRegistry = { return ( diff --git a/src/commands/link.tsx b/src/commands/link.tsx new file mode 100644 index 000000000..9db82aaae --- /dev/null +++ b/src/commands/link.tsx @@ -0,0 +1,223 @@ +import { teal } from '@radix-ui/colors'; +import { + AttachmentBuilder, + 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 { 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 embedNegative from '../core/discord/embedNegative'; +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, + + command: new SlashCommandBuilder() + .setName('link') + .setDescription('Links your Blitz and Discord account') + .addStringOption((option) => addUsernameChoices(option).setRequired(true)), + + async handler(interaction) { + const username = interaction.options.getString('username', true); + + 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 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, + ); + } + + return ( + +
+ + +
+
+ + +
+ + {interaction.user.displayName} + + + @{interaction.user.username} + +
+
+ +
+ + Accounts linked +
+ +
+ {clanAccountInfo?.clan ? ( + + ) : null} +
+ + {accountInfo.nickname} + + + {clanAccountInfo?.clan + ? `[${clanAccountInfo?.clan?.tag}] - ` + : ''} + {REGION_NAMES_SHORT[region as 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'), + ]; + } + }, + + autocomplete: autocompleteUsername, +}; diff --git a/src/commands/ownedTanks.tsx b/src/commands/ownedTanks.tsx index a8bd2e0cb..8f731590e 100644 --- a/src/commands/ownedTanks.tsx +++ b/src/commands/ownedTanks.tsx @@ -3,46 +3,24 @@ import NoData, { NoDataType } from '../components/NoData'; import * as Tanks from '../components/Tanks'; import TitleBar from '../components/TitleBar'; import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import getTankStats from '../core/blitz/getTankStats'; import getTreeType from '../core/blitz/getTreeType'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; import resolveTankName from '../core/blitz/resolveTankName'; +import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { TIER_ROMAN_NUMERALS, TankopediaEntry, Tier, tankopedia, tankopediaInfo, -} from '../core/blitz/tankopedia'; +} from '../core/blitzstars/tankopedia'; import addTierChoices from '../core/discord/addTierChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; - -const COMP_TANKS = [ - // light tanks - 24321, // t-100 lt - 20257, // sheridan - 3649, // bat chat - 19537, // vickers - - // heavy tanks - 6145, // is-4 - 7169, // is-7 - 10785, // t110e5 - 12161, // strvk - 4481, // kranvagn - 22817, // m6 yoh - 6225, // fv215b - 5425, // wz 113 - 7297, // 60tp - 58641, // vk 72 - 6753, // type 71 -]; export const ownedTanksCommand: CommandRegistry = { inProduction: true, @@ -58,9 +36,7 @@ export const ownedTanksCommand: CommandRegistry = { const tier = Number(interaction.options.getString('tier')); const account = await resolvePlayerFromCommand(interaction); const { id, region: server } = account; - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); + const accountInfo = await getAccountInfo(server, id); const tankStats = await getTankStats(server, id); const filteredTanks = ( await Promise.all( @@ -70,9 +46,7 @@ export const ownedTanksCommand: CommandRegistry = { })), ) ).filter((tank) => tank.tankopedia?.tier === tier); - const clanData = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ); + const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']); const groupedTanks: Record = {}; const nations: string[] = []; @@ -90,18 +64,13 @@ export const ownedTanksCommand: CommandRegistry = { return ( {filteredTanks.length === 0 && } @@ -158,17 +127,3 @@ export const ownedTanksCommand: CommandRegistry = { autocomplete: autocompleteUsername, }; - -// TODO: make this work in the future -// console.log('Caching tank images...'); -// TANKS.then((tanks) => -// jsxToSvg( -//
-// {tanks.map(({ images, tank_id }) => { -// return ( -// images.normal && -// ); -// })} -//
, -// ), -// ).then(() => console.log('Cached tank images')); diff --git a/src/commands/playerAchievements.ts b/src/commands/playerAchievements.ts index 91baffa25..440b4d954 100644 --- a/src/commands/playerAchievements.ts +++ b/src/commands/playerAchievements.ts @@ -1,16 +1,14 @@ import { SlashCommandBuilder } from 'discord.js'; import { startCase } from 'lodash'; import markdownEscape from 'markdown-escape'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; +import { getAccountAchievements } from '../core/blitz/getAccountAchievements'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import embedInfo from '../core/discord/embedInfo'; import markdownTable, { TableInputEntry } from '../core/discord/markdownTable'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; -import { AccountAchievements } from '../types/accountAchievements'; -import { AccountInfo } from '../types/accountInfo'; type SortBy = 'name' | 'count'; @@ -35,22 +33,17 @@ export const playerAchievementsCommand: CommandRegistry = { async handler(interaction) { const sortBy = (interaction.options.getString('sort') ?? 'name') as SortBy; const account = await resolvePlayerFromCommand(interaction); - const { id, region: server } = account; - const accounts = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const accountsAchievements = - await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const accountAchievements = accountsAchievements[id]; + const { id, region: region } = account; + const accountInfo = await getAccountInfo(region, id); + const accountsAchievements = await getAccountAchievements(region, id); + const accountAchievements = accountsAchievements; const compound = { ...accountAchievements.achievements, ...accountAchievements.max_series, }; return embedInfo( - `${markdownEscape(accounts[id].nickname)}'s information`, + `${markdownEscape(accountInfo.nickname)}'s information`, markdownTable( Object.keys(compound) diff --git a/src/commands/playerInfo.ts b/src/commands/playerInfo.ts index f85908394..64342da2d 100644 --- a/src/commands/playerInfo.ts +++ b/src/commands/playerInfo.ts @@ -1,14 +1,12 @@ import { SlashCommandBuilder } from 'discord.js'; import markdownEscape from 'markdown-escape'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import embedInfo from '../core/discord/embedInfo'; import markdownTable from '../core/discord/markdownTable'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; export const playerInfoCommand: CommandRegistry = { inProduction: true, @@ -21,11 +19,8 @@ export const playerInfoCommand: CommandRegistry = { async handler(interaction) { const account = await resolvePlayerFromCommand(interaction); - const { id, region: server } = account; - const accounts = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const accountInfo = accounts[id]; + const { id, region: region } = account; + const accountInfo = await getAccountInfo(region, id); return embedInfo( `${markdownEscape(accountInfo.nickname)}'s information`, diff --git a/src/commands/ratings.tsx b/src/commands/ratings.tsx index 3f49a15ea..647507ed0 100644 --- a/src/commands/ratings.tsx +++ b/src/commands/ratings.tsx @@ -9,23 +9,22 @@ 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 { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getArchivedRatingsInfo from '../core/blitz/getArchivedRatingsInfo'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import getRatingsInfo from '../core/blitz/getRatingsInfo'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain'; +import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo'; import getMidnightLeaderboard, { DATABASE_REPO, -} from '../core/database/getMidnightLeaderboard'; +} from '../core/blitzkrieg/getMidnightLeaderboard'; +import { octokit } from '../core/blitzkrieg/octokit'; +import throwError from '../core/blitzkrieg/throwError'; 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 { octokit } from '../core/github/octokit'; -import throwError from '../core/node/throwError'; import { CommandRegistryRaw } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; export interface RatingsPlayer { spa_id: number; @@ -172,6 +171,11 @@ function addSubcommands( ); } +const noOngoingSeason = embedNegative( + 'No ongoing season', + "Wargaming didn't provide any data for this season.", +); + export const ratingsCommand = new Promise( async (resolve) => { const seasonChoices = await octokit.repos @@ -259,27 +263,28 @@ export const ratingsCommand = new Promise( regionRatingsInfo = await (subcommandGroup === 'current' ? getRatingsInfo(region) : getArchivedRatingsInfo(season!, region)); + + if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; + const regionSubdomain = regionToRegionSubdomain(region); const result = subcommandGroup === 'current' - ? regionRatingsInfo.detail === undefined - ? await fetch( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`, + ? 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, + ), ) - .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, @@ -305,9 +310,10 @@ export const ratingsCommand = new Promise( firstPlayerIndex, lastPlayerIndex + 1, ); - const ids = trimmed.map((player) => player.id).join(','); - const clanData = await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`, + const clanData = await getClanAccountInfo( + region, + trimmed.map(({ id }) => id), + ['clan'], ); return trimmed.map( @@ -323,34 +329,25 @@ export const ratingsCommand = new Promise( }) satisfies SimplifiedPlayer, ); }); - const leagueInfo = - regionRatingsInfo.detail === undefined - ? regionRatingsInfo.leagues[leagueIndex] - : undefined; + const leagueInfo = regionRatingsInfo.leagues[leagueIndex]; midnightLeaderboard = subcommandGroup === 'current' - ? regionRatingsInfo.detail === undefined - ? await getMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ) - : undefined + ? await getMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) : await getMidnightLeaderboard(region, season!); players = result; playersBefore = result ? result[0].position - 1 : 0; playersAfter = - regionRatingsInfo.detail === undefined && result !== undefined - ? regionRatingsInfo.count - result[result.length - 1].position - : 0; + regionRatingsInfo.count - result[result.length - 1].position; 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; + titleImage = leagueInfo.big_icon.startsWith('http') + ? leagueInfo.big_icon + : `https:${leagueInfo.big_icon}`; titleDescription = `Top ${limit} players`; } else { const { region, id } = await resolvePlayerFromCommand(interaction); @@ -372,40 +369,34 @@ export const ratingsCommand = new Promise( response.json(), )) as RatingsInfo; }); - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const clan = ( - await getWargamingResponse( - `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ) - )[id]?.clan; + + if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; + + const accountInfo = await getAccountInfo(region, id); + const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const regionSubdomain = regionToRegionSubdomain(region); const neighbors = subcommandGroup === 'current' - ? regionRatingsInfo.detail === undefined - ? await fetch( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round( - limit / 2, - )}`, + ? 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, + ), ) - .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, @@ -428,7 +419,7 @@ export const ratingsCommand = new Promise( if (playerIndex === -1) { throw throwError( - `${accountInfo[id].nickname} didn't player ratings in season ${season}`, + `${accountInfo.nickname} didn't player ratings in season ${season}`, 'This player did not participate in this season or did not get past calibration.', ); } @@ -438,9 +429,10 @@ export const ratingsCommand = new Promise( 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=${WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`, + const clanData = await getClanAccountInfo( + region, + trimmed.map((player) => player.id), + ['clan'], ); return trimmed.map((player, index) => ({ @@ -453,46 +445,38 @@ export const ratingsCommand = new Promise( }); midnightLeaderboard = subcommandGroup === 'current' - ? regionRatingsInfo.detail === undefined - ? await getMidnightLeaderboard( - region, - regionRatingsInfo.current_season, - ) - : undefined + ? await getMidnightLeaderboard( + region, + regionRatingsInfo.current_season, + ) : await getMidnightLeaderboard(region, season!); if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`; players = neighbors; - titleImage = neighbors - ? clan - ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` - : undefined + titleImage = clan + ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` : undefined; titleName = neighbors - ? accountInfo[id].nickname + ? accountInfo.nickname : `No Ongoing Season - ${REGION_NAMES_SHORT[region]}`; titleDescription = 'Ratings neighbours'; playersBefore = neighbors ? neighbors[0].position - 1 : 0; playersAfter = - regionRatingsInfo.detail === undefined - ? neighbors - ? regionRatingsInfo.count - - neighbors[neighbors.length - 1].position - : 0 - : 0; + regionRatingsInfo.count - neighbors[neighbors.length - 1].position; playerId = id; } - 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 resolvedRegionRatingsInfo: typeof regionRatingsInfo & { + detail: undefined; + } = regionRatingsInfo; + + const items = players.map((player) => { + const reward = resolvedRegionRatingsInfo.rewards.find( + (reward) => + player.position >= reward.from_position && + player.position <= reward.to_position, + ); const midnightIndex = midnightLeaderboard?.findIndex( (item) => item.id === player.id, ); @@ -523,8 +507,7 @@ export const ratingsCommand = new Promise( {items && ( diff --git a/src/commands/searchClans.ts b/src/commands/searchClans.ts index 28016ce85..8eb4bc83d 100644 --- a/src/commands/searchClans.ts +++ b/src/commands/searchClans.ts @@ -2,7 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import markdownEscape from 'markdown-escape'; import { REGION_NAMES, Region } from '../constants/regions'; import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; +import fetchBlitz from '../core/blitz/fetchWargaming'; import addClanChoices from '../core/discord/addClanChoices'; import addRegionChoices from '../core/discord/addRegionChoices'; import embedInfo from '../core/discord/embedInfo'; @@ -30,7 +30,7 @@ export const searchClansCommand: CommandRegistry = { const server = interaction.options.getString('region') as Region; const clan = interaction.options.getString('clan')!; const limit = interaction.options.getInteger('limit') ?? 25; - const clanList = await getWargamingResponse( + const clanList = await fetchBlitz( `https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`, ); diff --git a/src/commands/searchPlayers.ts b/src/commands/searchPlayers.ts index 87d66c640..9a376d9c7 100644 --- a/src/commands/searchPlayers.ts +++ b/src/commands/searchPlayers.ts @@ -2,8 +2,11 @@ import { SlashCommandBuilder } from 'discord.js'; import markdownEscape from 'markdown-escape'; import { REGION_NAMES, Region } from '../constants/regions'; import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import { AccountList, usernamePattern } from '../core/blitz/listPlayers'; +import fetchBlitz from '../core/blitz/fetchWargaming'; +import { + AccountList, + usernamePattern, +} from '../core/blitz/searchPlayersAcrossRegions'; import addRegionChoices from '../core/discord/addRegionChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import embedInfo from '../core/discord/embedInfo'; @@ -34,7 +37,7 @@ export const searchPlayersCommand: CommandRegistry = { const limit = interaction.options.getInteger('limit') ?? 25; const trimmedSearch = name.trim(); const players = usernamePattern.test(trimmedSearch) - ? await getWargamingResponse( + ? await fetchBlitz( `https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent( trimmedSearch, )}&limit=${limit}`, diff --git a/src/commands/searchTanks.ts b/src/commands/searchTanks.ts index 441443b92..0429c5e56 100644 --- a/src/commands/searchTanks.ts +++ b/src/commands/searchTanks.ts @@ -1,7 +1,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { go } from 'fuzzysort'; import markdownEscape from 'markdown-escape'; -import { TANK_NAMES_DIACRITICS } from '../core/blitz/tankopedia'; +import { TANK_NAMES_DIACRITICS } from '../core/blitzstars/tankopedia'; import addTankChoices from '../core/discord/addTankChoices'; import embedInfo from '../core/discord/embedInfo'; import { CommandRegistry } from '../events/interactionCreate'; diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx index 60f1dcc9c..3f2e8cf60 100644 --- a/src/commands/stats.tsx +++ b/src/commands/stats.tsx @@ -1,28 +1,68 @@ import { SlashCommandBuilder } from 'discord.js'; -import { TreeTypeString } from '../components/Tanks'; -import { CYCLIC_API } from '../constants/cyclic'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankId from '../core/blitz/resolveTankId'; -import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups'; +import AllStatsOverview from '../components/AllStatsOverview'; +import NoData, { NoDataType } from '../components/NoData'; +import TitleBar from '../components/TitleBar'; +import Wrapper from '../components/Wrapper'; +import { getAccountInfo } from '../core/blitz/getAccountInfo'; +import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; +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 addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; -import interactionToURL from '../core/discord/interactionToURL'; -import linkButton from '../core/discord/linkButton'; -import primaryButton from '../core/discord/primaryButton'; +import { buttonRefresh } from '../core/discord/buttonRefresh'; +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 resolvePeriodFromCommand, { + ResolvedPeriod, +} from '../core/discord/resolvePeriodFromCommand'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; -import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import resolvePlayerFromCommand, { + ResolvedPlayer, +} from '../core/discord/resolvePlayerFromCommand'; +import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { CommandRegistryRaw } from '../events/interactionCreate'; -import stats, { MultiTankFilters, StatType } from '../renderers/stats'; -import { AccountInfo } from '../types/accountInfo'; + +async function render( + { region, id }: ResolvedPlayer, + { start, end, name }: ResolvedPeriod, + filters: StatFilters, +) { + const { nickname } = await getAccountInfo(region, id); + const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; + const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; + const diffedTankStats = await getStatsInPeriod(region, id, start, end); + const { stats, supplementary } = await filterStats(diffedTankStats, filters); + const filterDescriptions = await filtersToDescription(filters); + + return ( + + + + {!stats.battles && } + {stats.battles > 0 && ( + + )} + + ); +} export const statsCommand = new Promise(async (resolve) => { - const command = await addStatTypeSubCommandGroups( + const command = await addFilterOptions( new SlashCommandBuilder() .setName('stats') .setDescription('Regular battles statistics'), + (option) => option.addStringOption(addUsernameChoices), ); resolve({ @@ -32,60 +72,20 @@ export const statsCommand = new Promise(async (resolve) => { command, async handler(interaction) { - const commandGroup = interaction.options.getSubcommandGroup( - true, - ) as StatType; const player = await resolvePlayerFromCommand(interaction); - const resolvedPeriod = resolvePeriodFromCommand( - player.region, - interaction, - ); - const period = interaction.options.getString('period'); - const tankIdRaw = interaction.options.getString('tank')!; - const tankId = - commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null; - const start = interaction.options.getInteger('start'); - const end = interaction.options.getInteger('end'); - const path = interactionToURL(interaction, { + const period = resolvePeriodFromCommand(player.region, interaction); + const filters = await getFiltersFromCommand(interaction); + const path = commandToURL(interaction, { ...player, - tankId, - start, - end, - period, + ...getCustomPeriodParams(interaction), + ...filters, }); - const { nickname } = ( - await getWargamingResponse( - `https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`, - ) - )[player.id]; - const tierOption = interaction.options.getString('tier'); - - return [ - await stats( - commandGroup, - resolvedPeriod, - player, - commandGroup === 'tank' - ? tankId - : commandGroup === 'multi-tank' - ? ({ - 'tank-type': - interaction.options.getString('tank-type') ?? undefined, - nation: interaction.options.getString('nation') ?? undefined, - tier: tierOption ? parseInt(tierOption) : undefined, - 'tree-type': (interaction.options.getString('tree-type') ?? - undefined) as TreeTypeString | undefined, - } satisfies Partial) - : null, - ), - primaryButton(path, 'Refresh'), - // linkButton(`${CYCLIC_API}/${path}`, 'Embed'), - linkButton( - `https://www.blitzstars.com/player/${player.region}/${nickname}`, - 'BlitzStars', - ), - ]; + return Promise.all([ + render(player, period, filters), + buttonRefresh(interaction, path), + getBlitzStarsLinkButton(player.region, player.id), + ]); }, autocomplete: (interaction) => { @@ -94,27 +94,11 @@ export const statsCommand = new Promise(async (resolve) => { }, async button(interaction) { - const url = new URL(`${CYCLIC_API}/${interaction.customId}`); - const path = url.pathname.split('/').filter(Boolean); - const commandGroup = path[1] as StatType; const player = await resolvePlayerFromButton(interaction); const period = resolvePeriodFromButton(player.region, interaction); + const filters = getFiltersFromButton(interaction); - return await stats( - commandGroup, - period, - player, - commandGroup === 'tank' - ? parseInt(url.searchParams.get('tankId')!) - : commandGroup === 'multi-tank' - ? ({ - 'tank-type': url.searchParams.get('tank-type')!, - nation: url.searchParams.get('nation')!, - tier: parseInt(url.searchParams.get('tier')!), - 'tree-type': url.searchParams.get('tree-type') as TreeTypeString, - } satisfies MultiTankFilters) - : null, - ); + return await render(player, period, filters); }, }); }); diff --git a/src/commands/today.tsx b/src/commands/today.tsx index 48d658056..3b3e5e348 100644 --- a/src/commands/today.tsx +++ b/src/commands/today.tsx @@ -1,83 +1,18 @@ import { SlashCommandBuilder } from 'discord.js'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import addUsernameChoices from '../core/discord/addUsernameChoices'; -import autocompleteUsername from '../core/discord/autocompleteUsername'; -import interactionToURL from '../core/discord/interactionToURL'; -import linkButton from '../core/discord/linkButton'; -import primaryButton from '../core/discord/primaryButton'; -import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; -import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import { CommandRegistry } from '../events/interactionCreate'; -import today from '../renderers/today'; -import { AccountInfo } from '../types/accountInfo'; +/** + * TODO: @deprecated + */ export const todayCommand: CommandRegistry = { inProduction: true, inPublic: true, command: new SlashCommandBuilder() .setName('today') - .setDescription('A general daily breakdown of your performance') - .addStringOption(addUsernameChoices) - .addIntegerOption((option) => - option - .setName('cutoff') - .setDescription( - 'The maximum number of tanks to display (default: Infinity)', - ) - .setRequired(false) - .setMinValue(1), - ) - .addIntegerOption((option) => - option - .setName('maximized') - .setDescription( - 'The number of rows with full stats after which all are collapsed (default: 4)', - ) - .setMinValue(0) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName('show-total') - .setDescription('Show the total stats at the top (default: true)') - .setRequired(false), - ), + .setDescription('This command has been renamed to `/breakdown today`'), - async handler(interaction) { - const player = await resolvePlayerFromCommand(interaction); - const cutoff = interaction.options.getInteger('cutoff') ?? undefined; - const maximized = interaction.options.getInteger('maximized') ?? undefined; - const showTotal = interaction.options.getBoolean('show-total') ?? undefined; - const { nickname } = ( - await getWargamingResponse( - `https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`, - ) - )[player.id]; - const path = interactionToURL(interaction, { - ...player, - cutoff, - maximized, - 'show-total': showTotal, - }); - - return [ - await today(player, cutoff, maximized, showTotal, false), - primaryButton(path, 'Refresh'), - // linkButton(`${CYCLIC_API}/${path}`, 'Embed'), - linkButton( - `https://www.blitzstars.com/player/${player.region}/${nickname}`, - 'BlitzStars', - ), - ]; - }, - - autocomplete: autocompleteUsername, - - async button(interaction) { - const player = await resolvePlayerFromButton(interaction); - - return await today(player); + handler() { + return '# This command has been renamed to `/breakdown today`\n`/today` now comes with filters, periods and more! Use `/breakdown today` for same old functionality but also do checkout the other options.'; }, }; diff --git a/src/commands/verify.ts b/src/commands/verify.ts deleted file mode 100644 index 70524c432..000000000 --- a/src/commands/verify.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import markdownEscape from 'markdown-escape'; -import discord from '../../discord.json' assert { type: 'json' }; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import addUsernameChoices from '../core/discord/addUsernameChoices'; -import autocompleteUsername from '../core/discord/autocompleteUsername'; -import embedNegative from '../core/discord/embedNegative'; -import embedPositive from '../core/discord/embedPositive'; -import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; -import { CommandRegistry } from '../events/interactionCreate'; -import { AccountInfo } from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; - -export const verifyCommand: CommandRegistry = { - inProduction: true, - inPublic: true, - - command: new SlashCommandBuilder() - .setName('verify') - .setDescription("Set's the user's username to their in-game name") - .addStringOption((option) => addUsernameChoices(option).setRequired(true)), - - async handler(interaction) { - const blitzAccount = await resolvePlayerFromCommand(interaction); - const { id, region: server } = blitzAccount; - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const clanData = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ); - const clan = clanData[id]?.clan; - const clanTag = clan ? ` [${clan!.tag}]` : ''; - const member = - interaction.member && - interaction.guild?.members.cache.get(interaction.member?.user.id); - - if (!interaction.guild?.members.me?.permissions.has('ManageNicknames')) { - return embedNegative( - `${markdownEscape(interaction.user.username)} failed to verify`, - "I don't have the permission to change your nickname.", - ); - } - if (!interaction.member || !member) { - return embedNegative( - `${interaction.user.username} is not in this server`, - "I couldn't find this person in this server. Did they leave?", - ); - } - - try { - await member.setNickname(`${accountInfo[id].nickname}${clanTag}`); - - 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 member.roles.remove(discord.sklld_verify_role); - await member.roles.add(discord.sklld_peasant_role); - } - - return embedPositive( - `${interaction.user.username} is verified`, - `The user is now verified as ${markdownEscape( - accountInfo[id].nickname, - )}${markdownEscape(clanTag)}`, - ); - } catch (error) { - return embedNegative( - `${interaction.user.username} failed to verify`, - "I can't change your nickname because you have higher permissions than me. Try setting your nickname to your Blitz username manually.", - ); - } - }, - - autocomplete: autocompleteUsername, -}; diff --git a/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx index 44c1c21d5..b5a524067 100644 --- a/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx +++ b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx @@ -1,31 +1,28 @@ -import { Percentile } from '../../../../../constants/percentiles'; -import { PERCENTILE_COLORS } from '../../../../PercentileIndicator'; - -export enum GlowSide { - Top, - Bottom, -} - export interface GlowProps { - percentile: Percentile; - side: GlowSide; + color: string; + direction?: 'default' | 'reverse'; + rotation?: number; } -export function Glow({ percentile, side }: GlowProps) { - const color = PERCENTILE_COLORS[percentile]; +export function Glow({ + color, + direction = 'default', + rotation = 0, +}: GlowProps) { const color1 = color; const color2 = `${color}00`; const background = `linear-gradient(${ - side === GlowSide.Top ? 0 : 180 + direction === 'default' ? 0 : 180 }deg, ${color1} 0%, ${color2} 100%)`; return (
diff --git a/src/components/AllStatsOverview/components/WN8Display/index.tsx b/src/components/AllStatsOverview/components/WN8Display/index.tsx index 4fd0da495..6e8fe8aba 100644 --- a/src/components/AllStatsOverview/components/WN8Display/index.tsx +++ b/src/components/AllStatsOverview/components/WN8Display/index.tsx @@ -1,8 +1,8 @@ import { Percentile } from '../../../../constants/percentiles'; -import getWN8Percentile from '../../../../core/blitz/getWN8Percentile'; +import getWN8Percentile from '../../../../core/statistics/getWN8Percentile'; import { theme } from '../../../../stitches.config'; import { PERCENTILE_COLORS } from '../../../PercentileIndicator'; -import { Glow, GlowSide } from './components/Glow'; +import { Glow } from './components/Glow'; export interface WN8DisplayProps { WN8?: number; @@ -37,7 +37,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) { gap: -16, }} > - +
- +
); } diff --git a/src/components/AllStatsOverview/index.tsx b/src/components/AllStatsOverview/index.tsx index da7ba6c34..5b3f4d0f3 100644 --- a/src/components/AllStatsOverview/index.tsx +++ b/src/components/AllStatsOverview/index.tsx @@ -1,4 +1,4 @@ -import { AllStats, SupplementaryStats } from '../../types/accountInfo'; +import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo'; import { WN8Display } from './components/WN8Display'; import { WN8SurroundingStat, diff --git a/src/components/Breakdown/components/Row.tsx b/src/components/Breakdown/components/Row.tsx index 9c4a7281f..04328e483 100644 --- a/src/components/Breakdown/components/Row.tsx +++ b/src/components/Breakdown/components/Row.tsx @@ -1,5 +1,4 @@ import { Percentile } from '../../../constants/percentiles'; -import { TankType } from '../../../core/blitz/tankopedia'; import { theme } from '../../../stitches.config'; import { TREE_TYPE_ICONS, TreeTypeEnum } from '../../Tanks'; import { RowStat } from './RowStat'; @@ -17,7 +16,7 @@ interface RowProps { title: string; minimized?: boolean; treeType?: TreeTypeEnum; - tankType?: TankType; + tankType?: string; stats: (RowStatItem | undefined)[]; } diff --git a/src/components/Breakdown/components/RowStat.tsx b/src/components/Breakdown/components/RowStat.tsx index 3c0cda7c0..17b1cd6a2 100644 --- a/src/components/Breakdown/components/RowStat.tsx +++ b/src/components/Breakdown/components/RowStat.tsx @@ -15,7 +15,7 @@ export function RowStat({ value, delta, percentile, - minimized, + minimized = false, }: RowStatProps) { return (
{items.map((item) => (
= { + 'tech-tree': 'Tech tree', + premium: 'Premium', + collector: 'Collector', +}; export interface ItemProps { image?: string; - tankType?: TankType; + tankType?: string; name: string; treeType: TreeTypeEnum; } @@ -75,7 +80,15 @@ export function Item({ image, tankType, name, treeType }: ItemProps) {
- {image && } + {/* {image && ( + // TODO: remove hardcoded dimensions when satori fixes non-width unloadable images + + )} */}
); } diff --git a/src/components/TierWeights.tsx b/src/components/TierWeights.tsx index f08a7046f..61951cb41 100644 --- a/src/components/TierWeights.tsx +++ b/src/components/TierWeights.tsx @@ -1,4 +1,4 @@ -import { TIER_ROMAN_NUMERALS, Tier } from '../core/blitz/tankopedia'; +import { Tier } from '../core/blitzstars/tankopedia'; import { theme } from '../stitches.config'; export type TierWeightsRecord = Partial>; @@ -26,7 +26,7 @@ export default function TierWeights({ weights }: TierWeightsProps) { minWidth: 16, }} > - {TIER_ROMAN_NUMERALS[parseInt(tier) as Tier]} + {tier}
))} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 277ec1c38..b3e4da675 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -2,17 +2,11 @@ import { theme } from '../stitches.config'; export interface TitleBarProps { name: string; - nameDiscriminator?: string; image?: string; description?: string; } -export default function TitleBar({ - name, - nameDiscriminator, - image, - description, -}: TitleBarProps) { +export default function TitleBar({ name, image, description }: TitleBarProps) { return (
@@ -51,24 +44,11 @@ export default function TitleBar({ color: theme.colors.textHighContrast, fontWeight: 900, whiteSpace: 'nowrap', - maxWidth: nameDiscriminator === undefined ? Infinity : 240, overflow: 'hidden', }} > {name} - {nameDiscriminator && ( - - {nameDiscriminator} - - )}
diff --git a/src/components/Wrapper.tsx b/src/components/Wrapper.tsx index ce3aad029..0af54719e 100644 --- a/src/components/Wrapper.tsx +++ b/src/components/Wrapper.tsx @@ -4,15 +4,16 @@ import { theme } from '../stitches.config'; export interface WrapperProps { children: ReactNode; naked?: boolean; + fat?: boolean; } -export default function Wrapper({ children, naked }: WrapperProps) { +export default function Wrapper({ children, naked, fat }: WrapperProps) { return (
= +export type BlitzResponse = | { status: 'error'; error: { field: string; message: string; code: 402; value: null }; @@ -10,12 +10,10 @@ export type WargamingResponse = data: Data; }; -export default async function getWargamingResponse( - url: string, -) { +export default async function fetchBlitz(url: string) { const data = (await fetch(url).then((response) => response.json(), - )) as WargamingResponse; + )) as BlitzResponse; if (data.status === 'ok') { return data.data; diff --git a/src/core/blitz/getAccountAchievements.ts b/src/core/blitz/getAccountAchievements.ts new file mode 100644 index 000000000..edb21ca14 --- /dev/null +++ b/src/core/blitz/getAccountAchievements.ts @@ -0,0 +1,36 @@ +import { Region } from '../../constants/regions'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import fetchBlitz from './fetchWargaming'; +import { normalizeIds } from './normalizeIds'; + +interface IndividualAccountAchievements { + achievements: { + [achievement: string]: number; + }; + max_series: { + [achievement: string]: number; + }; +} + +export interface AccountAchievements { + [id: number]: IndividualAccountAchievements; +} + +export async function getAccountAchievements< + Ids extends number | number[], + ReturnType = Ids extends number + ? IndividualAccountAchievements + : IndividualAccountAchievements[], +>(region: Region, ids: Ids) { + const object = await fetchBlitz( + `https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( + ids, + )}`, + ); + + if (typeof ids === 'number') { + return object[ids as number] as ReturnType; + } else { + return ids.map((id) => object[id]) as ReturnType; + } +} diff --git a/src/core/blitz/getAccountInfo.ts b/src/core/blitz/getAccountInfo.ts new file mode 100644 index 000000000..5852ea073 --- /dev/null +++ b/src/core/blitz/getAccountInfo.ts @@ -0,0 +1,89 @@ +import { Region } from '../../constants/regions'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import fetchBlitz from './fetchWargaming'; +import { normalizeIds } from './normalizeIds'; + +export interface AllStats { + spotted: number; + hits: number; + frags: number; + max_xp: number; + wins: number; + losses: number; + capture_points: number; + battles: number; + damage_dealt: number; + damage_received: number; + max_frags: number; + shots: number; + frags8p: number; + xp: number; + win_and_survived: number; + survived_battles: number; + dropped_capture_points: number; +} + +export interface SupplementaryStats { + // if undefined, WN8 is not available for tanks without averages + WN8?: number; + tier?: number; +} + +export interface ClanStats { + spotted: number; + max_frags_tank_id: number; + hits: number; + frags: number; + max_xp: number; + max_xp_tank_id: number; + wins: number; + losses: number; + capture_points: number; + battles: number; + damage_dealt: number; + damage_received: number; + max_frags: number; + shots: number; + frags8p: number; + xp: number; + win_and_survived: number; + survived_battles: number; + dropped_capture_points: number; +} + +interface IndividualAccountInfo { + account_id: number; + created_at: number; + updated_at: number; + private: null; + last_battle_time: number; + nickname: string; + statistics: { + all: AllStats; + clan: ClanStats; + frags: null | number; + }; +} + +export interface AccountInfo { + [accountId: number]: IndividualAccountInfo; +} + +export async function getAccountInfo< + Ids extends number | number[], + ReturnType = Ids extends number + ? IndividualAccountInfo + : IndividualAccountInfo[], +>(region: Region, ids: Ids) { + const object = await fetchBlitz( + `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( + ids, + )}`, + ); + + if (typeof ids === 'number') { + return object[ids as number] as ReturnType; + } else { + return ids.map((id) => object[id]) as ReturnType; + } +} diff --git a/src/core/blitz/getClanAccountInfo.ts b/src/core/blitz/getClanAccountInfo.ts new file mode 100644 index 000000000..6c36fc15f --- /dev/null +++ b/src/core/blitz/getClanAccountInfo.ts @@ -0,0 +1,45 @@ +import { Region } from '../../constants/regions'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import fetchBlitz from './fetchWargaming'; +import { normalizeExtra } from './normalizeExtra'; +import { normalizeIds } from './normalizeIds'; + +export type IndividualClanAccountInfo = { + account_id: number; + joined_at: number; + clan_id: number; + role: string; + account_name: string; + + clan?: { + members_count: number; + name: string; + created_at: number; + tag: string; + clan_id: number; + emblem_set_id: number; + }; +} | null; + +export interface ClanAccountInfo { + [accountId: number]: IndividualClanAccountInfo; +} + +export async function getClanAccountInfo< + Ids extends number | number[], + ReturnType = Ids extends number + ? IndividualClanAccountInfo + : IndividualClanAccountInfo[], +>(region: Region, ids: Ids, extra?: string[]) { + const object = await fetchBlitz( + `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( + ids, + )}${normalizeExtra(extra)}`, + ); + + if (typeof ids === 'number') { + return object[ids as number] as ReturnType; + } else { + return ids.map((id) => object[id]) as ReturnType; + } +} diff --git a/src/core/blitz/getClanInfo.ts b/src/core/blitz/getClanInfo.ts new file mode 100644 index 000000000..625daca67 --- /dev/null +++ b/src/core/blitz/getClanInfo.ts @@ -0,0 +1,54 @@ +import { Region } from '../../constants/regions'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import fetchBlitz from './fetchWargaming'; +import { normalizeIds } from './normalizeIds'; + +export interface IndividualClanInfo { + recruiting_options: { + vehicles_level: number; + wins_ratio: number; + average_battles_per_day: number; + battles: number; + average_damage: number; + }; + members_count: number; + name: string; + creator_name: string; + clan_id: number; + created_at: number; + updated_at: number; + leader_name: string; + members_ids: number[]; + recruiting_policy: string; + tag: string; + is_clan_disbanded: boolean; + old_name: string; + emblem_set_id: number; + creator_id: number; + motto: string; + renamed_at: number; + old_tag: null; + leader_id: number; + description: string; +} + +export interface ClanInfo { + [clanId: number]: IndividualClanInfo; +} + +export async function getClanInfo< + Ids extends number | number[], + ReturnType = Ids extends number ? IndividualClanInfo : IndividualClanInfo[], +>(region: Region, ids: Ids) { + const object = await fetchBlitz( + `https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( + ids, + )}`, + ); + + if (typeof ids === 'number') { + return object[ids as number] as ReturnType; + } else { + return ids.map((id) => object[id]) as ReturnType; + } +} diff --git a/src/core/blitz/getTankStats.ts b/src/core/blitz/getTankStats.ts index 8ff5766ba..3d435143c 100644 --- a/src/core/blitz/getTankStats.ts +++ b/src/core/blitz/getTankStats.ts @@ -1,18 +1,18 @@ -import { REGION_NAMES, Region } from '../../constants/regions'; +import { Region } from '../../constants/regions'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { TanksStats } from '../../types/tanksStats'; -import throwError from '../node/throwError'; -import getWargamingResponse from './getWargamingResponse'; +import throwError from '../blitzkrieg/throwError'; +import fetchBlitz from './fetchWargaming'; export default async function getTankStats(region: Region, id: number) { - const tankStats = await getWargamingResponse( + const tankStats = await fetchBlitz( `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, ); if (tankStats[id] === null) { throw throwError( 'No tank stats available', - `Wargaming says there is no tank stats for this account. This account may not have any battles/tanks or exist in the ${REGION_NAMES[region]} server.`, + "This player doesn't have any stats for tanks available. This may not be the player you are looking for.", ); } diff --git a/src/core/blitz/getTreeType.ts b/src/core/blitz/getTreeType.ts index 5c5e4f1ec..01db48e26 100644 --- a/src/core/blitz/getTreeType.ts +++ b/src/core/blitz/getTreeType.ts @@ -1,5 +1,5 @@ import { TreeTypeEnum } from '../../components/Tanks'; -import { tankopedia } from './tankopedia'; +import { tankopedia } from '../blitzstars/tankopedia'; export default async function getTreeType(id: number) { const entry = (await tankopedia)[id]; diff --git a/src/core/blitz/normalizeExtra.ts b/src/core/blitz/normalizeExtra.ts new file mode 100644 index 000000000..23c5acd9b --- /dev/null +++ b/src/core/blitz/normalizeExtra.ts @@ -0,0 +1,3 @@ +export function normalizeExtra(extra?: string[]) { + return extra ? `&extra=${extra.join(',')}` : ''; +} diff --git a/src/core/blitz/normalizeIds.ts b/src/core/blitz/normalizeIds.ts new file mode 100644 index 000000000..3357438e7 --- /dev/null +++ b/src/core/blitz/normalizeIds.ts @@ -0,0 +1,3 @@ +export function normalizeIds(ids: number | number[]): string { + return typeof ids === 'number' ? ids.toString() : ids.join(','); +} diff --git a/src/core/blitz/resolveTankId.ts b/src/core/blitz/resolveTankId.ts index a7bb5d8a2..391785fbe 100644 --- a/src/core/blitz/resolveTankId.ts +++ b/src/core/blitz/resolveTankId.ts @@ -1,12 +1,15 @@ import { go } from 'fuzzysort'; -import throwError from '../node/throwError'; -import { TANKS, tankopedia } from './tankopedia'; +import throwError from '../blitzkrieg/throwError'; +import { TANKS, tankopedia } from '../blitzstars/tankopedia'; -export default async function resolveTankId(tank: string) { - const number = parseInt(tank); +export default async function resolveTankId(tank: string | number) { + const number = typeof tank === 'string' ? parseInt(tank) : tank; if (Number.isNaN(number)) { - const searchResult = go(tank, await TANKS, { keys: ['name'], limit: 1 }); + const searchResult = go(`${tank}`, await TANKS, { + keys: ['name'], + limit: 1, + }); if (searchResult.length === 0) { throw throwError( diff --git a/src/core/blitz/resolveTankName.ts b/src/core/blitz/resolveTankName.ts index 6d3d38a8d..7954de496 100644 --- a/src/core/blitz/resolveTankName.ts +++ b/src/core/blitz/resolveTankName.ts @@ -1,4 +1,4 @@ -import { tankopedia } from './tankopedia'; +import { tankopedia } from '../blitzstars/tankopedia'; export default async function resolveTankName(tankId: number) { const tankopediaName = (await tankopedia)[tankId]?.name; diff --git a/src/core/blitz/listClans.ts b/src/core/blitz/searchClansAcrossRegions.ts similarity index 85% rename from src/core/blitz/listClans.ts rename to src/core/blitz/searchClansAcrossRegions.ts index a0475bd62..1e62aa981 100644 --- a/src/core/blitz/listClans.ts +++ b/src/core/blitz/searchClansAcrossRegions.ts @@ -1,20 +1,23 @@ import { Region } from '../../constants/regions'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { Clan, ClanList } from '../../types/clanList'; -import getWargamingResponse from './getWargamingResponse'; +import fetchBlitz from './fetchWargaming'; export type ClanListWithRegion = (Clan & { region: 'com' | 'eu' | 'asia'; })[]; -export default async function listClans(search: string, limit = 9) { +export default async function searchClansAcrossRegions( + search: string, + limit = 9, +) { const trimmed = search.trim(); if (trimmed.length < 3 && trimmed.length > 100) return []; const normalizedLimit = Math.round(limit / 3); return ( await Promise.all([ - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.com/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, ).then( (value) => @@ -24,7 +27,7 @@ export default async function listClans(search: string, limit = 9) { region: 'com' as Region, })), ), - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.eu/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, ).then( (value) => @@ -34,7 +37,7 @@ export default async function listClans(search: string, limit = 9) { region: 'eu' as Region, })), ), - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.asia/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, ).then( (value) => diff --git a/src/core/blitz/listPlayers.ts b/src/core/blitz/searchPlayersAcrossRegions.ts similarity index 87% rename from src/core/blitz/listPlayers.ts rename to src/core/blitz/searchPlayersAcrossRegions.ts index 15f3bae99..b24315ee1 100644 --- a/src/core/blitz/listPlayers.ts +++ b/src/core/blitz/searchPlayersAcrossRegions.ts @@ -1,6 +1,6 @@ import { Region } from '../../constants/regions'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; -import getWargamingResponse from './getWargamingResponse'; +import fetchBlitz from './fetchWargaming'; interface Account { nickname: string; @@ -14,7 +14,10 @@ export type AccountListWithServer = (Account & { export const usernamePattern = /^[a-zA-Z0-9_]{3,24}$/; export const usernamePatternWithoutPosition = /[a-zA-Z0-9_]{3,24}/; -export default async function listPlayers(search: string, limit = 9) { +export default async function searchPlayersAcrossRegions( + search: string, + limit = 9, +) { const trimmedSearch = search.trim(); const normalizedLimit = Math.round(limit / 3); const encodedSearch = encodeURIComponent(trimmedSearch); @@ -22,7 +25,7 @@ export default async function listPlayers(search: string, limit = 9) { if (usernamePattern.test(trimmedSearch)) { return ( await Promise.all([ - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.com/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => @@ -32,7 +35,7 @@ export default async function listPlayers(search: string, limit = 9) { region: 'com' as Region, })), ), - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.eu/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => @@ -42,7 +45,7 @@ export default async function listPlayers(search: string, limit = 9) { region: 'eu' as Region, })), ), - getWargamingResponse( + fetchBlitz( `https://api.wotblitz.asia/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => diff --git a/src/core/blitzkrieg/discordBlitz.ts b/src/core/blitzkrieg/discordBlitz.ts new file mode 100644 index 000000000..fd644e944 --- /dev/null +++ b/src/core/blitzkrieg/discordBlitz.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from '@prisma/client'; +import { Region } from '../../constants/regions'; + +export const discordBlitzDB = new PrismaClient(); + +export function linkBlitzAndDiscord( + discord: number, + region: Region, + blitz: number, +) { + return discordBlitzDB.discord_blitz.upsert({ + where: { discord }, + update: { region, blitz }, + create: { discord, region, blitz }, + }); +} + +export function getBlitzFromDiscord(discord: number) { + return discordBlitzDB.discord_blitz.findUnique({ + where: { discord }, + select: { region: true, blitz: true }, + }) as Promise; +} diff --git a/src/core/blitzkrieg/emblemIdToURL.ts b/src/core/blitzkrieg/emblemIdToURL.ts new file mode 100644 index 000000000..e07dd2ba8 --- /dev/null +++ b/src/core/blitzkrieg/emblemIdToURL.ts @@ -0,0 +1,3 @@ +export function emblemIdToURL(emblemId: number) { + return `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${emblemId}.png`; +} diff --git a/src/core/blitzkrieg/filtersToDescription.ts b/src/core/blitzkrieg/filtersToDescription.ts new file mode 100644 index 000000000..85d79c20d --- /dev/null +++ b/src/core/blitzkrieg/filtersToDescription.ts @@ -0,0 +1,27 @@ +import { TREE_TYPE_NAMES } from '../../components/Tanks'; +import resolveTankName from '../blitz/resolveTankName'; +import { + TIER_ROMAN_NUMERALS, + Tier, + tankopediaInfo, +} from '../blitzstars/tankopedia'; +import { StatFilters } from '../statistics/filterStats'; + +export async function filtersToDescription({ + nation, + tier, + tankType, + treeType, + tank, +}: StatFilters) { + const awaitedTankopediaInfo = await tankopediaInfo; + const info: string[] = []; + + if (tank) return await resolveTankName(tank); + if (nation) info.push(awaitedTankopediaInfo.vehicle_nations[nation]); + if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`); + if (tankType) info.push(awaitedTankopediaInfo.vehicle_types[tankType]); + if (treeType) info.push(TREE_TYPE_NAMES[treeType]); + + return info.length === 0 ? 'No filters' : info.join(', '); +} diff --git a/src/core/blitz/getArchivedRatingsInfo.ts b/src/core/blitzkrieg/getArchivedRatingsInfo.ts similarity index 57% rename from src/core/blitz/getArchivedRatingsInfo.ts rename to src/core/blitzkrieg/getArchivedRatingsInfo.ts index c22e29653..6a97afcef 100644 --- a/src/core/blitz/getArchivedRatingsInfo.ts +++ b/src/core/blitzkrieg/getArchivedRatingsInfo.ts @@ -1,13 +1,31 @@ import { RatingsInfo } from '../../commands/ratings'; import { Region } from '../../constants/regions'; -import { DATABASE_REPO } from '../database/getMidnightLeaderboard'; -import { octokit } from '../github/octokit'; -import throwError from '../node/throwError'; +import { DATABASE_REPO } from './getMidnightLeaderboard'; +import { octokit } from './octokit'; +import throwError from './throwError'; + +const ARCHIVED_RATINGS_CACHE: Record< + Region, + Record< + number, + RatingsInfo & { + detail: undefined; + } + > +> = { + com: {}, + eu: {}, + asia: {}, +}; export default async function getArchivedRatingsInfo( season: number, region: Region, ) { + if (ARCHIVED_RATINGS_CACHE[region][season]) { + return ARCHIVED_RATINGS_CACHE[region][season]; + } + const { data } = await octokit.repos.getContent({ ...DATABASE_REPO, path: `${region}/ratings/${season}/info.json`, @@ -21,5 +39,7 @@ export default async function getArchivedRatingsInfo( detail: undefined; }; + ARCHIVED_RATINGS_CACHE[region][season] = jsonContent; + return jsonContent; } diff --git a/src/core/node/getClientId.ts b/src/core/blitzkrieg/getClientId.ts similarity index 100% rename from src/core/node/getClientId.ts rename to src/core/blitzkrieg/getClientId.ts diff --git a/src/core/database/getMidnightLeaderboard.ts b/src/core/blitzkrieg/getMidnightLeaderboard.ts similarity index 61% rename from src/core/database/getMidnightLeaderboard.ts rename to src/core/blitzkrieg/getMidnightLeaderboard.ts index b318299e2..17794025d 100644 --- a/src/core/database/getMidnightLeaderboard.ts +++ b/src/core/blitzkrieg/getMidnightLeaderboard.ts @@ -1,13 +1,26 @@ import { BlitzkriegRatingsLeaderboard } from '../../../scripts/buildRatingsLeaderboard'; import { Region } from '../../constants/regions'; -import { octokit } from '../github/octokit'; +import { octokit } from './octokit'; export const DATABASE_REPO = { owner: 'tresabhi', repo: 'blitzkrieg-db' }; +const MIDNIGHT_LEADERBOARD_CACHE: Record< + Region, + Record +> = { + com: {}, + eu: {}, + asia: {}, +}; + export default async function getMidnightLeaderboard( region: Region, season: number, ) { + if (MIDNIGHT_LEADERBOARD_CACHE[region][season]) { + return MIDNIGHT_LEADERBOARD_CACHE[region][season]; + } + try { const { data } = await octokit.repos.getContent({ ...DATABASE_REPO, @@ -16,8 +29,11 @@ export default async function getMidnightLeaderboard( if (!Array.isArray(data) && data.type === 'file') { const response = await fetch(data.download_url!); + const json = (await response.json()) as BlitzkriegRatingsLeaderboard; - return (await response.json()) as BlitzkriegRatingsLeaderboard; + MIDNIGHT_LEADERBOARD_CACHE[region][season] = json; + + return json; } } catch (error) { console.warn( diff --git a/src/core/blitzstars/getPeriodNow.ts b/src/core/blitzkrieg/getPeriodNow.ts similarity index 100% rename from src/core/blitzstars/getPeriodNow.ts rename to src/core/blitzkrieg/getPeriodNow.ts diff --git a/src/core/blitzstars/getPeriodStart.ts b/src/core/blitzkrieg/getPeriodStart.ts similarity index 61% rename from src/core/blitzstars/getPeriodStart.ts rename to src/core/blitzkrieg/getPeriodStart.ts index 118084a35..b60c19307 100644 --- a/src/core/blitzstars/getPeriodStart.ts +++ b/src/core/blitzkrieg/getPeriodStart.ts @@ -3,5 +3,7 @@ import { PeriodSize } from '../discord/addPeriodSubCommands'; import getTimeDaysAgo from './getTimeDaysAgo'; export default function getPeriodStart(region: Region, period: PeriodSize) { - return period === 'career' ? 0 : getTimeDaysAgo(region, parseInt(period)); + if (period === 'career') return 0; + if (period === 'today') return getTimeDaysAgo(region, 1); + return getTimeDaysAgo(region, parseInt(period)); } diff --git a/src/core/blitzstars/getTankHistories.ts b/src/core/blitzkrieg/getTankHistories.ts similarity index 98% rename from src/core/blitzstars/getTankHistories.ts rename to src/core/blitzkrieg/getTankHistories.ts index 3b5ed9d14..8ee89de4d 100644 --- a/src/core/blitzstars/getTankHistories.ts +++ b/src/core/blitzkrieg/getTankHistories.ts @@ -6,7 +6,7 @@ import { getHistoriesDefaultOptions, } from '../../types/histories'; import getTankStats from '../blitz/getTankStats'; -import { emptyAllStats } from './getDiffedTankStats'; +import { emptyAllStats } from '../blitzstars/getStatsInPeriod'; export interface TankHistory extends History { tank_id: number; diff --git a/src/core/blitzstars/getTimeDaysAgo.ts b/src/core/blitzkrieg/getTimeDaysAgo.ts similarity index 100% rename from src/core/blitzstars/getTimeDaysAgo.ts rename to src/core/blitzkrieg/getTimeDaysAgo.ts diff --git a/src/core/node/isDev.ts b/src/core/blitzkrieg/isDev.ts similarity index 100% rename from src/core/node/isDev.ts rename to src/core/blitzkrieg/isDev.ts diff --git a/src/core/node/jsxToPng.ts b/src/core/blitzkrieg/jsxToPng.ts similarity index 100% rename from src/core/node/jsxToPng.ts rename to src/core/blitzkrieg/jsxToPng.ts diff --git a/src/core/blitzkrieg/jsxToPngThreaded.ts b/src/core/blitzkrieg/jsxToPngThreaded.ts new file mode 100644 index 000000000..5a68fb629 --- /dev/null +++ b/src/core/blitzkrieg/jsxToPngThreaded.ts @@ -0,0 +1,9 @@ +import jsxToSvg from './jsxToSvg'; +import { svgToPngThreaded } from './svgToPngThreaded'; + +export default async function jsxToPngThreaded(jsx: JSX.Element) { + const svg = await jsxToSvg(jsx); + const png = await svgToPngThreaded(svg); + + return png; +} diff --git a/src/core/node/jsxToSvg.ts b/src/core/blitzkrieg/jsxToSvg.ts similarity index 94% rename from src/core/node/jsxToSvg.ts rename to src/core/blitzkrieg/jsxToSvg.ts index eaffb3e5a..e0acc1621 100644 --- a/src/core/node/jsxToSvg.ts +++ b/src/core/blitzkrieg/jsxToSvg.ts @@ -12,7 +12,6 @@ let roboto: Buffer; let robotoBold: Buffer; let robotoBlack: Buffer; -console.log('Importing fonts...'); Promise.all( FONT_FILES.map( (file) => @@ -27,7 +26,6 @@ Promise.all( ), ).then((data) => { [roboto, robotoBold, robotoBlack] = data; - console.log('Fonts imported'); }); export default async function jsxToSvg(jsx: JSX.Element) { diff --git a/src/core/github/octokit.ts b/src/core/blitzkrieg/octokit.ts similarity index 71% rename from src/core/github/octokit.ts rename to src/core/blitzkrieg/octokit.ts index 6e87855d7..509aafa5a 100644 --- a/src/core/github/octokit.ts +++ b/src/core/blitzkrieg/octokit.ts @@ -1,4 +1,4 @@ import { Octokit } from '@octokit/rest'; -import { secrets } from '../node/secrets'; +import { secrets } from './secrets'; export const octokit = new Octokit({ auth: secrets.GH_TOKEN }); diff --git a/src/core/blitzkrieg/processes.ts b/src/core/blitzkrieg/processes.ts new file mode 100644 index 000000000..9834d616c --- /dev/null +++ b/src/core/blitzkrieg/processes.ts @@ -0,0 +1,29 @@ +import { client } from '../discord/client'; +import { discordBlitzDB } from './discordBlitz'; + +const EXIT_EVENTS = [ + 'beforeExit', + 'exit', + 'SIGINT', + 'SIGTERM', + 'SIGUSR1', + 'SIGUSR2', +]; + +async function cleanup() { + console.log('Cleaning up...'); + + await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]); + + console.log('Gracefully exiting...'); + + EXIT_EVENTS.forEach((event) => process.off(event, cleanup)); + process.exit(0); +} + +export function registerProcesses() { + process.on('uncaughtException', console.error); + client.on('error', console.error); + + EXIT_EVENTS.forEach((event) => process.on(event, cleanup)); +} diff --git a/src/core/node/secrets.ts b/src/core/blitzkrieg/secrets.ts similarity index 82% rename from src/core/node/secrets.ts rename to src/core/blitzkrieg/secrets.ts index 9836fec85..264382263 100644 --- a/src/core/node/secrets.ts +++ b/src/core/blitzkrieg/secrets.ts @@ -2,7 +2,11 @@ import { config } from 'dotenv'; config(); -export const SECRET_KEYS = ['DISCORD_TOKEN', 'GH_TOKEN'] as const; +export const SECRET_KEYS = [ + 'DISCORD_TOKEN', + 'GH_TOKEN', + 'DISCORD_BLITZ_DB', +] as const; export type SECRET = typeof SECRET_KEYS extends readonly (infer T)[] ? T diff --git a/src/core/node/svgToPng.ts b/src/core/blitzkrieg/svgToPng.ts similarity index 100% rename from src/core/node/svgToPng.ts rename to src/core/blitzkrieg/svgToPng.ts diff --git a/src/core/blitzkrieg/svgToPngThreaded.ts b/src/core/blitzkrieg/svgToPngThreaded.ts new file mode 100644 index 000000000..dbba997b4 --- /dev/null +++ b/src/core/blitzkrieg/svgToPngThreaded.ts @@ -0,0 +1,42 @@ +import { Worker } from 'worker_threads'; + +const MAX_WORKERS = 3; + +const workers = [...Array(MAX_WORKERS)].map(() => { + const worker = new Worker(`${__dirname}/workers/render.cjs`); + console.log(`🟡 Launching render worker ${worker.threadId}`); + return worker; +}); +const queue: { + svg: string; + resolve: (png: Buffer) => void; +}[] = []; + +function manageQueue() { + if (queue.length > 0 && workers.length > 0) { + const { svg, resolve } = queue.shift()!; + const worker = workers.shift()!; + + function cleanup() { + workers.push(worker); + manageQueue(); + } + + worker.postMessage(svg); + worker.on('message', (png: Uint8Array) => { + cleanup(); + resolve(Buffer.from(png)); + }); + worker.on('error', (error) => { + cleanup(); + throw error; + }); + } +} + +export function svgToPngThreaded(svg: string) { + return new Promise((resolve) => { + queue.push({ svg, resolve }); + manageQueue(); + }); +} diff --git a/src/core/node/throwError.ts b/src/core/blitzkrieg/throwError.ts similarity index 100% rename from src/core/node/throwError.ts rename to src/core/blitzkrieg/throwError.ts diff --git a/src/core/blitzstars/getBlitzStarsLinkButton.ts b/src/core/blitzstars/getBlitzStarsLinkButton.ts new file mode 100644 index 000000000..da2074adc --- /dev/null +++ b/src/core/blitzstars/getBlitzStarsLinkButton.ts @@ -0,0 +1,18 @@ +import { Region } from '../../constants/regions'; +import { getAccountInfo } from '../blitz/getAccountInfo'; +import buttonLink from '../discord/buttonLink'; + +export async function getBlitzStarsLinkButton( + region: Region, + id: number, + tankId?: number, +) { + const { nickname } = await getAccountInfo(region, id); + + return buttonLink( + `https://www.blitzstars.com/player/${region}/${nickname}${ + tankId ? `/tank/${tankId!}` : '' + }`, + 'BlitzStars', + ); +} diff --git a/src/core/blitzstars/getPlayerHistories.ts b/src/core/blitzstars/getPlayerHistories.ts index 7d5915ec5..b64bcc617 100644 --- a/src/core/blitzstars/getPlayerHistories.ts +++ b/src/core/blitzstars/getPlayerHistories.ts @@ -1,34 +1,13 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; -import { AccountInfo, AllStats } from '../../types/accountInfo'; import { GetHistoriesOptions, Histories, History, getHistoriesDefaultOptions, } from '../../types/histories'; -import getWargamingResponse from '../blitz/getWargamingResponse'; -import { emptyAllStats } from './getDiffedTankStats'; - -export interface PlayerHistoryRaw { - clan: { clan_id: number; name: string; tag: string }; - statistics: { - all: AllStats; - wn8: number; - wn7: number; - average_tier: number; - }; - last_battle_time: number; - nickname: string; - account_id: number; - achievements: { - account_id: number; - [key: string]: number; - }; - region: Region; -} - -export type PlayerHistoriesRaw = PlayerHistoryRaw[]; +import { getAccountInfo } from '../blitz/getAccountInfo'; +import { PlayerStats } from './getPlayerStats'; +import { emptyAllStats } from './getStatsInPeriod'; export default async function getPlayerHistories( server: Region, @@ -39,7 +18,7 @@ export default async function getPlayerHistories( const response = await fetch( `https://www.blitzstars.com/api/playerstats/${id}`, ); - const playerHistories = ((await response.json()) as PlayerHistoriesRaw).map( + const playerHistories = ((await response.json()) as PlayerStats).map( (history) => ({ all: history.statistics.all, @@ -62,16 +41,9 @@ export default async function getPlayerHistories( const latestNodes: Histories = []; if (mergedOptions.includeLatestHistories) { - const { - last_battle_time, - statistics: { all }, - } = ( - await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ) - )[id]; + const { last_battle_time, statistics } = await getAccountInfo(server, id); - latestNodes.push({ all, last_battle_time }); + latestNodes.push({ all: statistics.all, last_battle_time }); } const previousNodes: Histories = []; diff --git a/src/core/blitzstars/getPlayerStats.ts b/src/core/blitzstars/getPlayerStats.ts new file mode 100644 index 000000000..effff133b --- /dev/null +++ b/src/core/blitzstars/getPlayerStats.ts @@ -0,0 +1,38 @@ +import { Region } from '../../constants/regions'; +import { AllStats } from '../blitz/getAccountInfo'; + +export interface IndividualPlayerStats { + clan: { clan_id: number; name: string; tag: string }; + statistics: { + all: AllStats; + wn8: number; + wn7: number; + average_tier: number; + }; + last_battle_time: number; + nickname: string; + account_id: number; + achievements: { + account_id: number; + [key: string]: number; + }; + region: Region; +} + +export type PlayerStats = IndividualPlayerStats[]; + +export async function getPlayerStats< + Ids extends number | number[], + ReturnType = Ids extends number ? PlayerStats : PlayerStats[], +>(region: Region, ids: Ids) { + const stats = await Promise.all( + ((typeof ids === 'number' ? [ids] : ids) as number[]).map((id) => + fetch(`https://www.blitzstars.com/api/playerstats/${id}`).then( + (response) => response.json() as Promise, + ), + ), + ); + + if (typeof ids === 'number') return stats[0] as ReturnType; + return stats as ReturnType; +} diff --git a/src/core/blitzstars/getDiffedTankStats.ts b/src/core/blitzstars/getStatsInPeriod.ts similarity index 70% rename from src/core/blitzstars/getDiffedTankStats.ts rename to src/core/blitzstars/getStatsInPeriod.ts index ccf0f2099..0a703ff07 100644 --- a/src/core/blitzstars/getDiffedTankStats.ts +++ b/src/core/blitzstars/getStatsInPeriod.ts @@ -1,9 +1,9 @@ import { Region } from '../../constants/regions'; -import { AllStats } from '../../types/accountInfo'; +import { AllStats } from '../blitz/getAccountInfo'; import getTankHistories, { TankHistories, TankHistoryRaw, -} from './getTankHistories'; +} from '../blitzkrieg/getTankHistories'; export const emptyAllStats: AllStats = { battles: 0, @@ -34,8 +34,13 @@ export const emptyTankHistoryNode: TankHistoryRaw = { all: emptyAllStats, }; +export interface DiffedTankStats { + diff: Record; + order: number[]; +} + // BIG TODO: merge this neatly with diffNormalizedTankStats -export default async function getDiffedTankStats( +export default async function getStatsInPeriod( server: Region, id: number, start: number, @@ -66,7 +71,7 @@ export default async function getDiffedTankStats( const playedTanks: number[] = []; // fetch history node right before "start" and right before "end" - const diffed: Record = {}; + const diff: Record = {}; inRangeTanks.forEach((tankId) => { const sortedHistory = tankSortedHistory[tankId]; const previousIndex = @@ -77,30 +82,30 @@ export default async function getDiffedTankStats( const latest = sortedHistory[latestIndex] ?? sortedHistory[sortedHistory.length - 1]; - function diff(get: (allStats: AllStats) => number) { + function d(get: (allStats: AllStats) => number) { return get(latest.all) - get(previous.all); } // check if there was a change in battles as games in ratings do update last_battle_time - if (diff((a) => a.battles) > 0) { - diffed[tankId] = { - battles: diff((a) => a.battles), - capture_points: diff((a) => a.capture_points), - damage_dealt: diff((a) => a.damage_dealt), - damage_received: diff((a) => a.damage_received), - dropped_capture_points: diff((a) => a.dropped_capture_points), - frags: diff((a) => a.frags), - frags8p: diff((a) => a.frags8p), - hits: diff((a) => a.hits), - losses: diff((a) => a.losses), + if (d((a) => a.battles) > 0) { + diff[tankId] = { + battles: d((a) => a.battles), + capture_points: d((a) => a.capture_points), + damage_dealt: d((a) => a.damage_dealt), + damage_received: d((a) => a.damage_received), + dropped_capture_points: d((a) => a.dropped_capture_points), + frags: d((a) => a.frags), + frags8p: d((a) => a.frags8p), + hits: d((a) => a.hits), + losses: d((a) => a.losses), max_frags: latest.all.max_frags, max_xp: latest.all.max_xp, - shots: diff((a) => a.shots), - spotted: diff((a) => a.spotted), - survived_battles: diff((a) => a.survived_battles), - win_and_survived: diff((a) => a.win_and_survived), - wins: diff((a) => a.wins), - xp: diff((a) => a.xp), + shots: d((a) => a.shots), + spotted: d((a) => a.spotted), + survived_battles: d((a) => a.survived_battles), + win_and_survived: d((a) => a.win_and_survived), + wins: d((a) => a.wins), + xp: d((a) => a.xp), }; playedTanks.push(tankId); @@ -116,5 +121,5 @@ export default async function getDiffedTankStats( return bLatest.last_battle_time - aLatest.last_battle_time; }); - return { diffed, order }; + return { diff, order } satisfies DiffedTankStats; } diff --git a/src/core/blitzstars/tankAverages.ts b/src/core/blitzstars/tankAverages.ts index 8fd37a864..a210431c6 100644 --- a/src/core/blitzstars/tankAverages.ts +++ b/src/core/blitzstars/tankAverages.ts @@ -1,5 +1,6 @@ -import { AllStats } from '../../types/accountInfo'; +import { AllStats } from '../blitz/getAccountInfo'; import { context } from '../blitzkrieg/context'; +import { tankopedia } from './tankopedia'; export interface SpecialStats { winrate: number; @@ -79,9 +80,10 @@ export interface IndividualTankAverage { last_update: number; } +tankopedia; + export type TankAverages = Record; -console.log('Caching tank averages...'); export const tankAverages = fetch( context === 'bot' ? 'https://www.blitzstars.com/api/tankaverages.json' @@ -97,8 +99,6 @@ export const tankAverages = fetch( individualTankAverage; }); - console.log('Tank averages cached'); - return partialTankAverages; } else { return json as TankAverages; diff --git a/src/core/blitz/tankopedia.ts b/src/core/blitzstars/tankopedia.ts similarity index 80% rename from src/core/blitz/tankopedia.ts rename to src/core/blitzstars/tankopedia.ts index 77e84b852..7be335929 100644 --- a/src/core/blitz/tankopedia.ts +++ b/src/core/blitzstars/tankopedia.ts @@ -1,40 +1,37 @@ import { deburr } from 'lodash'; +import { TreeTypeString } from '../../components/Tanks'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import fetchBlitz from '../blitz/fetchWargaming'; import { context } from '../blitzkrieg/context'; -import getWargamingResponse from './getWargamingResponse'; - -export type TankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank'; export interface TankopediaEntry { name: string; nation: string; is_premium: boolean; is_collectible: boolean; - tier: number; + tier: Tier; cost: { price_credit: number; price_gold: number }; images: { preview: string; normal: string }; tank_id: number; - type: TankType; + type: string; description: string; } - +type asd = TreeTypeString; export interface Tankopedia { [id: number]: TankopediaEntry | undefined; } -console.log('Caching tankopedia...'); export const tankopedia = fetch( context === 'bot' ? 'https://www.blitzstars.com/bs-tankopedia.json' : '/api/tankopedia', ) .then(async (response) => response.json()) - .then((wrapperTankopedia) => { - console.log('Tankopedia cached'); - return context === 'bot' + .then((wrapperTankopedia) => + context === 'bot' ? (wrapperTankopedia as { data: Tankopedia }).data - : (wrapperTankopedia as Tankopedia); - }); + : (wrapperTankopedia as Tankopedia), + ); const entries = new Promise(async (resolve) => { resolve(Object.entries(await tankopedia).map(([, entry]) => entry)); }); @@ -71,27 +68,25 @@ export interface TankopediaInfo { } // this is blocking because info is needed for command creation -console.log('Caching tankopedia info...'); -export const tankopediaInfo = getWargamingResponse( +export const tankopediaInfo = fetchBlitz( `https://api.wotblitz.com/wotb/encyclopedia/info/?application_id=${WARGAMING_APPLICATION_ID}`, ); -console.log('Cached tankopedia info'); -export const TANK_ICONS: Record = { +export const TANK_ICONS: Record = { 'AT-SPG': 'https://i.imgur.com/BIHSEH0.png', lightTank: 'https://i.imgur.com/CSNha5V.png', mediumTank: 'https://i.imgur.com/wvf3ltm.png', heavyTank: 'https://i.imgur.com/ECeqlZa.png', }; -export const TANK_ICONS_PREMIUM: Record = { +export const TANK_ICONS_PREMIUM: Record = { 'AT-SPG': 'https://i.imgur.com/TCu3EdR.png', lightTank: 'https://i.imgur.com/zdkpTRb.png', mediumTank: 'https://i.imgur.com/3z7eHX6.png', heavyTank: 'https://i.imgur.com/P3vbmyA.png', }; -export const TANK_ICONS_COLLECTOR: Record = { +export const TANK_ICONS_COLLECTOR: Record = { 'AT-SPG': 'https://i.imgur.com/WTjeirB.png', lightTank: 'https://i.imgur.com/EwhtKkU.png', mediumTank: 'https://i.imgur.com/u8YDMBh.png', diff --git a/src/core/discord/addFilterOptions.ts b/src/core/discord/addFilterOptions.ts new file mode 100644 index 000000000..9a79642a4 --- /dev/null +++ b/src/core/discord/addFilterOptions.ts @@ -0,0 +1,76 @@ +import { + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks'; +import { tankopediaInfo } from '../blitzstars/tankopedia'; +import addPeriodSubCommands from './addPeriodSubCommands'; +import addTankChoices from './addTankChoices'; +import addTierChoices from './addTierChoices'; + +export default async function addFilterOptions< + OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, +>( + option: OptionType, + extra: ( + option: SlashCommandSubcommandBuilder, + ) => SlashCommandSubcommandBuilder = (option) => option, +): Promise { + const awaitedTankopediaInfo = await tankopediaInfo; + + addPeriodSubCommands(option, (option) => { + extra(option); + + return option + .addStringOption((option) => + option + .setName('nation') + .setDescription('Nation') + .addChoices( + ...Object.entries(awaitedTankopediaInfo.vehicle_nations).map( + ([type, name]) => ({ name, value: type }), + ), + ) + .setRequired(false), + ) + .addStringOption((option) => addTierChoices(option).setRequired(false)) + .addStringOption((option) => + option + .setName('tank-type') + .setDescription('Tank type') + .addChoices( + ...Object.entries(awaitedTankopediaInfo.vehicle_types).map( + ([type, name]) => ({ + name: name.replace(' Tank', ''), + value: type, + }), + ), + ) + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('tree-type') + .setDescription('Tech tree type') + .addChoices( + { + name: TREE_TYPE_NAMES['tech-tree'], + value: 'tech-tree' satisfies TreeTypeString, + }, + { + name: TREE_TYPE_NAMES.premium, + value: 'premium' satisfies TreeTypeString, + }, + { + name: TREE_TYPE_NAMES.collector, + value: 'collector' satisfies TreeTypeString, + }, + ) + .setRequired(false), + ) + .addStringOption((option) => addTankChoices(option).setRequired(false)); + }); + + return option; +} diff --git a/src/core/discord/addPeriodSubCommands.ts b/src/core/discord/addPeriodSubCommands.ts index cd28a4c4b..fa900898a 100644 --- a/src/core/discord/addPeriodSubCommands.ts +++ b/src/core/discord/addPeriodSubCommands.ts @@ -1,55 +1,49 @@ import { + SlashCommandBuilder, SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; -export type PeriodType = 'period' | 'custom'; -export type PeriodSize = `${number}` | 'career'; +export type PeriodType = 'today' | '30' | '60' | '90' | 'career' | 'custom'; +export type PeriodSize = 'today' | `${number}` | 'career'; export default function addPeriodSubCommands( - option: SlashCommandSubcommandGroupBuilder, + option: SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, extra: ( option: SlashCommandSubcommandBuilder, ) => SlashCommandSubcommandBuilder = (option) => option, ) { - return option + option .addSubcommand((option) => - extra( - option - .setName('period' satisfies PeriodType) - .setDescription("Preset period's statistics") - .addStringOption((option) => - option - .setName('period') - .setDescription('A preset period') - .setChoices( - { name: 'Today', value: '1' satisfies PeriodSize }, - { name: '30 days', value: '30' satisfies PeriodSize }, - { name: '60 days', value: '60' satisfies PeriodSize }, - { name: '90 days', value: '90' satisfies PeriodSize }, - { name: 'Career', value: 'career' satisfies PeriodSize }, - ) - .setRequired(true), - ), - ), + extra(option.setName('today').setDescription('Today')), + ) + .addSubcommand((option) => + extra(option.setName('30').setDescription('30 days')), + ) + .addSubcommand((option) => + extra(option.setName('60').setDescription('60 days')), + ) + .addSubcommand((option) => + extra(option.setName('90').setDescription('90 days')), + ) + .addSubcommand((option) => + extra(option.setName('career').setDescription('Career')), ) .addSubcommand((option) => extra( option - .setName('custom' satisfies PeriodType) - .setDescription("Custom period's statistics") + .setName('custom') + .setDescription('Custom range') .addIntegerOption((option) => option .setName('start') - .setDescription('Days ago from today') - .setMinValue(0) + .setDescription('Start in number of days ago from today') .setRequired(true), ) .addIntegerOption((option) => option .setName('end') - .setDescription('Days ago from today') - .setMinValue(0) + .setDescription('End in number of days') .setRequired(true), ), ), diff --git a/src/core/discord/addStatTypeSubCommandGroups.ts b/src/core/discord/addStatTypeSubCommandGroups.ts deleted file mode 100644 index e1866c441..000000000 --- a/src/core/discord/addStatTypeSubCommandGroups.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { TreeTypeString } from '../../components/Tanks'; -import { StatType } from '../../renderers/stats'; -import { tankopediaInfo } from '../blitz/tankopedia'; -import addPeriodSubCommands from './addPeriodSubCommands'; -import addTankChoices from './addTankChoices'; -import addTierChoices from './addTierChoices'; -import addUsernameChoices from './addUsernameChoices'; - -export default async function addStatTypeSubCommandGroups( - option: SlashCommandBuilder, - addMultiTank = true, -) { - const awaitedTankopediaInfo = await tankopediaInfo; - - option - .addSubcommandGroup((option) => - addPeriodSubCommands(option, (option) => - option.addStringOption(addUsernameChoices), - ) - .setName('player' satisfies StatType) - .setDescription('Player statistics'), - ) - .addSubcommandGroup((option) => - addPeriodSubCommands(option, (option) => - option - .addStringOption(addTankChoices) - .addStringOption(addUsernameChoices), - ) - .setName('tank' satisfies StatType) - .setDescription('Tank statistics'), - ); - - if (addMultiTank) - option.addSubcommandGroup((option) => - addPeriodSubCommands(option, (option) => - option - .addStringOption(addUsernameChoices) - .addStringOption((option) => { - option - .setName('nation') - .setDescription('Nation') - .addChoices( - ...Object.entries(awaitedTankopediaInfo.vehicle_nations).map( - ([type, name]) => ({ name, value: type }), - ), - ); - - return option; - }) - .addStringOption((option) => - option - .setName('tank-type') - .setDescription('Type') - .addChoices( - ...Object.entries(awaitedTankopediaInfo.vehicle_types).map( - ([type, name]) => ({ - name: name.replace(' Tank', ''), - value: type, - }), - ), - ) - .setRequired(false), - ) - .addStringOption((option) => - addTierChoices(option).setRequired(false), - ) - .addStringOption((option) => - option - .setName('tree-type') - .setDescription('Tech tree type') - .addChoices( - { - name: 'Tech Tree', - value: 'techtree' satisfies TreeTypeString, - }, - { name: 'Premium', value: 'premium' satisfies TreeTypeString }, - { - name: 'Collector', - value: 'collector' satisfies TreeTypeString, - }, - ) - .setRequired(false), - ), - ) - .setName('multi-tank' satisfies StatType) - .setDescription('Multiple tanks'), - ); - - return option; -} diff --git a/src/core/discord/addTierChoices.ts b/src/core/discord/addTierChoices.ts index 04958d3c6..784096076 100644 --- a/src/core/discord/addTierChoices.ts +++ b/src/core/discord/addTierChoices.ts @@ -1,6 +1,6 @@ import { SlashCommandStringOption } from 'discord.js'; import { range } from 'lodash'; -import numberToRomanNumeral from '../blitz/numberToRomanNumeral'; +import numberToRomanNumeral from '../math/numberToRomanNumeral'; export default function addTierChoices(option: SlashCommandStringOption) { return option diff --git a/src/core/discord/autocompleteClan.ts b/src/core/discord/autocompleteClan.ts index c80d0c2c5..83b4bdc0e 100644 --- a/src/core/discord/autocompleteClan.ts +++ b/src/core/discord/autocompleteClan.ts @@ -4,7 +4,7 @@ import { CacheType, } from 'discord.js'; import { REGION_NAMES } from '../../constants/regions'; -import listClans from '../blitz/listClans'; +import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions'; export default async function autocompleteClan( interaction: AutocompleteInteraction, @@ -12,7 +12,7 @@ export default async function autocompleteClan( const focusedOption = interaction.options.getFocused(true); if (focusedOption.name !== 'clan') return; if (focusedOption.value.length < 2) return interaction.respond([]); - const players = await listClans(focusedOption.value); + const players = await searchClansAcrossRegions(focusedOption.value); await interaction.respond( players @@ -25,6 +25,4 @@ export default async function autocompleteClan( ) : [], ); - - console.log(`Clan autocomplete for ${focusedOption.value}`); } diff --git a/src/core/discord/autocompleteTanks.ts b/src/core/discord/autocompleteTanks.ts index dabd7d25a..adfc8477b 100644 --- a/src/core/discord/autocompleteTanks.ts +++ b/src/core/discord/autocompleteTanks.ts @@ -4,7 +4,7 @@ import { CacheType, } from 'discord.js'; import { go } from 'fuzzysort'; -import { TANK_NAMES_DIACRITICS } from '../blitz/tankopedia'; +import { TANK_NAMES_DIACRITICS } from '../blitzstars/tankopedia'; export default async function autocompleteTanks( interaction: AutocompleteInteraction, @@ -28,6 +28,4 @@ export default async function autocompleteTanks( ) : [], ); - - console.log(`Tanks autocomplete for ${focusedOption.value}`); } diff --git a/src/core/discord/autocompleteUsername.ts b/src/core/discord/autocompleteUsername.ts index f83722c01..8d5428883 100644 --- a/src/core/discord/autocompleteUsername.ts +++ b/src/core/discord/autocompleteUsername.ts @@ -4,14 +4,14 @@ import { CacheType, } from 'discord.js'; import { REGION_NAMES } from '../../constants/regions'; -import listPlayers from '../blitz/listPlayers'; +import searchPlayersAcrossRegions from '../blitz/searchPlayersAcrossRegions'; export default async function autocompleteUsername( interaction: AutocompleteInteraction, ) { const focusedOption = interaction.options.getFocused(true); if (focusedOption.name !== 'username') return; - const players = await listPlayers(focusedOption.value); + const players = await searchPlayersAcrossRegions(focusedOption.value); try { await interaction.respond( @@ -25,11 +25,5 @@ export default async function autocompleteUsername( ) : [], ); - - console.log(`Username autocomplete for ${focusedOption.value}`); - } catch (error) { - console.warn( - `Failed to autocomplete username for ${focusedOption.value} in time`, - ); - } + } catch (error) {} } diff --git a/src/core/discord/linkButton.ts b/src/core/discord/buttonLink.ts similarity index 90% rename from src/core/discord/linkButton.ts rename to src/core/discord/buttonLink.ts index c553b009b..5df2dfdf9 100644 --- a/src/core/discord/linkButton.ts +++ b/src/core/discord/buttonLink.ts @@ -4,7 +4,7 @@ import { ComponentEmojiResolvable, } from 'discord.js'; -export default function linkButton( +export default function buttonLink( url: string, label: string, emoji?: ComponentEmojiResolvable, diff --git a/src/core/discord/primaryButton.ts b/src/core/discord/buttonPrimary.ts similarity index 89% rename from src/core/discord/primaryButton.ts rename to src/core/discord/buttonPrimary.ts index eee16a2f4..9a38a56b5 100644 --- a/src/core/discord/primaryButton.ts +++ b/src/core/discord/buttonPrimary.ts @@ -4,7 +4,7 @@ import { ComponentEmojiResolvable, } from 'discord.js'; -export default function primaryButton( +export default function buttonPrimary( id: string, label: string, emoji?: ComponentEmojiResolvable, diff --git a/src/core/discord/buttonRefresh.ts b/src/core/discord/buttonRefresh.ts new file mode 100644 index 000000000..0a210a006 --- /dev/null +++ b/src/core/discord/buttonRefresh.ts @@ -0,0 +1,16 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import buttonPrimary from './buttonPrimary'; + +export function buttonRefresh( + interaction: ChatInputCommandInteraction, + path: string, +) { + if ( + interaction.appPermissions?.has('AttachFiles') && + interaction.appPermissions?.has('ViewChannel') + ) { + return buttonPrimary(path, 'Refresh'); + } + + return null; +} diff --git a/src/core/discord/client.ts b/src/core/discord/client.ts index 3b9e6bbd8..d79c653d5 100644 --- a/src/core/discord/client.ts +++ b/src/core/discord/client.ts @@ -1,11 +1,10 @@ import { Client, GatewayIntentBits } from 'discord.js'; import guildMemberAdd from '../../events/guildMemberAdd'; import interactionCreate from '../../events/interactionCreate'; -import ready from '../../events/ready'; export const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], }) - .on('ready', ready) + // .on('ready', ready) .on('guildMemberAdd', guildMemberAdd) .on('interactionCreate', interactionCreate); diff --git a/src/core/discord/interactionToURL.ts b/src/core/discord/commandToURL.ts similarity index 67% rename from src/core/discord/interactionToURL.ts rename to src/core/discord/commandToURL.ts index f05d03cb3..bad50d302 100644 --- a/src/core/discord/interactionToURL.ts +++ b/src/core/discord/commandToURL.ts @@ -1,14 +1,13 @@ -import { - CacheType, - ChatInputCommandInteraction, - CommandInteractionOption, -} from 'discord.js'; +import { CacheType, ChatInputCommandInteraction } from 'discord.js'; export interface Parameters { - [key: string]: CommandInteractionOption['value'] | undefined | null; + [key: string]: string | number | boolean | undefined | null; } -export default function interactionToURL( +// TODO: automatically infer command options??? +// but how would that work with the region and id options? + +export default function commandToURL( interaction: ChatInputCommandInteraction, parameters?: Parameters, ) { diff --git a/src/core/discord/getCustomPeriodParams.ts b/src/core/discord/getCustomPeriodParams.ts new file mode 100644 index 000000000..55b7b8aa0 --- /dev/null +++ b/src/core/discord/getCustomPeriodParams.ts @@ -0,0 +1,14 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { PeriodType } from './addPeriodSubCommands'; + +export function getCustomPeriodParams( + interaction: ChatInputCommandInteraction, +) { + if ((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/getFiltersFromButton.ts b/src/core/discord/getFiltersFromButton.ts new file mode 100644 index 000000000..3d906e35b --- /dev/null +++ b/src/core/discord/getFiltersFromButton.ts @@ -0,0 +1,17 @@ +import { ButtonInteraction } from 'discord.js'; +import { TreeTypeString } from '../../components/Tanks'; +import { StatFilters } from '../statistics/filterStats'; + +export function getFiltersFromButton(interaction: ButtonInteraction) { + const url = new URL(`https://example.com/${interaction.customId}`); + + return { + nation: url.searchParams.get('nation') ?? undefined, + tank: parseInt(url.searchParams.get('tank') ?? '0') || undefined, + tankType: url.searchParams.get('tank-type') ?? undefined, + treeType: + (url.searchParams.get('tree-type') as TreeTypeString | undefined) ?? + undefined, + tier: parseInt(url.searchParams.get('tier') ?? '0') || undefined, + } satisfies StatFilters; +} diff --git a/src/core/discord/getFiltersFromCommand.ts b/src/core/discord/getFiltersFromCommand.ts new file mode 100644 index 000000000..3a08f1bad --- /dev/null +++ b/src/core/discord/getFiltersFromCommand.ts @@ -0,0 +1,21 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { TreeTypeString } from '../../components/Tanks'; +import resolveTankId from '../blitz/resolveTankId'; +import { StatFilters } from '../statistics/filterStats'; + +export async function getFiltersFromCommand( + interaction: ChatInputCommandInteraction, +) { + const tankRaw = interaction.options.getString('tank'); + + return { + nation: interaction.options.getString('nation') ?? undefined, + tier: parseInt(interaction.options.getString('tier') ?? '0') || undefined, + tankType: interaction.options.getString('tank-type') ?? undefined, + treeType: + (interaction.options.getString('tree-type') as + | TreeTypeString + | undefined) ?? undefined, + tank: tankRaw === null ? undefined : await resolveTankId(tankRaw), + } satisfies StatFilters; +} diff --git a/src/core/discord/manager.ts b/src/core/discord/manager.ts new file mode 100644 index 000000000..700b47607 --- /dev/null +++ b/src/core/discord/manager.ts @@ -0,0 +1,6 @@ +import { ShardingManager } from 'discord.js'; +import { secrets } from '../blitzkrieg/secrets'; + +export const manager = new ShardingManager(`${__dirname}/bot.cjs`, { + token: secrets.DISCORD_TOKEN, +}); diff --git a/src/core/discord/normalizeInteractionReturnable.ts b/src/core/discord/normalizeInteractionReturnable.ts index 3f354735b..cdb0e2208 100644 --- a/src/core/discord/normalizeInteractionReturnable.ts +++ b/src/core/discord/normalizeInteractionReturnable.ts @@ -1,11 +1,12 @@ import { ActionRowBuilder, + AttachmentBuilder, ButtonBuilder, EmbedBuilder, InteractionEditReplyOptions, } from 'discord.js'; import { InteractionReturnable } from '../../events/interactionCreate'; -import jsxToPng from '../node/jsxToPng'; +import jsxToPngThreaded from '../blitzkrieg/jsxToPngThreaded'; export default async function normalizeInteractionReturnable( returnable: InteractionReturnable, @@ -30,8 +31,13 @@ export default async function normalizeInteractionReturnable( (reply.components[0] as ActionRowBuilder).addComponents( item, ); + } else if (item instanceof AttachmentBuilder) { + if (!reply.files) reply.files = []; + reply.files.push(item); + } else if (item === null) { + return; } else { - const image = await jsxToPng(item); + const image = await jsxToPngThreaded(item); if (!reply.files) reply.files = []; reply.files.push(image); } diff --git a/src/core/discord/psa.ts b/src/core/discord/psa.ts index 48252aca0..06441f663 100644 --- a/src/core/discord/psa.ts +++ b/src/core/discord/psa.ts @@ -9,12 +9,10 @@ export interface PSA { export const psa: PSA = { data: null }; -console.log('Caching PSA...'); fetch( 'https://gist.githubusercontent.com/tresabhi/ed4b136f08856e615212e573aabe1968/raw', ) .then((response) => response.json()) .then((data) => { psa.data = data as PSAData; - console.log('PSA cached'); }); diff --git a/src/core/discord/resolveClanFromCommand.ts b/src/core/discord/resolveClanFromCommand.ts index 0e47d700b..f9828d0c0 100644 --- a/src/core/discord/resolveClanFromCommand.ts +++ b/src/core/discord/resolveClanFromCommand.ts @@ -1,7 +1,7 @@ import { ChatInputCommandInteraction } from 'discord.js'; import { Region } from '../../constants/regions'; -import listClans from '../blitz/listClans'; -import throwError from '../node/throwError'; +import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions'; +import throwError from '../blitzkrieg/throwError'; import { serverAndIdPattern } from './resolvePlayerFromCommand'; export default async function resolveClanFromCommand( @@ -13,7 +13,7 @@ export default async function resolveClanFromCommand( const [server, accountId] = clan.split('/'); return { region: server as Region, id: Number(accountId) }; } else { - const accounts = await listClans(clan); + const accounts = await searchClansAcrossRegions(clan); if (accounts[0]) { return { region: accounts[0].region, id: accounts[0].clan_id }; diff --git a/src/core/discord/resolvePeriodFromButton.ts b/src/core/discord/resolvePeriodFromButton.ts index 8267c3dce..5498b7553 100644 --- a/src/core/discord/resolvePeriodFromButton.ts +++ b/src/core/discord/resolvePeriodFromButton.ts @@ -1,11 +1,43 @@ -import { ButtonInteraction, CacheType } from 'discord.js'; -import { CYCLIC_API } from '../../constants/cyclic'; +import { ButtonInteraction } from 'discord.js'; import { Region } from '../../constants/regions'; -import resolvePeriodFromURL from './resolvePeriodFromURL'; +import getPeriodNow from '../blitzkrieg/getPeriodNow'; +import getPeriodStart from '../blitzkrieg/getPeriodStart'; +import getTimeDaysAgo from '../blitzkrieg/getTimeDaysAgo'; +import { PeriodType } from './addPeriodSubCommands'; +import { + ResolvedPeriod, + getPeriodOptionName, +} from './resolvePeriodFromCommand'; export default function resolvePeriodFromButton( - server: Region, - interaction: ButtonInteraction, + region: Region, + interaction: ButtonInteraction, ) { - return resolvePeriodFromURL(server, `${CYCLIC_API}/${interaction.customId}`); + let name: string; + let start: number; + let end: number; + const url = new URL(`https://example.com/${interaction.customId}`); + const path = url.pathname.split('/'); + const periodSubcommand = path.at(-1) as PeriodType; + + if (periodSubcommand === 'custom') { + const startRaw = parseInt(url!.searchParams.get('start')!); + const endRaw = parseInt(url!.searchParams.get('end')!); + const startDaysAgoMin = Math.min(startRaw, endRaw); + const endDaysAgoMax = Math.max(startRaw, endRaw); + + name = `${startDaysAgoMin} - ${endDaysAgoMax} days`; + start = getTimeDaysAgo(region, endDaysAgoMax); + end = getTimeDaysAgo(region, startDaysAgoMin); + } else { + name = getPeriodOptionName(periodSubcommand); + start = getPeriodStart(region, periodSubcommand); + end = getPeriodNow(); + } + + return { + name, + start, + end, + } satisfies ResolvedPeriod; } diff --git a/src/core/discord/resolvePeriodFromCommand.ts b/src/core/discord/resolvePeriodFromCommand.ts index 564226944..a09da237d 100644 --- a/src/core/discord/resolvePeriodFromCommand.ts +++ b/src/core/discord/resolvePeriodFromCommand.ts @@ -1,17 +1,18 @@ import { CacheType, ChatInputCommandInteraction } from 'discord.js'; import { Region } from '../../constants/regions'; -import getPeriodNow from '../blitzstars/getPeriodNow'; -import getPeriodStart from '../blitzstars/getPeriodStart'; -import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo'; -import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands'; +import getPeriodNow from '../blitzkrieg/getPeriodNow'; +import getPeriodStart from '../blitzkrieg/getPeriodStart'; +import getTimeDaysAgo from '../blitzkrieg/getTimeDaysAgo'; +import { PeriodSize, PeriodType } from './addPeriodSubCommands'; export function getPeriodOptionName(period: PeriodSize) { - return period === 'career' ? 'Career' : `${period}-day`; + if (period === 'career') return 'Career'; + if (period === 'today') return 'Today'; + return `${period} days`; } export interface ResolvedPeriod { - statsName: string; - evolutionName: string; + name: string; start: number; end: number; } @@ -20,33 +21,28 @@ export default function resolvePeriodFromCommand( region: Region, interaction: ChatInputCommandInteraction, ) { - let statsName: string; - let evolutionName: string; + let name: string; let start: number; let end: number; const periodSubcommand = interaction.options.getSubcommand() as PeriodType; - const periodOption = interaction.options.getString('period') as PeriodSize; if (periodSubcommand === 'custom') { - const startRaw = interaction.options.getInteger('start', true); - const endRaw = interaction.options.getInteger('end', true); - const startDaysAgoMin = Math.min(startRaw, endRaw); - const endDaysAgoMax = Math.max(startRaw, endRaw); + const startOption = interaction.options.getInteger('start', true); + const endOption = interaction.options.getInteger('end', true); + const startDaysAgoMin = Math.min(startOption, endOption); + const endDaysAgoMax = Math.max(startOption, endOption); - statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`; - evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`; + name = `${startDaysAgoMin} - ${endDaysAgoMax} days`; start = getTimeDaysAgo(region, endDaysAgoMax); end = getTimeDaysAgo(region, startDaysAgoMin); } else { - statsName = `${getPeriodOptionName(periodOption)} Statistics`; - evolutionName = `${getPeriodOptionName(periodOption)} Evolution`; - start = getPeriodStart(region, periodOption); + name = getPeriodOptionName(periodSubcommand); + start = getPeriodStart(region, periodSubcommand); end = getPeriodNow(); } return { - statsName, - evolutionName, + name, start, end, } satisfies ResolvedPeriod; diff --git a/src/core/discord/resolvePeriodFromURL.ts b/src/core/discord/resolvePeriodFromURL.ts deleted file mode 100644 index 2f275e4c3..000000000 --- a/src/core/discord/resolvePeriodFromURL.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Region } from '../../constants/regions'; -import getPeriodNow from '../blitzstars/getPeriodNow'; -import getPeriodStart from '../blitzstars/getPeriodStart'; -import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo'; -import { PeriodSize, PeriodType } from './addPeriodSubCommands'; -import { - ResolvedPeriod, - getPeriodOptionName, -} from './resolvePeriodFromCommand'; - -export default function resolvePeriodFromURL( - server: Region, - urlString: string, -) { - let statsName: string; - let evolutionName: string; - let start: number; - let end: number; - const url = new URL(urlString); - const path = url.pathname.split('/').filter(Boolean); - const periodSubcommand = path[path.length - 1] as PeriodType; - const periodOption = url.searchParams.get('period') as PeriodSize; - - if (periodSubcommand === 'custom') { - const startRaw = parseInt(url!.searchParams.get('start')!); - const endRaw = parseInt(url!.searchParams.get('end')!); - const startDaysAgoMin = Math.min(startRaw, endRaw); - const endDaysAgoMax = Math.max(startRaw, endRaw); - - statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`; - evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`; - start = getTimeDaysAgo(server, endDaysAgoMax); - end = getTimeDaysAgo(server, startDaysAgoMin); - } else { - statsName = `${getPeriodOptionName(periodOption)} Statistics`; - evolutionName = `${getPeriodOptionName(periodOption)} Evolution`; - start = getPeriodStart(server, periodOption); - end = getPeriodNow(); - } - - return { statsName, evolutionName, start, end } satisfies ResolvedPeriod; -} diff --git a/src/core/discord/resolvePlayerFromButton.ts b/src/core/discord/resolvePlayerFromButton.ts index 8369c4c1b..8d5a5a4b1 100644 --- a/src/core/discord/resolvePlayerFromButton.ts +++ b/src/core/discord/resolvePlayerFromButton.ts @@ -1,9 +1,14 @@ -import { ButtonInteraction, CacheType } from 'discord.js'; -import { CYCLIC_API } from '../../constants/cyclic'; -import resolvePlayerFromURL from './resolvePlayerFromURL'; +import { ButtonInteraction } from 'discord.js'; +import { Region } from '../../constants/regions'; +import { ResolvedPlayer } from './resolvePlayerFromCommand'; export default async function resolvePlayerFromButton( - interaction: ButtonInteraction, + interaction: ButtonInteraction, ) { - return resolvePlayerFromURL(`${CYCLIC_API}/${interaction.customId}`); + const url = new URL(`https://exmaple.com/${interaction.customId}`); + + return { + id: parseInt(url.searchParams.get('id')!), + region: url.searchParams.get('region') as Region, + } satisfies ResolvedPlayer; } diff --git a/src/core/discord/resolvePlayerFromCommand.ts b/src/core/discord/resolvePlayerFromCommand.ts index 38e4efa2f..5a53615c0 100644 --- a/src/core/discord/resolvePlayerFromCommand.ts +++ b/src/core/discord/resolvePlayerFromCommand.ts @@ -1,9 +1,9 @@ import { CacheType, ChatInputCommandInteraction } from 'discord.js'; +import markdownEscape from 'markdown-escape'; import { Region } from '../../constants/regions'; -import listPlayers, { - usernamePatternWithoutPosition, -} from '../blitz/listPlayers'; -import throwError from '../node/throwError'; +import searchPlayersAcrossRegions from '../blitz/searchPlayersAcrossRegions'; +import { getBlitzFromDiscord } from '../blitzkrieg/discordBlitz'; +import throwError from '../blitzkrieg/throwError'; export const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/; @@ -16,36 +16,41 @@ export default async function resolvePlayerFromCommand( interaction: ChatInputCommandInteraction, ) { const commandUsername = interaction.options.getString('username'); - const displayName = interaction.guild?.members.cache - .get(interaction.user.id) - ?.displayName.match(usernamePatternWithoutPosition)?.[0]; - const username = commandUsername ?? displayName; - if (username === undefined) { - throw throwError( - 'Username could not be inferred', - "You didn't provide me a username in the command nor was I able to infer your Blitz username from your Discord nickname. Try providing a username manually in the command or using the `/verify` command to set your Discord nickname to your Blitz username.", - ); - } + if (commandUsername) { + if (serverAndIdPattern.test(commandUsername)) { + const [server, accountId] = commandUsername.split('/'); - if (serverAndIdPattern.test(username)) { - const [server, accountId] = username.split('/'); - return { - region: server as Region, - id: Number(accountId), - } satisfies ResolvedPlayer; - } else { - const accounts = await listPlayers(username); - - if (accounts[0]) { return { - region: accounts[0].region, - id: accounts[0].account_id, + region: server as Region, + id: Number(accountId), } satisfies ResolvedPlayer; + } else { + const accounts = await searchPlayersAcrossRegions(commandUsername); + + if (accounts[0]) { + return { + region: accounts[0].region, + id: accounts[0].account_id, + } satisfies ResolvedPlayer; + } else { + throw throwError( + 'Could not find user', + `I 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 { + const account = await getBlitzFromDiscord(parseInt(interaction.user.id)); + + if (account) { + return { region: account.region, id: account.blitz }; } else { throw throwError( - 'Could not find user', - `I couldn't find user \`${username}\`. Try providing a valid username manually or using the \`/verify\` command to set your Discord nickname to your Blitz username.`, + "You're not linked", + 'Use the `/link` command to link your Discord and Blitz accounts.', ); } } diff --git a/src/core/discord/resolvePlayerFromURL.ts b/src/core/discord/resolvePlayerFromURL.ts deleted file mode 100644 index 33c369a0c..000000000 --- a/src/core/discord/resolvePlayerFromURL.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Region } from '../../constants/regions'; -import { ResolvedPlayer } from '../discord/resolvePlayerFromCommand'; - -export default function resolvePlayerFromURL(urlString: string) { - const url = new URL(urlString); - - return { - id: parseInt(url.searchParams.get('id')!), - region: url.searchParams.get('region') as Region, - } satisfies ResolvedPlayer; -} diff --git a/src/core/node/isNumber.ts b/src/core/math/isNumber.ts similarity index 100% rename from src/core/node/isNumber.ts rename to src/core/math/isNumber.ts diff --git a/src/core/blitz/numberToRomanNumeral.ts b/src/core/math/numberToRomanNumeral.ts similarity index 100% rename from src/core/blitz/numberToRomanNumeral.ts rename to src/core/math/numberToRomanNumeral.ts diff --git a/src/core/blitz/calculateWN8.ts b/src/core/statistics/calculateWN8.ts similarity index 89% rename from src/core/blitz/calculateWN8.ts rename to src/core/statistics/calculateWN8.ts index 1c3a1fc24..c169bc148 100644 --- a/src/core/blitz/calculateWN8.ts +++ b/src/core/statistics/calculateWN8.ts @@ -1,7 +1,10 @@ -import { AllStats } from '../../types/accountInfo'; +import { AllStats } from '../blitz/getAccountInfo'; /** * Formula from https://web.archive.org/web/20190105064817/http://wiki.wnefficiency.net/pages/WN8 + * + * @param expected what all players on average perform like + * @param average what the player in question performs like */ export default function calculateWN8(expected: AllStats, average: AllStats) { const avgDmg = average.damage_dealt / average.battles; diff --git a/src/core/blitz/diffNormalizedTankStats.ts b/src/core/statistics/deltaTankStats.ts similarity index 96% rename from src/core/blitz/diffNormalizedTankStats.ts rename to src/core/statistics/deltaTankStats.ts index 35f4a26c2..83c017a43 100644 --- a/src/core/blitz/diffNormalizedTankStats.ts +++ b/src/core/statistics/deltaTankStats.ts @@ -4,10 +4,7 @@ import { emptyIndividualTankStats, } from '../../types/tanksStats'; -export function diffNormalizedTankStats( - a: NormalizedTankStats, - b: NormalizedTankStats, -) { +export function deltaTankStats(a: NormalizedTankStats, b: NormalizedTankStats) { const bTanks = Object.keys(b); const changedTanks = bTanks.filter((id) => { return ( diff --git a/src/core/statistics/filterStats.ts b/src/core/statistics/filterStats.ts new file mode 100644 index 000000000..a0b3ecc55 --- /dev/null +++ b/src/core/statistics/filterStats.ts @@ -0,0 +1,77 @@ +import { TreeTypeString } from '../../components/Tanks'; +import { DiffedTankStats } from '../blitzstars/getStatsInPeriod'; +import { tankAverages } from '../blitzstars/tankAverages'; +import { tankopedia } from '../blitzstars/tankopedia'; +import calculateWN8 from './calculateWN8'; +import sumStats from './sumStats'; + +export interface StatFilters { + nation?: string; + tier?: number; + tankType?: string; + treeType?: TreeTypeString; + tank?: number; +} + +export async function filterStats( + { diff, order }: DiffedTankStats, + filters: StatFilters, +) { + // tank, if provided, takes priority over all other filters + if (filters.tank) filters = { tank: filters.tank }; + + const awaitedTankopedia = await tankopedia; + const awaitedTankAverages = await tankAverages; + const filteredOrder = order.filter((id) => { + const entry = awaitedTankopedia[id]; + + return ( + entry && + (filters.nation === undefined || entry.nation === filters.nation) && + (filters.tier === undefined || entry.tier === filters.tier) && + (filters.tankType === undefined || entry.type === filters.tankType) && + (filters.treeType === undefined || + (filters.treeType === 'collector' && entry.is_collectible) || + (filters.treeType === 'premium' && + entry.is_premium && + !entry.is_collectible) || + (filters.treeType === 'tech-tree' && + !entry.is_collectible && + !entry.is_premium)) && + (filters.tank === undefined || entry.tank_id === filters.tank) + ); + }); + const stats = sumStats(filteredOrder.map((id) => diff[id])); + const battlesOfTanksWithAverages = filteredOrder.reduce( + (accumulator, id) => + awaitedTankAverages[id] ? accumulator + diff[id].battles : accumulator, + 0, + ); + const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce( + (accumulator, id) => + awaitedTankopedia[id] ? accumulator + diff[id].battles : accumulator, + 0, + ); + const supplementary = { + WN8: + filteredOrder.reduce( + (accumulator, id) => + awaitedTankAverages[id] + ? accumulator + + calculateWN8(awaitedTankAverages[id].all, diff[id]) * + diff[id].battles + : accumulator, + 0, + ) / battlesOfTanksWithAverages, + tier: + filteredOrder.reduce( + (accumulator, id) => + awaitedTankopedia[id] + ? accumulator + awaitedTankopedia[id]!.tier * diff[id].battles + : accumulator, + 0, + ) / battlesOfTanksWithTankopediaEntry, + }; + + return { stats, supplementary, filteredOrder }; +} diff --git a/src/core/statistics/getTierWeights.ts b/src/core/statistics/getTierWeights.ts new file mode 100644 index 000000000..a31dde982 --- /dev/null +++ b/src/core/statistics/getTierWeights.ts @@ -0,0 +1,22 @@ +import { TierWeightsRecord } from '../../components/TierWeights'; +import { AllStats } from '../blitz/getAccountInfo'; +import { tankopedia } from '../blitzstars/tankopedia'; + +export async function getTierWeights( + stats: Record, + order: number[] = Object.keys(stats).map(parseInt), +) { + const awaitedTankopedia = await tankopedia; + const tierWeights: TierWeightsRecord = {}; + + order.map((id) => { + const entry = awaitedTankopedia[id]; + + if (!entry) return; + if (!tierWeights[entry.tier]) tierWeights[entry.tier] = 0; + + tierWeights[entry.tier]! += stats[id].battles; + }); + + return tierWeights; +} diff --git a/src/core/blitz/getWN8Percentile.ts b/src/core/statistics/getWN8Percentile.ts similarity index 100% rename from src/core/blitz/getWN8Percentile.ts rename to src/core/statistics/getWN8Percentile.ts diff --git a/src/core/blitz/sumStats.ts b/src/core/statistics/sumStats.ts similarity index 90% rename from src/core/blitz/sumStats.ts rename to src/core/statistics/sumStats.ts index 160ddf593..10c692c0b 100644 --- a/src/core/blitz/sumStats.ts +++ b/src/core/statistics/sumStats.ts @@ -1,5 +1,5 @@ -import { AllStats } from '../../types/accountInfo'; -import { emptyAllStats } from '../blitzstars/getDiffedTankStats'; +import { AllStats } from '../blitz/getAccountInfo'; +import { emptyAllStats } from '../blitzstars/getStatsInPeriod'; export default function sumStats(allStatsCollection: AllStats[]) { return allStatsCollection.reduce((accumulated, current) => { diff --git a/src/events/error.ts b/src/events/error.ts deleted file mode 100644 index 537e06b29..000000000 --- a/src/events/error.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EmbedBuilder, TextChannel } from 'discord.js'; -import discord from '../../discord.json' assert { type: 'json' }; -import { NEGATIVE_COLOR } from '../constants/colors'; -import { client } from '../core/discord/client'; -import isDev from '../core/node/isDev'; - -const PROCESS_ERROR_EVENTS = ['uncaughtException']; -const CLIENT_ERROR_EVENTS = ['error']; - -export function handleError(error: Error, command: string) { - console.error(error); - - ( - client.guilds.cache - .get(discord.tres_guild_id) - ?.channels.cache.get(discord.tres_log_channel) as TextChannel - ).send({ - embeds: [ - new EmbedBuilder() - .setTitle(`${client.user?.username} ran into an error`) - .setColor(NEGATIVE_COLOR) - .setDescription( - `\`\`\`${[command, error.name, error.message, error.stack] - .filter((item) => item !== undefined) - .join('\n\n')}\`\`\``, - ), - ], - }); -} - -export function registerErrorHandlers() { - if (!isDev()) { - PROCESS_ERROR_EVENTS.forEach((name) => - process.on(name, (error) => handleError(error, 'process error')), - ); - CLIENT_ERROR_EVENTS.forEach((name) => - client.on(name, (error) => handleError(error, 'client error')), - ); - } -} diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index a8182b3a4..ad798295f 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,4 +1,10 @@ -import { EmbedBuilder, GuildMember, TextChannel } from 'discord.js'; +import { + AttachmentBuilder, + EmbedBuilder, + GuildMember, + TextChannel, +} from 'discord.js'; +import markdownEscape from 'markdown-escape'; import discord from '../../discord.json' assert { type: 'json' }; import { ACCENT_COLOR } from '../constants/colors'; @@ -13,13 +19,12 @@ export default async function (member: GuildMember) { embeds: [ new EmbedBuilder() .setColor(ACCENT_COLOR) - .setTitle(`Welcome ${member.user.username}`) + .setTitle(`Welcome, ${markdownEscape(member.user.displayName)}`) .setDescription( - `Welcome to the Skilled server, **${member.user.username}**! To continue, please use the \`/verify\` command.\n\nExample: \`/verify Sabina244\``, + `Welcome to the Skilled server! To continue, please use the \`/link\` command.`, ), ], + files: [new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif')], }); - - console.log(`${member.user.tag} joined`); } } diff --git a/src/events/interactionCreate/handlers/button.tsx b/src/events/interactionCreate/handlers/button.tsx index cac6fddb5..4394a54ce 100644 --- a/src/events/interactionCreate/handlers/button.tsx +++ b/src/events/interactionCreate/handlers/button.tsx @@ -1,15 +1,14 @@ import { ButtonInteraction, CacheType } from 'discord.js'; import { commands } from '..'; -import { CYCLIC_API } from '../../../constants/cyclic'; +import throwError from '../../../core/blitzkrieg/throwError'; import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable'; -import throwError from '../../../core/node/throwError'; export default async function handleButton( interaction: ButtonInteraction, ) { await interaction.deferUpdate(); - const url = new URL(`${CYCLIC_API}/${interaction.customId}`); + const url = new URL(`https://example.com/${interaction.customId}`); const commandName = url.pathname.split('/')[1]; const button = (await commands)[commandName]?.button; diff --git a/src/events/interactionCreate/handlers/chatInputCommand.ts b/src/events/interactionCreate/handlers/chatInputCommand.ts index e603b4048..039e4fd0e 100644 --- a/src/events/interactionCreate/handlers/chatInputCommand.ts +++ b/src/events/interactionCreate/handlers/chatInputCommand.ts @@ -7,18 +7,16 @@ import { } from 'discord.js'; import { commands } from '..'; import discord from '../../../../discord.json' assert { type: 'json' }; +import { psa } from '../../../core/discord/psa'; import embedNegative from '../../../core/discord/embedNegative'; import embedWarning from '../../../core/discord/embedWarning'; import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable'; -import { psa } from '../../../core/discord/psa'; -import { handleError } from '../../error'; export default async function handleChatInputCommand( interaction: ChatInputCommandInteraction, ) { const registry = (await commands)[interaction.commandName]; - console.log(interaction.toString()); await interaction.deferReply(); try { @@ -55,6 +53,8 @@ export default async function handleChatInputCommand( .setStyle(ButtonStyle.Link), ); + console.error(interaction.commandName, error); + await interaction.editReply({ embeds: [ embedNegative( @@ -64,7 +64,5 @@ export default async function handleChatInputCommand( ], components: [actionRow], }); - - handleError(error as Error, interaction.commandName); } } diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts index 823eca426..7a41cc454 100644 --- a/src/events/interactionCreate/index.ts +++ b/src/events/interactionCreate/index.ts @@ -1,4 +1,5 @@ import { + AttachmentBuilder, AutocompleteInteraction, ButtonBuilder, ButtonInteraction, @@ -14,12 +15,13 @@ import { } from 'discord.js'; import discord from '../../../discord.json' assert { type: 'json' }; import { aboutCommand } from '../../commands/about'; -import { clanEventCommand } from '../../commands/clanEvent'; +import { breakdownCommand } from '../../commands/breakdown'; import { debugCommand } from '../../commands/debug'; import { eligibleCommand } from '../../commands/eligible'; import { evolutionCommand } from '../../commands/evolution'; import { fullStatsCommand } from '../../commands/fullStats'; import { inactiveCommand } from '../../commands/inactive'; +import { verifyCommand } from '../../commands/link'; import { ownedTanksCommand } from '../../commands/ownedTanks'; import { pingCommand } from '../../commands/ping'; import { playerAchievementsCommand } from '../../commands/playerAchievements'; @@ -30,10 +32,9 @@ import { searchPlayersCommand } from '../../commands/searchPlayers'; import { searchTanksCommand } from '../../commands/searchTanks'; import { statsCommand } from '../../commands/stats'; import { todayCommand } from '../../commands/today'; -import { verifyCommand } from '../../commands/verify'; -import getClientId from '../../core/node/getClientId'; -import isDev from '../../core/node/isDev'; -import { secrets } from '../../core/node/secrets'; +import getClientId from '../../core/blitzkrieg/getClientId'; +import isDev from '../../core/blitzkrieg/isDev'; +import { secrets } from '../../core/blitzkrieg/secrets'; import handleAutocomplete from './handlers/autocomplete'; import handleButton from './handlers/button'; import handleChatInputCommand from './handlers/chatInputCommand'; @@ -42,7 +43,9 @@ export type InteractionRawReturnable = | string | EmbedBuilder | ButtonBuilder - | JSX.Element; + | AttachmentBuilder + | JSX.Element + | null; export type InteractionIterableReturnable = | InteractionRawReturnable | InteractionRawReturnable[]; @@ -93,13 +96,13 @@ export const COMMANDS_RAW: CommandRegistry[] = [ searchPlayersCommand, searchTanksCommand, fullStatsCommand, - todayCommand, + breakdownCommand, verifyCommand, pingCommand, evolutionCommand, statsCommand, ratingsCommand, - clanEventCommand, + todayCommand, ]; export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) => @@ -133,31 +136,18 @@ commands.then((awaitedCommands) => { }); try { - console.log(`Refreshing ${publicCommands.length} public command(s).`); - rest - .put(Routes.applicationCommands(getClientId()), { body: publicCommands }) - .then((publicData) => { - console.log( - `Successfully refreshed ${ - (publicData as RESTPostAPIChatInputApplicationCommandsJSONBody[]) - .length - } public command(s).`, - ); - }); + rest.put(Routes.applicationCommands(getClientId()), { + body: publicCommands, + }); - console.log(`Refreshing ${guildCommands.length} guild command(s).`); - rest - .put( - Routes.applicationGuildCommands(getClientId(), discord.tres_guild_id), - { body: guildCommands }, - ) - .then((guildData) => { - console.log( - `Successfully refreshed ${ - (guildData as unknown[]).length - } guild command(s).`, - ); - }); + (isDev() + ? [discord.test_guild_id] + : [discord.tres_guild_id, discord.sklld_guild_id] + ).forEach((guildId) => + rest.put(Routes.applicationGuildCommands(getClientId(), guildId), { + body: guildCommands, + }), + ); } catch (error) { console.error(error); } diff --git a/src/events/ready.ts b/src/events/ready.ts index cf6996d04..3c24d0ab2 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,10 +1,5 @@ import { Client } from 'discord.js'; -import isDev from '../core/node/isDev'; export default function ready(client: Client) { - console.log( - `Logged in as ${client.user.tag} in ${ - isDev() ? 'development' : 'production' - } mode across ${client.guilds.cache.size} servers`, - ); + console.log(`🟢 Launched bot ${client.shard?.ids[0]}`); } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..abd416e69 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +// import { manager } from './core/discord/manager'; + +// manager.on('shardCreate', (shard) => { +// console.log(`🟡 Launching shard ${shard.id}`); +// shard.on('ready', () => console.log(`🟢 Launched shard ${shard.id}`)); +// }); + +// manager.spawn(); + +// TODO: re-enable if needed in the future + +import './bot'; diff --git a/src/renderers/evolution.tsx b/src/renderers/evolution.tsx deleted file mode 100644 index 2f140de09..000000000 --- a/src/renderers/evolution.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import * as Graph from '../components/Graph'; -import NoData, { NoDataType } from '../components/NoData'; -import TitleBar from '../components/TitleBar'; -import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankName from '../core/blitz/resolveTankName'; -import { tankopedia } from '../core/blitz/tankopedia'; -import getPlayerHistories from '../core/blitzstars/getPlayerHistories'; -import getTankHistories from '../core/blitzstars/getTankHistories'; -import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand'; -import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand'; -import { AccountInfo } from '../types/accountInfo'; -import { Histories } from '../types/histories'; -import { PlayerClanData } from '../types/playerClanData'; -import { StatType } from './stats'; - -export default async function evolution( - type: Type, - { start, end, evolutionName }: ResolvedPeriod, - { region: server, id }: ResolvedPlayer, - tankId: Type extends 'tank' ? number : null, -) { - let nameDiscriminator: string | undefined; - let image: string | undefined; - - if (type === 'player') { - const clan = ( - await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ) - )[id]?.clan; - - if (clan) nameDiscriminator = `[${clan.tag}]`; - image = clan - ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` - : undefined; - } else { - nameDiscriminator = `(${await resolveTankName(tankId!)})`; - image = (await tankopedia)[tankId!]?.images.normal; - } - - let histories: Histories; - - if (type === 'player') { - histories = await getPlayerHistories(server, id, { - start, - end, - includeLatestHistories: true, - includePreviousHistories: true, - }); - } else { - histories = await getTankHistories(server, id, { - tankId: tankId!, - start, - end, - includeLatestHistories: true, - includePreviousHistories: true, - }); - } - - const plot = histories.map( - (history) => - [ - history.all.battles, - (history.all.wins / history.all.battles) * 100, - ] as Graph.PlotItem, - ); - const times = histories.map((history) => history.last_battle_time); - const minTime = Math.min(...times); - const maxTime = Math.max(...times); - const ys = plot.map(([, y]) => y); - const xs = plot.map(([x]) => x); - const maxY = Math.max(...ys); - const minY = Math.min(...ys); - const maxX = Math.max(...xs); - const minX = Math.min(...xs); - - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - - return ( - - - - {/* goofy ahh bug forces me to call them as functions */} - {plot.length > 0 && ( - - {Graph.Line({ - // career winrate - color: Graph.LineColor.Red, - plot: plot, - minY, - maxY, - })} - - )} - - {plot.length === 0 && } - - ); -} diff --git a/src/renderers/fullStats.tsx b/src/renderers/fullStats.tsx deleted file mode 100644 index e69f28c29..000000000 --- a/src/renderers/fullStats.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import GenericAllStats from '../components/GenericAllStats'; -import NoData, { NoDataType } from '../components/NoData'; -import TierWeights, { TierWeightsRecord } from '../components/TierWeights'; -import TitleBar from '../components/TitleBar'; -import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import calculateWN8 from '../core/blitz/calculateWN8'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankName from '../core/blitz/resolveTankName'; -import sumStats from '../core/blitz/sumStats'; -import { Tier, tankopedia } from '../core/blitz/tankopedia'; -import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats'; -import { tankAverages } from '../core/blitzstars/tankAverages'; -import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand'; -import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand'; -import { - AccountInfo, - AllStats, - SupplementaryStats, -} from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; -import { MultiTankFilters, StatType } from './stats'; - -export default async function fullStats( - type: Type, - { start, end, statsName }: ResolvedPeriod, - { region: server, id }: ResolvedPlayer, - filters: Type extends 'tank' - ? number - : Type extends 'multi-tank' - ? Partial - : null, -) { - let nameDiscriminator: string | undefined; - let image: string | undefined; - - if (type === 'player' || type === 'multi-tank') { - const clan = ( - await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ) - )[id]?.clan; - - if (clan) nameDiscriminator = `[${clan.tag}]`; - image = clan - ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` - : undefined; - } else if (type === 'tank') { - nameDiscriminator = `(${await resolveTankName(filters as number)})`; - image = (await tankopedia)[filters as number]?.images.normal; - } - - const { diffed, order } = await getDiffedTankStats(server, id, start, end); - const awaitedTankopedia = await tankopedia; - let stats: AllStats | undefined; - let supplementaryStats: SupplementaryStats; - let tierWeights: TierWeightsRecord; - - if (type === 'player' || type === 'multi-tank') { - const typedFilters = filters as Partial | null; - const filteredOrder = order.filter( - (id) => - typedFilters === null || - ((typedFilters.nation === undefined || - awaitedTankopedia[id]?.nation === typedFilters.nation) && - (typedFilters['tank-type'] === undefined || - awaitedTankopedia[id]?.type === typedFilters['tank-type']) && - (typedFilters.tier === undefined || - awaitedTankopedia[id]?.tier === typedFilters.tier) && - (typedFilters['tree-type'] === undefined || - (typedFilters['tree-type'] === 'collector' && - awaitedTankopedia[id]?.is_collectible) || - (typedFilters['tree-type'] === 'premium' && - awaitedTankopedia[id]?.is_premium) || - (typedFilters['tree-type'] === 'techtree' && - !awaitedTankopedia[id]?.is_collectible && - !awaitedTankopedia[id]?.is_premium))), - ); - const awaitedTankAverages = await tankAverages; - stats = sumStats(filteredOrder.map((id) => diffed[id])); - const battlesOfTanksWithAverages = filteredOrder.reduce( - (accumulator, id) => - awaitedTankAverages[id] - ? accumulator + diffed[id].battles - : accumulator, - 0, - ); - const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce( - (accumulator, id) => - awaitedTankopedia[id] ? accumulator + diffed[id].battles : accumulator, - 0, - ); - - supplementaryStats = { - WN8: - filteredOrder.reduce( - (accumulator, id) => - awaitedTankAverages[id] - ? accumulator + - calculateWN8(awaitedTankAverages[id].all, diffed[id]) * - diffed[id].battles - : accumulator, - 0, - ) / battlesOfTanksWithAverages, - tier: - filteredOrder.reduce( - (accumulator, id) => - awaitedTankopedia[id] - ? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles - : accumulator, - 0, - ) / battlesOfTanksWithTankopediaEntry, - }; - tierWeights = filteredOrder.reduce((accumulator, id) => { - const tankopediaEntry = awaitedTankopedia[id]; - - if (!tankopediaEntry) return accumulator; - - const tier = tankopediaEntry.tier as Tier; - - if (accumulator[tier]) { - accumulator[tier]! += diffed[id].battles; - } else { - accumulator[tier] = diffed[id].battles; - } - - return accumulator; - }, {}); - } else { - stats = diffed[(filters as number)!]; - - supplementaryStats = { - WN8: stats - ? calculateWN8( - (await tankAverages)[(filters as number)!].all, - diffed[(filters as number)!], - ) - : undefined, - tier: (await tankopedia)[(filters as number)!]?.tier, - }; - } - - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - - return ( - - - - {stats.battles === 0 && } - {stats.battles > 0 && type === 'player' && ( - - )} - {stats.battles > 0 && ( - - )} - - ); -} diff --git a/src/renderers/stats.tsx b/src/renderers/stats.tsx deleted file mode 100644 index 5e1f457e4..000000000 --- a/src/renderers/stats.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import AllStatsOverview from '../components/AllStatsOverview'; -import NoData, { NoDataType } from '../components/NoData'; -import { TreeTypeString } from '../components/Tanks'; -import TitleBar from '../components/TitleBar'; -import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import calculateWN8 from '../core/blitz/calculateWN8'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankName from '../core/blitz/resolveTankName'; -import sumStats from '../core/blitz/sumStats'; -import { tankopedia } from '../core/blitz/tankopedia'; -import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats'; -import { tankAverages } from '../core/blitzstars/tankAverages'; -import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand'; -import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand'; -import { theme } from '../stitches.config'; -import { - AccountInfo, - AllStats, - SupplementaryStats, -} from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; - -export type StatType = 'player' | 'tank' | 'multi-tank'; - -export interface MultiTankFilters { - nation: string; - 'tank-type': string; - tier: number; - 'tree-type': TreeTypeString; -} - -export default async function stats( - type: Type, - { start, end, statsName }: ResolvedPeriod, - { region: server, id }: ResolvedPlayer, - filters: Type extends 'tank' - ? number - : Type extends 'multi-tank' - ? Partial - : null, - naked = false, -) { - let nameDiscriminator: string | undefined; - let image: string | undefined; - - if (type === 'player' || type === 'multi-tank') { - const clan = ( - await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ) - )[id]?.clan; - - if (clan) nameDiscriminator = `[${clan.tag}]`; - image = clan - ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` - : undefined; - } else if (type === 'tank') { - nameDiscriminator = `(${await resolveTankName(filters as number)})`; - image = (await tankopedia)[filters as number]?.images.normal; - } else { - } - - const { diffed, order } = await getDiffedTankStats(server, id, start, end); - const awaitedTankopedia = await tankopedia; - let stats: AllStats | undefined; - let supplementaryStats: SupplementaryStats; - - if (type === 'player' || type === 'multi-tank') { - const typedFilters = filters as Partial | null; - const filteredOrder = order.filter( - (id) => - typedFilters === null || - ((typedFilters.nation === undefined || - awaitedTankopedia[id]?.nation === typedFilters.nation) && - (typedFilters['tank-type'] === undefined || - awaitedTankopedia[id]?.type === typedFilters['tank-type']) && - (typedFilters.tier === undefined || - awaitedTankopedia[id]?.tier === typedFilters.tier) && - (typedFilters['tree-type'] === undefined || - (typedFilters['tree-type'] === 'collector' && - awaitedTankopedia[id]?.is_collectible) || - (typedFilters['tree-type'] === 'premium' && - awaitedTankopedia[id]?.is_premium) || - (typedFilters['tree-type'] === 'techtree' && - !awaitedTankopedia[id]?.is_collectible && - !awaitedTankopedia[id]?.is_premium))), - ); - const awaitedTankAverages = await tankAverages; - stats = sumStats(filteredOrder.map((id) => diffed[id])); - const battlesOfTanksWithAverages = filteredOrder.reduce( - (accumulator, id) => - awaitedTankAverages[id] - ? accumulator + diffed[id].battles - : accumulator, - 0, - ); - const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce( - (accumulator, id) => - awaitedTankopedia[id] ? accumulator + diffed[id].battles : accumulator, - 0, - ); - - supplementaryStats = { - WN8: - filteredOrder.reduce( - (accumulator, id) => - awaitedTankAverages[id] - ? accumulator + - calculateWN8(awaitedTankAverages[id].all, diffed[id]) * - diffed[id].battles - : accumulator, - 0, - ) / battlesOfTanksWithAverages, - tier: - filteredOrder.reduce( - (accumulator, id) => - awaitedTankopedia[id] - ? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles - : accumulator, - 0, - ) / battlesOfTanksWithTankopediaEntry, - }; - } else { - stats = diffed[(filters as number)!]; - - supplementaryStats = { - WN8: stats - ? calculateWN8( - (await tankAverages)[(filters as number)!].all, - diffed[(filters as number)!], - ) - : undefined, - tier: (await tankopedia)[(filters as number)!]?.tier, - }; - } - - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const overview = ( - - ); - const footer = ( - - /full-stats for all statistics - - ); - - return naked ? ( - {overview} - ) : ( - - - - {!stats?.battles && } - {stats?.battles > 0 && overview} - - ); -} diff --git a/src/renderers/today.tsx b/src/renderers/today.tsx deleted file mode 100644 index ff0c40bc6..000000000 --- a/src/renderers/today.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import * as Breakdown from '../components/Breakdown'; -import NoData, { NoDataType } from '../components/NoData'; -import TitleBar from '../components/TitleBar'; -import Wrapper from '../components/Wrapper'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; -import calculateWN8 from '../core/blitz/calculateWN8'; -import getTankStats from '../core/blitz/getTankStats'; -import getTreeType from '../core/blitz/getTreeType'; -import getWN8Percentile from '../core/blitz/getWN8Percentile'; -import getWargamingResponse from '../core/blitz/getWargamingResponse'; -import resolveTankName from '../core/blitz/resolveTankName'; -import sumStats from '../core/blitz/sumStats'; -import { tankopedia } from '../core/blitz/tankopedia'; -import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats'; -import getPeriodNow from '../core/blitzstars/getPeriodNow'; -import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo'; -import { tankAverages } from '../core/blitzstars/tankAverages'; -import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand'; -import { AccountInfo, AllStats } from '../types/accountInfo'; -import { PlayerClanData } from '../types/playerClanData'; -import { PossiblyPromise } from '../types/possiblyPromise'; - -export default async function today( - { region: server, id }: ResolvedPlayer, - cutoff = Infinity, - maximized = 4, - showTotal = true, - naked?: boolean, -) { - const { diffed, order } = await getDiffedTankStats( - server, - id, - getTimeDaysAgo(server, 1), - getPeriodNow(), - ); - const accountInfo = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const clanData = await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, - ); - const careerTankStatsRaw = await getTankStats(server, id); - const careerStats: Record = showTotal - ? { - 0: accountInfo[id].statistics.all, - } - : {}; - const allStatsToAccumulate: AllStats[] = []; - - Object.entries(diffed).forEach(([, tankStats]) => { - allStatsToAccumulate.push(tankStats); - }); - Object.entries(careerTankStatsRaw).forEach(([, tankStats]) => { - careerStats[tankStats.tank_id] = tankStats.all; - }); - - const accumulatedStats = sumStats(allStatsToAccumulate); - - if (showTotal && Object.keys(diffed).length > 0) { - diffed[0] = accumulatedStats; - } - - const tankStatsOverTimeEntries = Object.entries(diffed); - const todayWN8s = await tankStatsOverTimeEntries.reduce< - PossiblyPromise> - >(async (accumulator, [tankIdString, tankStats]) => { - const tankId = parseInt(tankIdString); - - return (showTotal && tankId === 0) || !(await tankAverages)[tankId] - ? accumulator - : { - ...(await accumulator), - [tankId]: calculateWN8((await tankAverages)[tankId].all, tankStats), - }; - }, {}); - - const careerWN8s = await careerTankStatsRaw.reduce< - PossiblyPromise> - >(async (accumulator, { tank_id }) => { - return (showTotal && tank_id === 0) || - (await tankAverages)[tank_id] === undefined - ? accumulator - : { - ...(await accumulator), - [tank_id]: calculateWN8( - (await tankAverages)[tank_id].all, - careerStats[tank_id], - ), - }; - }, {}); - const todayWN8sEntries = Object.entries(todayWN8s); - const careerWN8sEntries = Object.entries(careerWN8s); - - if (showTotal) { - todayWN8s[0] = - todayWN8sEntries.reduce( - (accumulator, [tankIdString, wn8]) => - accumulator + wn8 * diffed[Number(tankIdString)].battles, - 0, - ) / - todayWN8sEntries.reduce( - (accumulator, [tankIdString]) => - accumulator + diffed[Number(tankIdString)].battles, - 0, - ); - careerWN8s[0] = - careerWN8sEntries.reduce( - (accumulator, [tankIdString, WN8]) => - isNaN(WN8) - ? accumulator - : accumulator + WN8 * careerStats[Number(tankIdString)].battles, - 0, - ) / - careerWN8sEntries.reduce( - (accumulator, [tankIdString, WN8]) => - isNaN(WN8) - ? accumulator - : accumulator + careerStats[Number(tankIdString)].battles, - 0, - ); - } - - const rows = await Promise.all( - [ - // this code unreadable on god will forget tomorrow no cap - ...(order.length === 0 || !showTotal ? [] : [0]), - ...(isFinite(cutoff) ? order.slice(0, cutoff) : order), - ].map(async (id, index) => { - const tankStats = diffed[id]; - const career = careerStats[id]; - const tankopediaEntry = (await tankopedia)[id]; - - return ( - maximized : index + 1 > maximized} - stats={[ - { - title: 'Battles', - current: tankStats.battles.toLocaleString(), - career: career.battles.toLocaleString(), - }, - { - title: 'Winrate', - current: `${(100 * (tankStats.wins / tankStats.battles)).toFixed( - 2, - )}%`, - career: `${(100 * (career.wins / career.battles)).toFixed(2)}%`, - delta: - tankStats.wins / tankStats.battles - - career.wins / career.battles, - }, - { - title: 'WN8', - current: isNaN(todayWN8s[id]) - ? undefined - : Math.round(todayWN8s[id]).toLocaleString(), - career: isNaN(careerWN8s[id]) - ? undefined - : Math.round(careerWN8s[id]).toLocaleString(), - percentile: isNaN(todayWN8s[id]) - ? undefined - : getWN8Percentile(todayWN8s[id]), - }, - { - title: 'Damage', - current: Math.round( - tankStats.damage_dealt / tankStats.battles, - ).toLocaleString(), - career: Math.round( - career.damage_dealt / career.battles, - ).toLocaleString(), - delta: - tankStats.damage_dealt / tankStats.battles - - career.damage_dealt / career.battles, - }, - ]} - /> - ); - }), - ); - - return naked ? ( - - {rows} - - ) : ( - - - - {rows.length === 0 && } - {rows.length > 0 && {rows}} - - ); -} diff --git a/src/stitches.config.ts b/src/stitches.config.ts index 1d8327ba3..0e5b70ea0 100644 --- a/src/stitches.config.ts +++ b/src/stitches.config.ts @@ -3,7 +3,7 @@ import { blueDark, slate, slateDark, - tomatoDark, + tealDark, } from '@radix-ui/colors'; import { createBorderStyles, createColors, createSpaces } from 'bepaint'; @@ -17,13 +17,12 @@ export const theme = { ...createColors(amberDark, 'amber'), ...createColors(blueDark, 'blue'), - ...createColors(tomatoDark, 'red'), + ...createColors(tealDark, 'teal'), }, borderStyles: { ...createBorderStyles(slateDark), ...createBorderStyles(blueDark, undefined, 'blue'), - ...createBorderStyles(tomatoDark, undefined, 'red'), }, durations: { diff --git a/src/stores/session.ts b/src/stores/session.ts index e6db63fac..9c125f933 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -3,7 +3,9 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn'; import { Region } from '../constants/regions'; -import { NormalizedTankStats } from '../types/tanksStats'; +import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; +import fetchBlitz from '../core/blitz/fetchWargaming'; +import { NormalizedTankStats, TanksStats } from '../types/tanksStats'; type Session = ( | { @@ -20,6 +22,7 @@ type Session = ( } ) & { promptBeforeReset: boolean; + showEmbedPrompt: boolean; customColumns: { display: CustomColumnDisplay; showDelta: boolean; @@ -28,6 +31,37 @@ type Session = ( showCareer: boolean; }; +export async function setSession(region: Region, id: number, nickname: string) { + // TODO: generalize this function and also check for more of these kind + const rawTankStats = ( + await fetchBlitz( + `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, + ) + )[id]; + const tankStats = rawTankStats.reduce( + (accumulator, tank) => ({ + ...accumulator, + [tank.tank_id]: tank, + }), + {}, + ); + + useSession.setState({ + isTracking: true, + id, + region, + nickname, + tankStats, + time: Date.now(), + }); +} + +export function resetSession() { + const session = useSession.getState(); + if (!session.isTracking) return; + setSession(session.region, session.id, session.nickname); +} + export const useSession = create()( devtools( persist( @@ -36,6 +70,7 @@ export const useSession = create()( promptBeforeReset: true, showTotal: true, showCareer: true, + showEmbedPrompt: true, customColumns: [ { display: 'battles', showDelta: false }, diff --git a/src/types/accountAchievements.ts b/src/types/accountAchievements.ts deleted file mode 100644 index 1fbcc0378..000000000 --- a/src/types/accountAchievements.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AccountAchievements { - [id: number]: { - achievements: { - [achievement: string]: number; - }; - max_series: { - [achievement: string]: number; - }; - }; -} diff --git a/src/types/accountInfo.ts b/src/types/accountInfo.ts deleted file mode 100644 index bcb90e861..000000000 --- a/src/types/accountInfo.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface AllStats { - spotted: number; - hits: number; - frags: number; - max_xp: number; - wins: number; - losses: number; - capture_points: number; - battles: number; - damage_dealt: number; - damage_received: number; - max_frags: number; - shots: number; - frags8p: number; - xp: number; - win_and_survived: number; - survived_battles: number; - dropped_capture_points: number; -} - -export interface SupplementaryStats { - // if undefined, WN8 is not available for tanks without averages - WN8?: number; - tier?: number; -} - -export interface ClanStats { - spotted: number; - max_frags_tank_id: number; - hits: number; - frags: number; - max_xp: number; - max_xp_tank_id: number; - wins: number; - losses: number; - capture_points: number; - battles: number; - damage_dealt: number; - damage_received: number; - max_frags: number; - shots: number; - frags8p: number; - xp: number; - win_and_survived: number; - survived_battles: number; - dropped_capture_points: number; -} - -export interface AccountInfo { - [accountId: number]: { - account_id: number; - created_at: number; - updated_at: number; - private: null; - last_battle_time: number; - nickname: string; - statistics: { - all: AllStats; - clan: ClanStats; - frags: null | number; - }; - }; -} diff --git a/src/types/clanInfo.ts b/src/types/clanInfo.ts deleted file mode 100644 index 7ac8e8d5e..000000000 --- a/src/types/clanInfo.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface ClanInfo { - [clanId: number]: { - recruiting_options: { - vehicles_level: number; - wins_ratio: number; - average_battles_per_day: number; - battles: number; - average_damage: number; - }; - members_count: number; - name: string; - creator_name: string; - clan_id: number; - created_at: number; - updated_at: number; - leader_name: string; - members_ids: number[]; - recruiting_policy: string; - tag: string; - is_clan_disbanded: boolean; - old_name: string; - emblem_set_id: number; - creator_id: number; - motto: string; - renamed_at: number; - old_tag: null; - leader_id: number; - description: string; - }; -} diff --git a/src/types/histories.ts b/src/types/histories.ts index d2c5ae8be..59c2f8ab4 100644 --- a/src/types/histories.ts +++ b/src/types/histories.ts @@ -1,6 +1,6 @@ // a cleaner simplification of Blitz Stars' histories because it's a mess there -import { AllStats } from './accountInfo'; +import { AllStats } from '../core/blitz/getAccountInfo'; export interface History { all: AllStats; diff --git a/src/types/playerClanData.ts b/src/types/playerClanData.ts deleted file mode 100644 index 4dab4ff4d..000000000 --- a/src/types/playerClanData.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type PlayerClanData = { - [accountId: number]: { - account_id: number; - joined_at: number; - clan_id: number; - role: string; - account_name: string; - - clan?: { - members_count: number; - name: string; - created_at: number; - tag: string; - clan_id: number; - emblem_set_id: number; - }; - } | null; -}; diff --git a/src/types/tanksStats.ts b/src/types/tanksStats.ts index 0d60f4c72..70aa4986d 100644 --- a/src/types/tanksStats.ts +++ b/src/types/tanksStats.ts @@ -1,4 +1,4 @@ -import { AllStats } from './accountInfo'; +import { AllStats } from '../core/blitz/getAccountInfo'; export interface IndividualTankStats { all: AllStats; diff --git a/src/workers/render.ts b/src/workers/render.ts new file mode 100644 index 000000000..d6ad642c9 --- /dev/null +++ b/src/workers/render.ts @@ -0,0 +1,9 @@ +import { parentPort, threadId } from 'worker_threads'; +import svgToPng from '../core/blitzkrieg/svgToPng'; + +console.log(`🟢 Launched render worker ${threadId}`); + +parentPort?.on('message', async (svg: string) => { + const png = svgToPng(svg); + parentPort?.postMessage(png); +}); diff --git a/yarn.lock b/yarn.lock index d85a8246c..a5eb64006 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1060,6 +1060,23 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@prisma/client@^5.4.1": + version "5.4.1" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.4.1.tgz#f891d1bc2739d303afaf4406b673f57bf84b226c" + integrity sha512-xyD0DJ3gRNfLbPsC+YfMBBuLJtZKQfy1OD2qU/PZg+HKrr7SO+09174LMeTlWP0YF2wca9LxtVd4HnAiB5ketQ== + dependencies: + "@prisma/engines-version" "5.4.1-1.2f302df92bd8945e20ad4595a73def5b96afa54f" + +"@prisma/engines-version@5.4.1-1.2f302df92bd8945e20ad4595a73def5b96afa54f": + version "5.4.1-1.2f302df92bd8945e20ad4595a73def5b96afa54f" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.4.1-1.2f302df92bd8945e20ad4595a73def5b96afa54f.tgz#347534906f2a9d6fcf02aeeb911103c5ebfe4384" + integrity sha512-+nUQM/y8C+1GG5Ioeqcu6itFslCfxvQSAUVSMC9XM2G2Fcq0F4Afnp6m0pXF6X6iUBWen7jZBPmM9Qlq4Nr3/A== + +"@prisma/engines@5.4.1": + version "5.4.1" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.4.1.tgz#80b854c87ba30d02ce8df1578cc955ac1c97216a" + integrity sha512-vJTdY4la/5V3N7SFvWRmSMUh4mIQnyb/MNoDjzVbh9iLmEC+uEykj/1GPviVsorvfz7DbYSQC4RiwmlEpTEvGA== + "@radix-ui/colors@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/colors/-/colors-2.1.0.tgz#8aa6f233f68076a92c57db93d7235172e88fda44" @@ -3636,6 +3653,13 @@ pretty-ms@^8.0.0: dependencies: parse-ms "^3.0.0" +prisma@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.4.1.tgz#2abbd2ae446519654330d67acd81e7bafbac67a2" + integrity sha512-op9PmU8Bcw5dNAas82wBYTG0yHnpq9/O3bhxbDBrNzwZTwBqsVCxxYRLf6wHNh9HVaDGhgjjHlu1+BcW8qdnBg== + dependencies: + "@prisma/engines" "5.4.1" + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"