Merge pull request #1 from tresabhi/1.5.0

This commit is contained in:
TrèsAbhi authored and GitHub committed 2023-06-01 20:41:27 -05:00
commit f4b20611d5
39 files changed
+663 -359

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"Smri",
"strvk",
"tankopedia",
"Unicum",
"Unregistering",
"vickers",
"wargaming",
+23 -19
View File
@@ -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}`],
]),
),
],
});
+2 -2
View File
@@ -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
+9 -7
View File
@@ -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<AccountList>(
`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<AccountList>(
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${
args['wargaming-application-id']
}&search=${encodeURIComponent(trimmedSearch)}&limit=${limit}`,
)
: [];
await interaction.editReply({
embeds: [
+2 -2
View File
@@ -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,
);
+45 -51
View File
@@ -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 && <NoBattlesInPeriod />}
{stats.battles > 0 && (
<GenericStats
stats={[
[
'Winrate',
`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
],
[
'Survival',
`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2,
)}%`,
],
['Accuracy', `${((stats.hits / stats.shots) * 100).toFixed(2)}%`],
['Battles', stats.battles],
['Wins', stats.wins],
['Losses', stats.losses],
[
'Average damage',
(stats.damage_dealt / stats.battles).toFixed(0),
],
['Average XP', (stats.xp / stats.battles).toFixed(0)],
['Average shots', (stats.shots / stats.battles).toFixed(2)],
['Average hits', (stats.hits / stats.battles).toFixed(2)],
['Average kills', (stats.frags / stats.battles).toFixed(2)],
['Average spots', (stats.spotted / stats.battles).toFixed(2)],
[
'Damage ratio',
(stats.damage_dealt / stats.damage_received).toFixed(2),
],
[
'Kills to death ratio',
(
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2),
],
]}
<GenericAllStats
stats={stats}
supplementaryStats={supplementaryStats}
/>
)}
+26 -55
View File
@@ -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(
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={`(${resolveTankName(Number(tank))})`}
image={tankopedia[Number(tank)].images.normal}
nameDiscriminator={`(${resolveTankName(tankId)})`}
image={tankopedia[tankId].images.normal}
description={`${
statPeriodNames[period]
} • ${new Date().toDateString()} • ${BLITZ_SERVERS[server]}`}
@@ -93,44 +96,12 @@ export default {
{stats.battles === 0 && <NoBattlesInPeriod />}
{stats.battles > 0 && (
// TODO: GenericStats generator for AllStats
<GenericStats
stats={[
[
'Winrate',
`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
],
[
'Survival',
`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2,
)}%`,
],
['Accuracy', `${((stats.hits / stats.shots) * 100).toFixed(2)}%`],
['Battles', stats.battles],
['Wins', stats.wins],
['Losses', stats.losses],
[
'Average damage',
(stats.damage_dealt / stats.battles).toFixed(0),
],
['Average XP', (stats.xp / stats.battles).toFixed(0)],
['Average shots', (stats.shots / stats.battles).toFixed(2)],
['Average hits', (stats.hits / stats.battles).toFixed(2)],
['Average kills', (stats.frags / stats.battles).toFixed(2)],
['Average spots', (stats.spotted / stats.battles).toFixed(2)],
[
'Damage ratio',
(stats.damage_dealt / stats.damage_received).toFixed(2),
],
[
'Kills to death ratio',
(
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2),
],
]}
<GenericAllStats
stats={stats}
supplementaryStats={{
tier: tankopedia[tankId].tier,
WN8: getWN8(tankAverages[tankId].all, stats),
}}
/>
)}
+96 -31
View File
@@ -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<Record<number, number>>(
(accumulator, [tankIdString, tankStats]) => {
const tankId = Number(tankIdString);
return (
<Breakdown.Row
key={tankId}
name={tankId === '0' ? 'Today' : resolveTankName(Number(tankId))}
winrate={tankStats.wins / tankStats.battles}
careerWinrate={career.wins / career.battles}
WN8={-Infinity}
careerWN8={-Infinity}
damage={tankStats.damage_dealt / tankStats.battles}
careerDamage={career.damage_dealt / career.battles}
survival={tankStats.survived_battles / tankStats.battles}
careerSurvival={career.survived_battles / career.battles}
battles={tankStats.battles}
careerBattles={career.battles}
icon={
tankId === '0'
? undefined
: tankopedia[tankId as unknown as number].images.normal
}
/>
);
return tankId === 0
? accumulator
: {
...accumulator,
[tankId]: getWN8(tankAverages[tankId].all, tankStats),
};
},
{},
);
const careerWN8s = tankStatsOverTimeEntries.reduce<Record<number, number>>(
(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 (
<Breakdown.Row
key={tankId}
name={tankId === 0 ? 'Total' : resolveTankName(Number(tankIdString))}
winrate={tankStats.wins / tankStats.battles}
careerWinrate={career.wins / career.battles}
WN8={todayWN8s[tankId]}
careerWN8={careerWN8s[tankId]}
damage={tankStats.damage_dealt / tankStats.battles}
careerDamage={career.damage_dealt / career.battles}
survival={tankStats.survived_battles / tankStats.battles}
careerSurvival={career.survived_battles / career.battles}
battles={tankStats.battles}
careerBattles={career.battles}
icon={
tankId === 0
? undefined
: tankopedia[tankIdString as unknown as number].images.normal
}
/>
);
});
const image = await render(
<Wrapper>
<Wrapper size={WrapperSize.Roomy}>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={
@@ -125,6 +180,7 @@ export default {
<PoweredByBlitzStars />
</Wrapper>,
WrapperSize.Roomy,
);
const actionRow = new ActionRowBuilder<ButtonBuilder>().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],
});
+12 -5
View File
@@ -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}
/>
<RowStat
name="WN8"
value={WN8.toFixed(0)}
career={careerWN8.toFixed(0)}
percentile={getWN8Percentile(WN8)}
/>
<RowStat
name="Damage"
value={averageDamage.toFixed(2)}
career={careerAverageDamage.toFixed(2)}
delta={averageDamage - careerAverageDamage}
value={damage.toFixed(2)}
career={careerDamage.toFixed(2)}
delta={damage - careerDamage}
/>
<RowStat
name="Survival"
@@ -33,7 +33,7 @@ export function RowDiscriminator({ name, icon }: RowDiscriminatorProps) {
display: 'block',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
textOverflow: 'clip',
maxWidth: '100%',
}}
>
@@ -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 (
<div
style={{
@@ -31,7 +40,7 @@ export function RowStat({ name, value, career, delta }: RowStatProps) {
justifyContent: 'center',
}}
>
{delta && (
{percentile === undefined && delta !== undefined && delta !== 0 && (
<img
src={
(delta ?? 0) > 0
@@ -41,6 +50,9 @@ export function RowStat({ name, value, career, delta }: RowStatProps) {
style={{ width: 12, height: 12 }}
/>
)}
{percentile !== undefined && (
<PercentileIndicator percentile={percentile} />
)}
<span
style={{
+51
View File
@@ -0,0 +1,51 @@
import getWN8Percentile from '../core/blitz/getWN8Percentile.js';
import { AllStats, SupplementaryStats } from '../types/accountInfo.js';
import GenericStats from './GenericStats/index.js';
export interface GenericAllStatsProps {
stats: AllStats;
supplementaryStats?: SupplementaryStats;
}
export default function GenericAllStats({
stats,
supplementaryStats,
}: GenericAllStatsProps) {
return (
<GenericStats
stats={[
['Winrate', `${(100 * (stats.wins / stats.battles)).toFixed(2)}%`],
[
'WN8',
supplementaryStats?.WN8.toFixed(0),
supplementaryStats
? getWN8Percentile(supplementaryStats.WN8)
: undefined,
],
[
'Survival',
`${(100 * (stats.survived_battles / stats.battles)).toFixed(2)}%`,
],
['Accuracy', `${((stats.hits / stats.shots) * 100).toFixed(2)}%`],
['Battles', stats.battles],
['Wins', stats.wins],
['Losses', stats.losses],
['Average damage', (stats.damage_dealt / stats.battles).toFixed(0)],
['Average tier', supplementaryStats?.tier.toFixed(2)],
['Average XP', (stats.xp / stats.battles).toFixed(0)],
['Average shots', (stats.shots / stats.battles).toFixed(2)],
['Average hits', (stats.hits / stats.battles).toFixed(2)],
['Average kills', (stats.frags / stats.battles).toFixed(2)],
['Average spots', (stats.spotted / stats.battles).toFixed(2)],
[
'Damage ratio',
(stats.damage_dealt / stats.damage_received).toFixed(2),
],
[
'Kills to death ratio',
(stats.frags / (stats.battles - stats.survived_battles)).toFixed(2),
],
]}
/>
);
}
@@ -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) {
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
{stat[0]}
</span>
<span style={{ color: theme.colors.textHighContrast, fontWeight: 'bold', fontSize: 16 }}>
<span
style={{
color: theme.colors.textHighContrast,
fontWeight: 'bold',
fontSize: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
}}
>
{stat[2] !== undefined && <PercentileIndicator percentile={stat[2]} />}
{stat[1]}
</span>
</div>
+5 -2
View File
@@ -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[][] = [];
+45
View File
@@ -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 (
<div
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: PERCENTILE_COLORS[percentile],
}}
/>
);
}
+2 -5
View File
@@ -19,7 +19,7 @@ export default function TitleBar({
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
gap: 16,
}}
>
<div style={{ display: 'flex', gap: 8, flex: 1 }}>
@@ -37,6 +37,7 @@ export default function TitleBar({
style={{
display: 'flex',
gap: 4,
overflow: 'hidden',
}}
>
<span
@@ -44,8 +45,6 @@ export default function TitleBar({
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
@@ -58,8 +57,6 @@ export default function TitleBar({
fontSize: 32,
color: theme.colors.textLowContrast,
fontWeight: 900,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
+18 -4
View File
@@ -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, number> = {
[WrapperSize.Regular]: 640,
[WrapperSize.Roomy]: 800,
};
export default function Wrapper({
children,
size = WrapperSize.Regular,
}: WrapperProps) {
return (
<div
style={{
@@ -13,7 +27,7 @@ export default function Wrapper({ children }: WrapperProps) {
flexDirection: 'column',
padding: 16,
gap: 32,
width: 640,
width: WRAPPER_SIZE_WIDTHS[size],
background: 'url(https://i.imgur.com/PhS06NJ.png)',
color: theme.colors.textHighContrast,
}}
+13
View File
@@ -0,0 +1,13 @@
// http://www.wotinfo.net/en/wn8scale
export enum Percentile {
VeryBad,
Bad,
BelowAverage,
Average,
AboveAverage,
Good,
VeryGood,
Great,
Unicum,
SuperUnicum,
}
+3 -3
View File
@@ -4,8 +4,8 @@ import {
CacheType,
} from 'discord.js';
import { go } from 'fuzzysort';
import resolveTankName from '../../utilities/resolveTankName.js';
import { tanks } from '../blitzstars/tankopedia.js';
import resolveTankName from '../blitz/resolveTankName.js';
import { TANKS } from '../blitz/tankopedia.js';
export default async function tanksAutocomplete(
interaction: AutocompleteInteraction<CacheType>,
@@ -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
+45
View File
@@ -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;
}
+21
View File
@@ -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];
}
+10 -7
View File
@@ -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<AccountList>(
`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<AccountList>(
`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<AccountList>(
`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 &&
+2 -2
View File
@@ -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<CacheType>,
@@ -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({
@@ -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;
+69
View File
@@ -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<Tankopedia> & { status: 'ok' }
).data;
console.log('Cached tankopedia');
/**
* @deprecated
*/
export const TANK_TYPE_EMOJIS: Record<string, string> = {
'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');
}
+9 -1
View File
@@ -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);
-15
View File
@@ -1,15 +0,0 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { PlayerStats } from '../../types/playerStats.js';
export default async function getPlayerStats(
interaction: ChatInputCommandInteraction<CacheType>,
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);
},
);
}
+2 -2
View File
@@ -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<TanksStats>(
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${args['wargaming-application-id']}&account_id=${id}`,
+12
View File
@@ -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');
+90
View File
@@ -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<number, IndividualTankAverages> = {};
Object.entries(tankAveragesRaw).forEach(([, tank]) => {
tankAverages[tank.tank_id] = tank;
});
console.log('Tank averages cached');
-75
View File
@@ -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<TankType, string> = {
'AT-SPG': 'Tank Destroyer',
heavyTank: 'Heavy Tank',
mediumTank: 'Medium Tank',
lightTank: 'Light Tank',
};
export const TANK_TYPE_EMOJIS: Record<TankType, string> = {
'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');
}
+1 -1
View File
@@ -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');
}
+9
View File
@@ -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;
}
+6 -2
View File
@@ -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 },
+3 -10
View File
@@ -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 {
-23
View File
@@ -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;
}[];
-30
View File
@@ -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;
}