diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b09f08da..862f08758 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "Smri", "strvk", "tankopedia", + "Unicum", "Unregistering", "vickers", "wargaming", diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 7b18e4d5a..e7630f90d 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -1,8 +1,10 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; import packageJSON from '../../package.json' assert { type: 'json' }; -import { ACCENT_COLOR } from '../constants/colors.js'; -import { tankopedia } from '../core/blitzstars/tankopedia.js'; +import { tankopedia } from '../core/blitz/tankopedia.js'; +import { tankAverages } from '../core/blitzstars/tankAverages.js'; +import cleanTable from '../core/interaction/cleanTable.js'; import cmdName from '../core/interaction/cmdName.js'; +import infoEmbed from '../core/interaction/infoEmbed.js'; import getClientId from '../core/process/getClientId.js'; import { CommandRegistry } from '../events/interactionCreate.js'; import { client } from '../index.js'; @@ -24,22 +26,24 @@ export default { await interaction.editReply({ embeds: [ - new EmbedBuilder() - .setTitle(`${client.user?.username} debug information`) - .setColor(ACCENT_COLOR) - .setDescription( - `**Version**: ${packageJSON.version}\n**Uptime**: ${Math.floor( - uptime / 1000 / 60 / 60 / 24, - )}d ${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor( - (uptime / 1000 / 60) % 60, - )}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor( - uptime % 1000, - )}ms\n**Tankopedia**: ${ - tankopedia ? 'cached' : 'not cached' - }\n**Client ID**: ${getClientId()}\n**Tag**: ${ - interaction.client.user.tag - }`, - ), + infoEmbed( + `${client.user?.username} debug information`, + cleanTable([ + ['Version', packageJSON.version], + ['Client ID', getClientId()], + ['Tag', interaction.client.user.tag], + [ + 'Uptime', + `${Math.floor(uptime / 1000 / 60 / 60 / 24)}d ${Math.floor( + (uptime / 1000 / 60 / 60) % 24, + )}h ${Math.floor((uptime / 1000 / 60) % 60)}m ${Math.floor( + (uptime / 1000) % 60, + )}s ${Math.floor(uptime % 1000)}ms`, + ], + ['Tankopedia cached', `${tankopedia !== undefined}`], + ['Tank averages cached', `${tankAverages !== undefined}`], + ]), + ), ], }); diff --git a/src/commands/ownedtanks.ts b/src/commands/ownedtanks.ts index c026bf993..069200ea0 100644 --- a/src/commands/ownedtanks.ts +++ b/src/commands/ownedtanks.ts @@ -3,7 +3,8 @@ import markdownEscape from 'markdown-escape'; import usernameAutocomplete from '../core/autocomplete/username.js'; import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; -import { TANK_TYPE_EMOJIS, tankopedia } from '../core/blitzstars/tankopedia.js'; +import resolveTankName from '../core/blitz/resolveTankName.js'; +import { TANK_TYPE_EMOJIS, tankopedia } from '../core/blitz/tankopedia.js'; import cmdName from '../core/interaction/cmdName.js'; import infoEmbed from '../core/interaction/infoEmbed.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; @@ -11,7 +12,6 @@ import { args } from '../core/process/args.js'; import { CommandRegistry } from '../events/interactionCreate.js'; import { AccountInfo } from '../types/accountInfo.js'; import { TanksStats } from '../types/tanksStats.js'; -import resolveTankName from '../utilities/resolveTankName.js'; const COMP_TANKS = [ // light tanks diff --git a/src/commands/searchplayers.ts b/src/commands/searchplayers.ts index 93579dd83..9ea41a53a 100644 --- a/src/commands/searchplayers.ts +++ b/src/commands/searchplayers.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import markdownEscape from 'markdown-escape'; import { BLITZ_SERVERS, BlitzServer } from '../constants/servers.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; +import { usernameRegex } from '../core/blitz/listAccountsPanServer.js'; import cmdName from '../core/interaction/cmdName.js'; import infoEmbed from '../core/interaction/infoEmbed.js'; import addServerChoices from '../core/options/addServerChoices.js'; @@ -34,13 +35,14 @@ export default { const server = interaction.options.getString('server') as BlitzServer; const name = interaction.options.getString('username')!; const limit = interaction.options.getInteger('limit') ?? 25; - const normalizedSearch = encodeURIComponent(name.trim()); - const players = - normalizedSearch.length >= 3 && normalizedSearch.length <= 100 - ? await getWargamingResponse( - `https://api.wotblitz.${server}/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${normalizedSearch}&limit=${limit}`, - ) - : []; + const trimmedSearch = name.trim(); + const players = usernameRegex.test(trimmedSearch) + ? await getWargamingResponse( + `https://api.wotblitz.${server}/wotb/account/list/?application_id=${ + args['wargaming-application-id'] + }&search=${encodeURIComponent(trimmedSearch)}&limit=${limit}`, + ) + : []; await interaction.editReply({ embeds: [ diff --git a/src/commands/searchtanks.ts b/src/commands/searchtanks.ts index bf7d49823..f14361372 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 { tankNames } from '../core/blitzstars/tankopedia.js'; +import { TANK_NAMES } from '../core/blitz/tankopedia.js'; import cmdName from '../core/interaction/cmdName.js'; import infoEmbed from '../core/interaction/infoEmbed.js'; import addTankChoices from '../core/options/addTankChoices.js'; @@ -27,7 +27,7 @@ export default { async execute(interaction) { const tank = interaction.options.getString('tank')!; const limit = interaction.options.getInteger('limit') ?? 25; - const results = go(tank, tankNames, { limit }).map( + const results = go(tank, TANK_NAMES, { limit }).map( (result) => result.target, ); diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx index 3dc9bed2c..ee6e30b45 100644 --- a/src/commands/stats.tsx +++ b/src/commands/stats.tsx @@ -3,22 +3,25 @@ import { ButtonBuilder, SlashCommandBuilder, } from 'discord.js'; -import GenericStats from '../components/GenericStats/index.js'; +import GenericAllStats from '../components/GenericAllStats.js'; import NoBattlesInPeriod from '../components/NoBattlesInPeriod.js'; import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js'; import TitleBar from '../components/TitleBar.js'; import Wrapper from '../components/Wrapper.js'; import { BLITZ_SERVERS } from '../constants/servers.js'; -import fullBlitzStarsStats from '../core/actions/fullBlitzStarsStats.js'; -import { supportBlitzStars } from '../core/actions/supportBlitzStars.js'; import usernameAutocomplete from '../core/autocomplete/username.js'; import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; +import getWN8 from '../core/blitz/getWN8.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; import sumStats from '../core/blitz/sumStats.js'; +import { tankopedia } from '../core/blitz/tankopedia.js'; import getPeriodNow from '../core/blitzstars/getPeriodNow.js'; import getPeriodicStart from '../core/blitzstars/getPeriodStart.js'; import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js'; +import { tankAverages } from '../core/blitzstars/tankAverages.js'; import cmdName from '../core/interaction/cmdName.js'; +import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js'; +import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js'; import addStatPeriodChoices, { StatPeriod, statPeriodNames, @@ -27,12 +30,16 @@ import addUsernameOption from '../core/options/addUsernameOption.js'; import { args } from '../core/process/args.js'; import render from '../core/ui/render.js'; import { CommandRegistry } from '../events/interactionCreate.js'; -import { AccountInfo, AllStats } from '../types/accountInfo.js'; +import { + AccountInfo, + AllStats, + SupplementaryStats, +} from '../types/accountInfo.js'; import { PlayerClanData } from '../types/playerClanData.js'; export default { inProduction: true, - inDevelopment: false, + inDevelopment: true, inPublic: true, command: new SlashCommandBuilder() @@ -53,20 +60,41 @@ export default { `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${args['wargaming-application-id']}&account_id=${id}&extra=clan`, ); let stats: AllStats; + let supplementaryStats: SupplementaryStats | undefined; if (period === 'career') { stats = accountInfo[id].statistics.all; } else { - stats = sumStats( - Object.entries( - await getTankStatsOverTime( - server, - id, - getPeriodicStart(period), - getPeriodNow(), - ), - ).map(([, stats]) => stats), + const tankStatsOverTime = await getTankStatsOverTime( + server, + id, + getPeriodicStart(period), + getPeriodNow(), ); + const entries = Object.entries(tankStatsOverTime); + const totalBattles = entries.reduce( + (accumulator, [, stats]) => accumulator + stats.battles, + 0, + ); + + supplementaryStats = { + tier: + entries.reduce((accumulator, [tankIdString, stats]) => { + const tankId = Number(tankIdString); + const tankTier = tankopedia[tankId].tier; + + return accumulator + tankTier * stats.battles; + }, 0) / totalBattles, + WN8: + entries.reduce((accumulator, [tankIdString, stats]) => { + const tankId = Number(tankIdString); + const tankWN8 = getWN8(tankAverages[tankId].all, stats); + + return accumulator + tankWN8 * stats.battles; + }, 0) / totalBattles, + }; + + stats = sumStats(entries.map(([, stats]) => stats)); } const image = await render( @@ -88,43 +116,9 @@ export default { {stats.battles === 0 && } {stats.battles > 0 && ( - )} diff --git a/src/commands/tankstats.tsx b/src/commands/tankstats.tsx index bed654baa..17c55a548 100644 --- a/src/commands/tankstats.tsx +++ b/src/commands/tankstats.tsx @@ -3,26 +3,29 @@ import { ButtonBuilder, SlashCommandBuilder, } from 'discord.js'; -import GenericStats from '../components/GenericStats/index.js'; +import GenericAllStats from '../components/GenericAllStats.js'; import NoBattlesInPeriod from '../components/NoBattlesInPeriod.js'; import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js'; import TitleBar from '../components/TitleBar.js'; import Wrapper from '../components/Wrapper.js'; import { BLITZ_SERVERS } from '../constants/servers.js'; -import fullBlitzStarsStats from '../core/actions/fullBlitzStarsStats.js'; -import { supportBlitzStars } from '../core/actions/supportBlitzStars.js'; import tanksAutocomplete from '../core/autocomplete/tanks.js'; import usernameAutocomplete from '../core/autocomplete/username.js'; import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; +import getWN8 from '../core/blitz/getWN8.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; import resolveTankId from '../core/blitz/resolveTankId.js'; +import resolveTankName from '../core/blitz/resolveTankName.js'; +import { tankopedia } from '../core/blitz/tankopedia.js'; import getPeriodNow from '../core/blitzstars/getPeriodNow.js'; import getPeriodicStart from '../core/blitzstars/getPeriodStart.js'; import getTankStatsOverTime, { emptyAllStats, } from '../core/blitzstars/getTankStatsOverTime.js'; -import { tankopedia } from '../core/blitzstars/tankopedia.js'; +import { tankAverages } from '../core/blitzstars/tankAverages.js'; import cmdName from '../core/interaction/cmdName.js'; +import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js'; +import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js'; import addStatPeriodChoices, { StatPeriod, statPeriodNames, @@ -34,11 +37,10 @@ import render from '../core/ui/render.js'; import { CommandRegistry } from '../events/interactionCreate.js'; import { AccountInfo, AllStats } from '../types/accountInfo.js'; import { TanksStats } from '../types/tanksStats.js'; -import resolveTankName from '../utilities/resolveTankName.js'; export default { inProduction: true, - inDevelopment: false, + inDevelopment: true, inPublic: true, command: new SlashCommandBuilder() @@ -64,28 +66,29 @@ export default { `https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${args['wargaming-application-id']}&account_id=${id}`, ); let stats: AllStats; + const tankId = Number(tank); if (period === 'career') { stats = - tankStats[id].find((stats) => stats.tank_id === Number(tank))?.all ?? + tankStats[id].find((stats) => stats.tank_id === tankId)?.all ?? emptyAllStats; } else { - stats = ( - await getTankStatsOverTime( - server, - id, - getPeriodicStart(period), - getPeriodNow(), - ) - )[Number(tank)]; + const tankStatsOverTime = await getTankStatsOverTime( + server, + id, + getPeriodicStart(period), + getPeriodNow(), + ); + + stats = tankStatsOverTime[tankId] ?? emptyAllStats; } const image = await render( } {stats.battles > 0 && ( - // TODO: GenericStats generator for AllStats - )} diff --git a/src/commands/today.tsx b/src/commands/today.tsx index 7f756cc03..4eebe41b1 100644 --- a/src/commands/today.tsx +++ b/src/commands/today.tsx @@ -7,18 +7,22 @@ import * as Breakdown from '../components/Breakdown/index.js'; import NoBattlesInPeriod from '../components/NoBattlesInPeriod.js'; import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js'; import TitleBar from '../components/TitleBar.js'; -import Wrapper from '../components/Wrapper.js'; +import Wrapper, { WrapperSize } from '../components/Wrapper.js'; import { BLITZ_SERVERS } from '../constants/servers.js'; -import fullBlitzStarsStats from '../core/actions/fullBlitzStarsStats.js'; -import { supportBlitzStars } from '../core/actions/supportBlitzStars.js'; import usernameAutocomplete from '../core/autocomplete/username.js'; import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; +import getWN8 from '../core/blitz/getWN8.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; +import resolveTankName from '../core/blitz/resolveTankName.js'; import sumStats from '../core/blitz/sumStats.js'; +import { tankopedia } from '../core/blitz/tankopedia.js'; import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js'; import last5AM from '../core/blitzstars/last5AM.js'; -import { tankopedia } from '../core/blitzstars/tankopedia.js'; +import { tankAverages } from '../core/blitzstars/tankAverages.js'; import cmdName from '../core/interaction/cmdName.js'; +import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js'; +import infoEmbed from '../core/interaction/infoEmbed.js'; +import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import { args } from '../core/process/args.js'; import render from '../core/ui/render.js'; @@ -26,11 +30,10 @@ import { CommandRegistry } from '../events/interactionCreate.js'; import { AccountInfo, AllStats } from '../types/accountInfo.js'; import { PlayerClanData } from '../types/playerClanData.js'; import { TanksStats } from '../types/tanksStats.js'; -import resolveTankName from '../utilities/resolveTankName.js'; export default { inProduction: true, - inDevelopment: false, + inDevelopment: true, inPublic: true, command: new SlashCommandBuilder() @@ -75,36 +78,88 @@ export default { tankStatsOverTime[0] = accumulatedStats; } - const rows = Object.entries(tankStatsOverTime).map( - ([tankId, tankStats]) => { - const career = careerStats[tankId as unknown as number]; + const tankStatsOverTimeEntries = Object.entries(tankStatsOverTime); + const todayWN8s = tankStatsOverTimeEntries.reduce>( + (accumulator, [tankIdString, tankStats]) => { + const tankId = Number(tankIdString); - return ( - - ); + return tankId === 0 + ? accumulator + : { + ...accumulator, + [tankId]: getWN8(tankAverages[tankId].all, tankStats), + }; }, + {}, ); + const careerWN8s = tankStatsOverTimeEntries.reduce>( + (accumulator, [tankIdString, tankStats]) => { + const tankId = Number(tankIdString); + + return tankId === 0 + ? accumulator + : { + ...accumulator, + [tankId]: getWN8(tankAverages[tankId].all, careerStats[tankId]), + }; + }, + {}, + ); + const todayWN8sEntries = Object.entries(todayWN8s); + const careerWN8sEntries = Object.entries(careerWN8s); + + todayWN8s[0] = + todayWN8sEntries.reduce( + (accumulator, [tankIdString, wn8]) => + accumulator + wn8 * tankStatsOverTime[Number(tankIdString)].battles, + 0, + ) / + todayWN8sEntries.reduce( + (accumulator, [tankIdString]) => + accumulator + tankStatsOverTime[Number(tankIdString)].battles, + 0, + ); + careerWN8s[0] = + careerWN8sEntries.reduce( + (accumulator, [tankIdString, wn8]) => + accumulator + wn8 * careerStats[Number(tankIdString)].battles, + 0, + ) / + careerWN8sEntries.reduce( + (accumulator, [tankIdString]) => + accumulator + careerStats[Number(tankIdString)].battles, + 0, + ); + + const rows = tankStatsOverTimeEntries.map(([tankIdString, tankStats]) => { + const tankId = Number(tankIdString); + const career = careerStats[tankId]; + + return ( + + ); + }); const image = await render( - + , + WrapperSize.Roomy, ); const actionRow = new ActionRowBuilder().addComponents( @@ -133,6 +189,15 @@ export default { ); await interaction.editReply({ + embeds: + rows.length >= 6 + ? [ + infoEmbed( + 'Very large image!', + "Don't forget to oom in.", + ), + ] + : undefined, files: [image], components: [actionRow], }); diff --git a/src/components/Breakdown/components/Row.tsx b/src/components/Breakdown/components/Row.tsx index 29cfc00ec..0d5f8e125 100644 --- a/src/components/Breakdown/components/Row.tsx +++ b/src/components/Breakdown/components/Row.tsx @@ -1,3 +1,4 @@ +import getWN8Percentile from '../../../core/blitz/getWN8Percentile.js'; import { RowDiscriminator } from './RowDiscriminator.js'; import { RowStat } from './RowStat.js'; @@ -22,8 +23,8 @@ export function Row({ careerWinrate, WN8, careerWN8, - damage: averageDamage, - careerDamage: careerAverageDamage, + damage, + careerDamage, survival, careerSurvival, battles, @@ -39,11 +40,17 @@ export function Row({ career={`${(careerWinrate * 100).toFixed(2)}%`} delta={winrate - careerWinrate} /> + diff --git a/src/components/Breakdown/components/RowStat.tsx b/src/components/Breakdown/components/RowStat.tsx index 398cad660..1466ba384 100644 --- a/src/components/Breakdown/components/RowStat.tsx +++ b/src/components/Breakdown/components/RowStat.tsx @@ -1,13 +1,22 @@ +import { Percentile } from '../../../constants/percentiles.js'; import { theme } from '../../../stitches.config.js'; +import PercentileIndicator from '../../PercentileIndicator.js'; export interface RowStatProps { name: string; value: string; career: string; delta?: number; + percentile?: Percentile; } -export function RowStat({ name, value, career, delta }: RowStatProps) { +export function RowStat({ + name, + value, + career, + delta, + percentile, +}: RowStatProps) { return (
- {delta && ( + {percentile === undefined && delta !== undefined && delta !== 0 && ( 0 @@ -41,6 +50,9 @@ export function RowStat({ name, value, career, delta }: RowStatProps) { style={{ width: 12, height: 12 }} /> )} + {percentile !== undefined && ( + + )} + ); +} diff --git a/src/components/GenericStats/components/GenericStatsRow.tsx b/src/components/GenericStats/components/GenericStatsRow.tsx index fb178345f..449e9d5e1 100644 --- a/src/components/GenericStats/components/GenericStatsRow.tsx +++ b/src/components/GenericStats/components/GenericStatsRow.tsx @@ -1,6 +1,9 @@ import { theme } from '../../../stitches.config.js'; import { Stat } from '../index.js'; +import '../../../core/blitz/getWN8Percentile.js'; +import PercentileIndicator from '../../PercentileIndicator.js'; + export interface GenericStatsRowProps { stat: Stat; } @@ -17,7 +20,18 @@ export default function GenericStatsRow({ stat }: GenericStatsRowProps) { {stat[0]} - + + {stat[2] !== undefined && } {stat[1]}
diff --git a/src/components/GenericStats/index.tsx b/src/components/GenericStats/index.tsx index 6f55650b0..94414baad 100644 --- a/src/components/GenericStats/index.tsx +++ b/src/components/GenericStats/index.tsx @@ -1,6 +1,9 @@ +import { Percentile } from '../../constants/percentiles.js'; import GenericStatsColumn from './components/GenericStatsColumn.js'; -export type Stat = [string, string | number]; +export type Stat = + | [string, string | number | undefined] + | [string, string | number | undefined, Percentile | undefined]; export interface GenericStatsProps { stats: Stat[]; @@ -11,7 +14,7 @@ export default function GenericStats({ stats, columnCount = 2, }: GenericStatsProps) { - const filteredStats = stats.filter(([, value]) => value !== `${-Infinity}`); + const filteredStats = stats.filter(([, value]) => value !== undefined); const itemsPerRow = Math.ceil(filteredStats.length / columnCount); const columns: Stat[][] = []; diff --git a/src/components/PercentileIndicator.tsx b/src/components/PercentileIndicator.tsx new file mode 100644 index 000000000..9a466a562 --- /dev/null +++ b/src/components/PercentileIndicator.tsx @@ -0,0 +1,45 @@ +import { + blue, + green, + lime, + orange, + pink, + purple, + red, + slate, + teal, + yellow, +} from '@radix-ui/colors'; +import { Percentile } from '../constants/percentiles.js'; + +export interface PercentileIndicatorProps { + percentile: Percentile; +} + +export const PERCENTILE_COLORS = { + [Percentile.VeryBad]: slate.slate9, + [Percentile.Bad]: red.red9, + [Percentile.BelowAverage]: orange.orange9, + [Percentile.Average]: yellow.yellow9, + [Percentile.AboveAverage]: lime.lime9, + [Percentile.Good]: green.green9, + [Percentile.VeryGood]: teal.teal9, + [Percentile.Great]: blue.blue9, + [Percentile.Unicum]: pink.pink9, + [Percentile.SuperUnicum]: purple.purple9, +}; + +export default function PercentileIndicator({ + percentile, +}: PercentileIndicatorProps) { + return ( +
+ ); +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 3c8951a89..ead666fa2 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -19,7 +19,7 @@ export default function TitleBar({ style={{ display: 'flex', alignItems: 'center', - gap: 8, + gap: 16, }} >
@@ -37,6 +37,7 @@ export default function TitleBar({ style={{ display: 'flex', gap: 4, + overflow: 'hidden', }} > @@ -58,8 +57,6 @@ export default function TitleBar({ fontSize: 32, color: theme.colors.textLowContrast, fontWeight: 900, - overflow: 'hidden', - textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > diff --git a/src/components/Wrapper.tsx b/src/components/Wrapper.tsx index 15d51f1c2..9689b4bd3 100644 --- a/src/components/Wrapper.tsx +++ b/src/components/Wrapper.tsx @@ -1,11 +1,25 @@ import { ReactNode } from 'react'; import { theme } from '../stitches.config.js'; -export interface WrapperProps { - children: ReactNode; +export enum WrapperSize { + Regular, + Roomy, } -export default function Wrapper({ children }: WrapperProps) { +export interface WrapperProps { + children: ReactNode; + size?: WrapperSize; +} + +export const WRAPPER_SIZE_WIDTHS: Record = { + [WrapperSize.Regular]: 640, + [WrapperSize.Roomy]: 800, +}; + +export default function Wrapper({ + children, + size = WrapperSize.Regular, +}: WrapperProps) { return (
, @@ -15,7 +15,7 @@ export default async function tanksAutocomplete( await interaction.respond( focusedOption.value - ? go(focusedOption.value, tanks, { keys: ['name'], limit: 10 }).map( + ? go(focusedOption.value, TANKS, { keys: ['name'], limit: 10 }).map( (item) => ({ name: item.obj.name diff --git a/src/core/blitz/getWN8.ts b/src/core/blitz/getWN8.ts new file mode 100644 index 000000000..86da436f9 --- /dev/null +++ b/src/core/blitz/getWN8.ts @@ -0,0 +1,45 @@ +import { AllStats } from '../../types/accountInfo.js'; + +/** + * Formula from https://web.archive.org/web/20190105064817/http://wiki.wnefficiency.net/pages/WN8 + */ +export default function getWN8(expected: AllStats, average: AllStats) { + const avgDmg = average.damage_dealt / average.battles; + const avgSpot = average.spotted / average.battles; + const avgFrag = average.frags / average.battles; + const avgDef = average.dropped_capture_points / average.battles; + const avgWinRate = average.wins / average.battles; + + const expDmg = expected.damage_dealt / expected.battles; + const expSpot = expected.spotted / expected.battles; + const expFrag = expected.frags / expected.battles; + const expDef = expected.dropped_capture_points / expected.battles; + const expWinRate = expected.wins / expected.battles; + + const rDAMAGE = avgDmg / expDmg; + const rSPOT = avgSpot / expSpot; + const rFRAG = avgFrag / expFrag; + const rDEF = avgDef / expDef; + const rWIN = avgWinRate / expWinRate; + + const rWINc = Math.max(0, (rWIN - 0.71) / (1 - 0.71)); + const rDAMAGEc = Math.max(0, (rDAMAGE - 0.22) / (1 - 0.22)); + const rFRAGc = Math.max( + 0, + Math.min(rDAMAGEc + 0.2, (rFRAG - 0.12) / (1 - 0.12)), + ); + const rSPOTc = Math.max( + 0, + Math.min(rDAMAGEc + 0.1, (rSPOT - 0.38) / (1 - 0.38)), + ); + const rDEFc = Math.max(0, Math.min(rDAMAGEc + 0.1, (rDEF - 0.1) / (1 - 0.1))); + + const WN8 = + 980 * rDAMAGEc + + 210 * rDAMAGEc * rFRAGc + + 155 * rFRAGc * rSPOTc + + 75 * rDEFc * rFRAGc + + 145 * Math.min(1.8, rWINc); + + return WN8; +} diff --git a/src/core/blitz/getWN8Percentile.ts b/src/core/blitz/getWN8Percentile.ts new file mode 100644 index 000000000..870bf83d4 --- /dev/null +++ b/src/core/blitz/getWN8Percentile.ts @@ -0,0 +1,21 @@ +import { Percentile } from '../../constants/percentiles.js'; + +const WN8_PERCENTILES: [number, Percentile][] = [ + [0, Percentile.VeryBad], + [300, Percentile.Bad], + [450, Percentile.BelowAverage], + [650, Percentile.Average], + [900, Percentile.AboveAverage], + [1200, Percentile.Good], + [1600, Percentile.VeryGood], + [2000, Percentile.Great], + [2450, Percentile.Unicum], + [2900, Percentile.SuperUnicum], +]; + +export default function getWN8Percentile(WN8: number) { + const lastIndex = WN8_PERCENTILES.findLastIndex( + ([WN8Listing]) => WN8Listing <= WN8, + ); + return WN8_PERCENTILES[lastIndex === -1 ? 0 : lastIndex][1]; +} diff --git a/src/core/blitz/listAccountsPanServer.ts b/src/core/blitz/listAccountsPanServer.ts index 0c021a44a..70643e540 100644 --- a/src/core/blitz/listAccountsPanServer.ts +++ b/src/core/blitz/listAccountsPanServer.ts @@ -7,15 +7,18 @@ export type AccountListWithServer = (Account & { server: 'com' | 'eu' | 'asia'; })[]; -export default async function listAccountsPanServer(search: string, limit = 9) { - const normalizedSearch = encodeURIComponent(search.trim()); - const normalizedLimit = Math.round(limit / 3); +export const usernameRegex = /^[a-zA-Z0-9_]{3,24}$/; - if (normalizedSearch.length >= 3 && normalizedSearch.length <= 100) { +export default async function listAccountsPanServer(search: string, limit = 9) { + const trimmedSearch = search.trim(); + const normalizedLimit = Math.round(limit / 3); + const encodedSearch = encodeURIComponent(trimmedSearch); + + if (usernameRegex.test(trimmedSearch)) { return ( await Promise.all([ getWargamingResponse( - `https://api.wotblitz.com/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${search}&limit=${normalizedLimit}`, + `https://api.wotblitz.com/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => value && @@ -25,7 +28,7 @@ export default async function listAccountsPanServer(search: string, limit = 9) { })), ), getWargamingResponse( - `https://api.wotblitz.eu/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${search}&limit=${normalizedLimit}`, + `https://api.wotblitz.eu/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => value && @@ -35,7 +38,7 @@ export default async function listAccountsPanServer(search: string, limit = 9) { })), ), getWargamingResponse( - `https://api.wotblitz.asia/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${search}&limit=${normalizedLimit}`, + `https://api.wotblitz.asia/wotb/account/list/?application_id=${args['wargaming-application-id']}&search=${encodedSearch}&limit=${normalizedLimit}`, ).then( (value) => value && diff --git a/src/core/blitz/resolveTankId.ts b/src/core/blitz/resolveTankId.ts index fe3d71c94..95507f5d9 100644 --- a/src/core/blitz/resolveTankId.ts +++ b/src/core/blitz/resolveTankId.ts @@ -1,7 +1,7 @@ import { CacheType, ChatInputCommandInteraction } from 'discord.js'; import { go } from 'fuzzysort'; -import { tankopedia, tanks } from '../blitzstars/tankopedia.js'; import errorEmbed from '../interaction/errorEmbed.js'; +import { TANKS, tankopedia } from './tankopedia.js'; export default async function resolveTankId( interaction: ChatInputCommandInteraction, @@ -10,7 +10,7 @@ export default async function resolveTankId( const number = Number(tank); if (Number.isNaN(number)) { - const searchResult = go(tank, tanks, { keys: ['name'], limit: 1 }); + const searchResult = go(tank, TANKS, { keys: ['name'], limit: 1 }); if (searchResult.length === 0) { await interaction.editReply({ diff --git a/src/utilities/resolveTankName.ts b/src/core/blitz/resolveTankName.ts similarity index 74% rename from src/utilities/resolveTankName.ts rename to src/core/blitz/resolveTankName.ts index a17599908..2f97fdb7f 100644 --- a/src/utilities/resolveTankName.ts +++ b/src/core/blitz/resolveTankName.ts @@ -1,4 +1,4 @@ -import { tankopedia } from '../core/blitzstars/tankopedia.js'; +import { tankopedia } from './tankopedia.js'; export default function resolveTankName(tankId: number) { const tankopediaName = tankopedia[tankId].name; diff --git a/src/core/blitz/tankopedia.ts b/src/core/blitz/tankopedia.ts new file mode 100644 index 000000000..19d6c3df3 --- /dev/null +++ b/src/core/blitz/tankopedia.ts @@ -0,0 +1,69 @@ +import { WargamingResponse } from '../../types/wargamingResponse.js'; +import isDev from '../process/isDev.js'; +import URLToBase64 from '../ui/URLToBase64.js'; + +/* +"1": { + "name": "T-34", + "nation": "ussr", + "is_premium": false, + "tier": 5, + "cost": { + "price_credit": 400000, + "price_gold": 0 + }, + "images": { + "preview": "/api/bs-cache/tank-icons/https%3A%2F%2Fglossary-eu-static.gcdn.co%2Ficons%2Fwotb%2Fcurrent%2Fuploaded%2Fvehicles%2Fhd_thumbnail%2FT-34.png", + "normal": "http://glossary-eu-static.gcdn.co/icons/wotb/current/uploaded/vehicles/hd/T-34.png" + }, + "tank_id": 1, + "type": "mediumTank", + "description": "The legend of the Soviet armored forces and the most widely-produced Soviet tank of World War II, with a total of 33,805 vehicles manufactured. Three variants of this model were produced at several Soviet factories from 1940 through 1944." + }, + */ + +export interface Tankopedia { + [id: number]: { + name: string; + nation: string; + is_premium: boolean; + tier: number; + cost: { price_credit: number; price_gold: number }; + images: { preview: string; normal: string }; + tank_id: number; + type: string; + description: string; + }; +} + +console.log('Caching tankopedia...'); +export const tankopedia = ( + (await ( + await fetch('https://www.blitzstars.com/bs-tankopedia.json') + ).json()) as WargamingResponse & { status: 'ok' } +).data; +console.log('Cached tankopedia'); + +/** + * @deprecated + */ +export const TANK_TYPE_EMOJIS: Record = { + 'AT-SPG': '🔽', + heavyTank: '🇭', + mediumTank: '🇲', + lightTank: '🇱', +}; + +export const TANK_IDS = Object.keys(tankopedia).map((id) => Number(id)); +export const TANKS = TANK_IDS.map((id) => tankopedia[id]); +export const TANK_NAMES = TANKS.map((tank) => tank.name); + +if (!isDev()) { + console.log('Encoding tank images...'); + await Promise.all( + Object.entries(tankopedia).map(async ([, tank]) => { + tank.images.normal = await URLToBase64(tank.images.normal); + }), + ); + console.log('Encoded tank images'); +} diff --git a/src/core/blitzstars/getPeriodStart.ts b/src/core/blitzstars/getPeriodStart.ts index 8cb9c3074..916bcd9f7 100644 --- a/src/core/blitzstars/getPeriodStart.ts +++ b/src/core/blitzstars/getPeriodStart.ts @@ -3,8 +3,16 @@ import { StatPeriod } from '../options/addStatPeriodChoices.js'; export default function getPeriodicStart(period: StatPeriod) { if (period === 'career') { return -Infinity; + } else if (period === 'today') { + const now = new Date(); + now.setHours(5, 0, 0, 0); + + // If it's after 5:00 AM today, subtract one day + if (now.getTime() > Date.now()) now.setDate(now.getDate() - 1); + + return now.getTime() / 1000; } else { - const daysAgo = period === 'today' ? 0 : Number(period) - 1; + const daysAgo = Number(period) - 1; const periodStart = new Date(); periodStart.setDate(periodStart.getDate() - daysAgo); diff --git a/src/core/blitzstars/getPlayerStats.ts b/src/core/blitzstars/getPlayerStats.ts deleted file mode 100644 index 2c9d6904d..000000000 --- a/src/core/blitzstars/getPlayerStats.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CacheType, ChatInputCommandInteraction } from 'discord.js'; -import { PlayerStats } from '../../types/playerStats.js'; - -export default async function getPlayerStats( - interaction: ChatInputCommandInteraction, - command: string, - id: number, - callback: (playerStats: PlayerStats) => void, -) { - fetch(`https://www.blitzstars.com/api/playerstats/${id}`).then( - async (response) => { - callback((await response.json()) as PlayerStats); - }, - ); -} diff --git a/src/core/blitzstars/getTankStatsOverTime.ts b/src/core/blitzstars/getTankStatsOverTime.ts index 662b41b3b..1230d7c0f 100644 --- a/src/core/blitzstars/getTankStatsOverTime.ts +++ b/src/core/blitzstars/getTankStatsOverTime.ts @@ -47,8 +47,8 @@ export type TankHistory = TankHistoryNode[]; export default async function getTankStatsOverTime( server: BlitzServer, id: number, - start = -Infinity, - end = Infinity, + start: number, + end: number, ) { const latestTankStatsRaw = await getWargamingResponse( `https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${args['wargaming-application-id']}&account_id=${id}`, diff --git a/src/core/blitzstars/playerAverage.ts b/src/core/blitzstars/playerAverage.ts new file mode 100644 index 000000000..346298fcf --- /dev/null +++ b/src/core/blitzstars/playerAverage.ts @@ -0,0 +1,12 @@ +import sumStats from '../blitz/sumStats.js'; +import { emptyAllStats } from './getTankStatsOverTime.js'; +import { tankAverages } from './tankAverages.js'; + +console.log('Calculating player average...'); +export const playerAverage = Object.entries(tankAverages).reduce( + (accumulator, [, tankAverage]) => { + return sumStats([accumulator, tankAverage.all]); + }, + emptyAllStats, +); +console.log('Calculated player average'); diff --git a/src/core/blitzstars/tankAverages.ts b/src/core/blitzstars/tankAverages.ts new file mode 100644 index 000000000..7d6e5bce2 --- /dev/null +++ b/src/core/blitzstars/tankAverages.ts @@ -0,0 +1,90 @@ +import { AllStats } from '../../types/accountInfo.js'; + +export interface SpecialStats { + winrate: number; + damageRatio: number; + kdr: number; + damagePerBattle: number; + killsPerBattle: number; + hitsPerBattle: number; + spotsPerBattle: number; + wpm: number; + dpm: number; + kpm: number; + hitRate: number; + survivalRate: number; +} + +export type Percentiles = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +export interface SpecialPercentiles { + winRate: Percentiles; + damageRatio: Percentiles; + kdr: Percentiles; + damagePerBattle: Percentiles; + killsPerBattle: Percentiles; + hitsPerBattle: Percentiles; + spotsPerBattle: Percentiles; + wpm: Percentiles; + dpm: Percentiles; + kpm: Percentiles; + hitRate: Percentiles; + survivalRate: Percentiles; +} + +export interface MeanSd { + winrateMean: number; + winrateSd: number; + hitRateMean: number; + hitRateSd: number; + survivalRateMean: number; + survivalRateSd: number; + damageRatioMean: number; + damageRatioSd: number; + kdrMean: number; + kdrSd: number; + dpbMean: number; + dpbSd: number; + kpbMean: number; + kpbSd: number; + hpbMean: number; + hpbSd: number; + spbMean: number; + spbSd: number; +} + +export interface IndividualTankAverages { + _id: number; + battle_life_time: number; + total_battle_life_time: number; + number_of_players: number; + tank_id: number; + all: AllStats; + special: SpecialStats; + percentiles: SpecialPercentiles; + percentiles_player_count: number; + meanSd: MeanSd; + shotEff: number; + last_update: number; +} + +console.log('Caching tank averages...'); +const tankAveragesRaw = (await ( + await fetch('https://www.blitzstars.com/api/tankaverages.json') +).json()) as IndividualTankAverages[]; +export const tankAverages: Record = {}; + +Object.entries(tankAveragesRaw).forEach(([, tank]) => { + tankAverages[tank.tank_id] = tank; +}); +console.log('Tank averages cached'); diff --git a/src/core/blitzstars/tankopedia.ts b/src/core/blitzstars/tankopedia.ts deleted file mode 100644 index 638cf29d0..000000000 --- a/src/core/blitzstars/tankopedia.ts +++ /dev/null @@ -1,75 +0,0 @@ -import isDev from '../process/isDev.js'; - -export type TankType = 'heavyTank' | 'AT-SPG' | 'mediumTank' | 'lightTank'; - -export interface Tankopedia { - status: 'ok'; // always will be ok since it is cached - meta: { - count: number; - }; - data: { - [tankId: number]: { - name?: string; - nation: string; - is_premium: boolean; - tier: number; - cost: { - price_credit: number; - price_gold: number; - }; - images: { - preview: string; - normal?: string; - }; - tank_id: number; - type: TankType; - description: string; - }; - }; -} - -console.log('Caching tankopedia...'); -export const tankopedia = ( - (await ( - await fetch('https://www.blitzstars.com/bs-tankopedia.json') - ).json()) as Tankopedia -).data; -console.log('Cached tankopedia'); - -export const tankIds = Object.keys(tankopedia) as unknown as number[]; - -export const tankNames = tankIds.map((id) => tankopedia[id].name); - -export const tanks = tankIds.map((id) => tankopedia[id]); - -export const TANK_TYPE_NAMES: Record = { - 'AT-SPG': 'Tank Destroyer', - heavyTank: 'Heavy Tank', - mediumTank: 'Medium Tank', - lightTank: 'Light Tank', -}; - -export const TANK_TYPE_EMOJIS: Record = { - 'AT-SPG': '🔽', - heavyTank: '🇭', - mediumTank: '🇲', - lightTank: '🇱', -}; - -if (!isDev()) { - console.log('Encoding tank images...'); - await Promise.all( - Object.entries(tankopedia).map(async ([, tank]) => { - if (tank.images.normal) { - const response = await fetch(tank.images.normal); - const blob = await response.arrayBuffer(); - const base64 = `data:${response.headers.get( - 'content-type', - )};base64,${Buffer.from(blob).toString('base64')}`; - - tank.images.normal = base64; - } - }), - ); - console.log('Encoded tank images'); -} diff --git a/src/core/interaction/cleanTable.ts b/src/core/interaction/cleanTable.ts index 7939453cc..45961433c 100644 --- a/src/core/interaction/cleanTable.ts +++ b/src/core/interaction/cleanTable.ts @@ -3,7 +3,7 @@ export type TableInput = TableInputEntry[]; export default function cleanTable(input: TableInput) { return input - .filter((array) => array[1] !== `${-Infinity}`) + .filter((array) => array[1] !== undefined) .map((array) => (array.length === 0 ? '' : `**${array[0]}**: ${array[1]}`)) .join('\n'); } diff --git a/src/core/actions/fullBlitzStarsStats.ts b/src/core/interaction/fullBlitzStarsStats.ts similarity index 100% rename from src/core/actions/fullBlitzStarsStats.ts rename to src/core/interaction/fullBlitzStarsStats.ts diff --git a/src/core/actions/supportBlitzStars.ts b/src/core/interaction/supportBlitzStars.ts similarity index 100% rename from src/core/actions/supportBlitzStars.ts rename to src/core/interaction/supportBlitzStars.ts diff --git a/src/core/ui/URLToBase64.ts b/src/core/ui/URLToBase64.ts new file mode 100644 index 000000000..31b7c9954 --- /dev/null +++ b/src/core/ui/URLToBase64.ts @@ -0,0 +1,9 @@ +export default async function URLToBase64(URL: string) { + const response = await fetch(URL); + const blob = await response.arrayBuffer(); + const base64 = `data:${response.headers.get( + 'content-type', + )};base64,${Buffer.from(blob).toString('base64')}`; + + return base64; +} diff --git a/src/core/ui/render.tsx b/src/core/ui/render.tsx index 10afb44ef..6100c1793 100644 --- a/src/core/ui/render.tsx +++ b/src/core/ui/render.tsx @@ -2,6 +2,7 @@ import { Resvg } from '@resvg/resvg-js'; import { readFile } from 'fs'; import { ReactNode } from 'react'; import satori from 'satori'; +import { WRAPPER_SIZE_WIDTHS, WrapperSize } from '../../components/Wrapper.js'; const FONT_NAME = 'Roboto'; const FONT_FILES = ['Roboto', 'Roboto-Bold', 'Roboto-Black']; @@ -20,9 +21,12 @@ const [robotoFlex, robotoFlexBold, robotoFlexBlack] = await Promise.all( ); console.log('Fonts imported'); -export default async function render(element: ReactNode) { +export default async function render( + element: ReactNode, + size: WrapperSize = WrapperSize.Regular, +) { const svg = await satori(element, { - width: 640, + width: WRAPPER_SIZE_WIDTHS[size], fonts: [ { data: robotoFlex, name: FONT_NAME, weight: 400 }, { data: robotoFlexBold, name: FONT_NAME, weight: 700 }, diff --git a/src/types/accountInfo.ts b/src/types/accountInfo.ts index f4eceb21d..908930892 100644 --- a/src/types/accountInfo.ts +++ b/src/types/accountInfo.ts @@ -18,16 +18,9 @@ export interface AllStats { dropped_capture_points: number; } -export interface SpecialStats { - winrate: number; - damageRatio: number; - kdr: number; - dpb: number; - kpb: number; - hpb: number; - spb: number; - hitRate: number; - survivalRate: number; +export interface SupplementaryStats { + WN8: number; + tier: number; } export interface ClanStats { diff --git a/src/types/playerStats.ts b/src/types/playerStats.ts deleted file mode 100644 index 5f9e31662..000000000 --- a/src/types/playerStats.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BlitzStartsComputedPeriodicStatistics } from './statistics.js'; - -export type PlayerStats = { - clan: { - clan_id: number; - name: string; - tag: string; - }; - statistics: BlitzStartsComputedPeriodicStatistics; - _id: string; - last_battle_time: number; - nickname: string; - account_id: number; - achievements: { - account_id: string; - medalLafayettePool: number; - medalRadleyWalters: number; - warrior: number; - jointVictoryCount: number; - }; - region: string; - __v: number; -}[]; diff --git a/src/types/statistics.ts b/src/types/statistics.ts deleted file mode 100644 index 0f051d96a..000000000 --- a/src/types/statistics.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AllStats, SpecialStats } from './accountInfo.js'; - -export interface BlitzStartsComputedPeriodicStatistics { - all: AllStats; - - wn7: number; - wn8: number; - avg_tier: number; -} - -export interface PeriodStatistics - extends BlitzStartsComputedPeriodicStatistics { - special: SpecialStats; - pwp: number; -} - -export interface PlayerPeriodicStatsCollection { - statistics: PeriodStatistics; - period30d: PeriodStatistics; - period90d: PeriodStatistics; - - _id: string; - nickname: string; - nickname_lowercase: string; - region: string; - last_battle_time: number; - __v: number; - updatedAt: string; - createdAt: string; -}