diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx
index 3dc6d6ddb..5b4b8b1db 100644
--- a/src/commands/evolution.tsx
+++ b/src/commands/evolution.tsx
@@ -11,7 +11,7 @@ import { StatType } from '../renderers/statsfull.js';
export default {
inProduction: true,
- inDevelopment: true,
+ inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx
new file mode 100644
index 000000000..e0f85a615
--- /dev/null
+++ b/src/commands/stats.tsx
@@ -0,0 +1,73 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { CYCLIC_API } from '../constants/cyclic.js';
+import resolveTankId from '../core/blitz/resolveTankId.js';
+import { Period } from '../core/discord/addPeriodSubCommands.js';
+import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups.js';
+import autocompleteTanks from '../core/discord/autocompleteTanks.js';
+import autocompleteUsername from '../core/discord/autocompleteUsername.js';
+import interactionToURL from '../core/discord/interactionToURL.js';
+import linkButton from '../core/discord/linkButton.js';
+import primaryButton from '../core/discord/primaryButton.js';
+import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton.js';
+import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand.js';
+import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js';
+import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
+import { CommandRegistry } from '../events/interactionCreate/index.js';
+import stats from '../renderers/stats.js';
+import statsfull, { StatType } from '../renderers/statsfull.js';
+
+export default {
+ inProduction: true,
+ inDevelopment: true,
+ inPublic: true,
+
+ command: addStatTypeSubCommandGroups(
+ new SlashCommandBuilder()
+ .setName('stats')
+ .setDescription('In-game statistics'),
+ ),
+
+ async handler(interaction) {
+ const commandGroup = interaction.options.getSubcommandGroup(
+ true,
+ ) as StatType;
+ const subcommand = interaction.options.getSubcommand() as Period;
+ const player = await resolvePlayerFromCommand(interaction);
+ const period = resolvePeriodFromCommand(interaction);
+ const tankIdRaw = interaction.options.getString('tank')!;
+ const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : 0;
+ const start = interaction.options.getInteger('start');
+ const end = interaction.options.getInteger('end');
+ const path = `stats/${commandGroup}/${subcommand}?server=${
+ player.server
+ }&id=${player.id}&tankId=${tankId}&start=${start ?? 0}&end=${end ?? 0}`;
+
+ interactionToURL(interaction);
+
+ return [
+ await stats(commandGroup, period, player, tankId),
+ primaryButton(path, 'Refresh'),
+ linkButton(path, 'Embed'),
+ ];
+ },
+
+ autocomplete: (interaction) => {
+ autocompleteUsername(interaction);
+ autocompleteTanks(interaction);
+ },
+
+ 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 period = resolvePeriodFromButton(interaction);
+ const player = await resolvePlayerFromButton(interaction);
+
+ return await statsfull(
+ commandGroup,
+ period,
+ player,
+ parseInt(url.searchParams.get('tankId')!),
+ );
+ },
+} satisfies CommandRegistry;
diff --git a/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx
new file mode 100644
index 000000000..7c5d2f93e
--- /dev/null
+++ b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx
@@ -0,0 +1,39 @@
+import { Percentile } from '../../../../../constants/percentiles.js';
+import hslToHex from '../../../../../core/node/hslToHex.js';
+import { PERCENTILE_COLORS } from '../../../../PercentileIndicator.js';
+
+export enum GlowSide {
+ Top,
+ Bottom,
+}
+
+export interface GlowProps {
+ percentile: Percentile;
+ side: GlowSide;
+}
+
+export function Glow({ percentile, side }: GlowProps) {
+ const color = PERCENTILE_COLORS[percentile];
+ const color1 = hslToHex(color);
+ const color2 = `${color1}00`;
+
+ const background = `linear-gradient(${
+ side === GlowSide.Top ? 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
new file mode 100644
index 000000000..08f257fe2
--- /dev/null
+++ b/src/components/AllStatsOverview/components/WN8Display/index.tsx
@@ -0,0 +1,77 @@
+import { Percentile } from '../../../../constants/percentiles.js';
+import getWN8Percentile from '../../../../core/blitz/getWN8Percentile.js';
+import { theme } from '../../../../stitches.config.js';
+import { PERCENTILE_COLORS } from '../../../PercentileIndicator.js';
+import { Glow, GlowSide } from './components/Glow.js';
+
+export interface WN8DisplayProps {
+ WN8?: number;
+}
+
+export const PERCENTILE_NAMES = {
+ [Percentile.VeryBad]: 'Very Bad',
+ [Percentile.Bad]: 'Bad',
+ [Percentile.BelowAverage]: 'Below Average',
+ [Percentile.Average]: 'Average',
+ [Percentile.AboveAverage]: 'Above Average',
+ [Percentile.Good]: 'Good',
+ [Percentile.VeryGood]: 'Very Good',
+ [Percentile.Great]: 'Great',
+ [Percentile.Unicum]: 'Unicum',
+ [Percentile.SuperUnicum]: 'Super Unicum',
+};
+
+export function WN8Display({ WN8 }: WN8DisplayProps) {
+ const percentile =
+ WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(WN8);
+ const color = PERCENTILE_COLORS[percentile];
+
+ return (
+
+
+
+
+
+ {WN8 === undefined ? '--' : WN8.toFixed(0)}
+
+
+ {PERCENTILE_NAMES[percentile]}
+
+
+
+
+
+ );
+}
diff --git a/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx b/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx
new file mode 100644
index 000000000..4c48d00fa
--- /dev/null
+++ b/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx
@@ -0,0 +1,47 @@
+import { theme } from '../../../stitches.config.js';
+
+export enum WN8SurroundingStatAlign {
+ Left,
+ Right,
+}
+
+export interface WN8SurroundingStatProps {
+ label: string;
+ value: string | number;
+ align?: WN8SurroundingStatAlign;
+ padded?: boolean;
+}
+
+export function WN8SurroundingStat({
+ label,
+ value,
+ align = WN8SurroundingStatAlign.Left,
+ padded = false,
+}: WN8SurroundingStatProps) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
diff --git a/src/components/AllStatsOverview/index.tsx b/src/components/AllStatsOverview/index.tsx
new file mode 100644
index 000000000..0571520e7
--- /dev/null
+++ b/src/components/AllStatsOverview/index.tsx
@@ -0,0 +1,97 @@
+import { AllStats, SupplementaryStats } from '../../types/accountInfo.js';
+import { WN8Display } from './components/WN8Display/index.js';
+import {
+ WN8SurroundingStat,
+ WN8SurroundingStatAlign,
+} from './components/WN8SurroundingStat.js';
+
+export interface AllStatsOverviewProps {
+ stats: AllStats;
+ supplementaryStats: SupplementaryStats;
+}
+
+export default function AllStatsOverview({
+ stats,
+ supplementaryStats,
+}: AllStatsOverviewProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/core/node/hslToHex.ts b/src/core/node/hslToHex.ts
new file mode 100644
index 000000000..304ed774d
--- /dev/null
+++ b/src/core/node/hslToHex.ts
@@ -0,0 +1,22 @@
+const numberRegex = /\d+(\.\d+)?/g;
+
+/**
+ * Thanks to icl7126!
+ * https://stackoverflow.com/a/44134328/12294756
+ */
+export default function hslToHex(hsl: string) {
+ let [h, s, l] = Array.from(hsl.matchAll(numberRegex)).map((m) =>
+ parseFloat(m[0]),
+ );
+
+ l /= 100;
+ const a = (s * Math.min(l, 1 - l)) / 100;
+ const f = (n: number) => {
+ const k = (n + h / 30) % 12;
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+ return Math.round(255 * color)
+ .toString(16)
+ .padStart(2, '0'); // convert to Hex and prefix "0" if needed
+ };
+ return `#${f(0)}${f(8)}${f(4)}`;
+}
diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts
index b4f48ad9e..92e145979 100644
--- a/src/events/interactionCreate/index.ts
+++ b/src/events/interactionCreate/index.ts
@@ -25,6 +25,7 @@ import playerinfo from '../../commands/playerinfo.js';
import searchclans from '../../commands/searchclans.js';
import searchplayers from '../../commands/searchplayers.js';
import searchtanks from '../../commands/searchtanks.js';
+import stats from '../../commands/stats.js';
import statsfull from '../../commands/statsfull.js';
import today from '../../commands/today.js';
import verify from '../../commands/verify.js';
@@ -86,6 +87,7 @@ export const commands: Record = (
verify,
ping,
evolution,
+ stats,
] as CommandRegistry[]
).reduce((accumulator, registry) => {
if (isDev()) registry.command.setDefaultMemberPermissions(0);
diff --git a/src/renderers/stats.tsx b/src/renderers/stats.tsx
new file mode 100644
index 000000000..aec9de200
--- /dev/null
+++ b/src/renderers/stats.tsx
@@ -0,0 +1,149 @@
+import AllStatsOverview from '../components/AllStatsOverview/index.js';
+import NoData, { NoDataType } from '../components/NoData.js';
+import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
+import { TierWeightsRecord } from '../components/TierWeights.js';
+import TitleBar from '../components/TitleBar.js';
+import Wrapper from '../components/Wrapper.js';
+import { BLITZ_SERVERS } from '../constants/servers.js';
+import calculateWN8 from '../core/blitz/calculateWN8.js';
+import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
+import resolveTankName from '../core/blitz/resolveTankName.js';
+import sumStats from '../core/blitz/sumStats.js';
+import { Tier, tankopedia } from '../core/blitz/tankopedia.js';
+import getTankStatsDiffed from '../core/blitzstars/getTankStatsDiffed.js';
+import { tankAverages } from '../core/blitzstars/tankAverages.js';
+import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand.js';
+import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand.js';
+import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
+import {
+ AccountInfo,
+ AllStats,
+ SupplementaryStats,
+} from '../types/accountInfo.js';
+import { PlayerClanData } from '../types/playerClanData.js';
+
+export type StatType = 'player' | 'tank';
+
+export default async function stats(
+ type: Type,
+ { start, end, statsName }: ResolvedPeriod,
+ { server, id }: ResolvedPlayer,
+ tankId: Type extends 'tank' ? number : never,
+) {
+ 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 if (type === 'tank') {
+ nameDiscriminator = `(${resolveTankName(tankId)})`;
+ image = tankopedia[tankId].images.normal;
+ }
+
+ const tankStats = await getTankStatsDiffed(server, id, start, end);
+ let stats: AllStats;
+ let supplementaryStats: SupplementaryStats;
+ let tierWeights: TierWeightsRecord;
+
+ if (type === 'player') {
+ const entries = Object.entries(tankStats);
+ stats = sumStats(entries.map(([, stats]) => stats));
+ const battlesOfTanksWithAverages = entries.reduce(
+ (accumulator, [tankIdString, stats]) => {
+ const tankId = parseInt(tankIdString);
+ const tankAverage = tankAverages[tankId];
+
+ return tankAverage ? accumulator + stats.battles : accumulator;
+ },
+ 0,
+ );
+ const battlesOfTanksWithTankopediaEntry = entries.reduce(
+ (accumulator, [tankIdString, stats]) => {
+ const tankId = parseInt(tankIdString);
+ const tankopediaEntry = tankopedia[tankId];
+
+ return tankopediaEntry ? accumulator + stats.battles : accumulator;
+ },
+ 0,
+ );
+
+ supplementaryStats = {
+ WN8:
+ entries.reduce((accumulator, [tankIdString, stats]) => {
+ const tankId = parseInt(tankIdString);
+ const tankAverage = tankAverages[tankId];
+
+ return tankAverage
+ ? accumulator + calculateWN8(tankAverage.all, stats) * stats.battles
+ : accumulator;
+ }, 0) / battlesOfTanksWithAverages,
+ tier:
+ entries.reduce((accumulator, [tankIdString, stats]) => {
+ const tankId = parseInt(tankIdString);
+ const tankopediaEntry = tankopedia[tankId];
+
+ return tankopediaEntry
+ ? accumulator + tankopediaEntry.tier * stats.battles
+ : accumulator;
+ }, 0) / battlesOfTanksWithTankopediaEntry,
+ };
+ tierWeights = entries.reduce(
+ (accumulator, [tankIdString, stats]) => {
+ const tankId = parseInt(tankIdString);
+ const tier = tankopedia[tankId].tier as Tier;
+
+ if (accumulator[tier]) {
+ accumulator[tier]! += stats.battles;
+ } else {
+ accumulator[tier] = stats.battles;
+ }
+
+ return accumulator;
+ },
+ {},
+ );
+ } else {
+ stats = tankStats[tankId];
+
+ supplementaryStats = {
+ WN8: calculateWN8(tankAverages[tankId].all, tankStats[tankId]),
+ tier: tankopedia[tankId].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 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/routers/stats.tsx b/src/routers/stats.tsx
index cdaea9824..861933cfe 100644
--- a/src/routers/stats.tsx
+++ b/src/routers/stats.tsx
@@ -6,7 +6,7 @@ import { RouterRegistry } from '../server.js';
export default {
route: '/stats/:type/:period',
inProduction: true,
- inDevelopment: true,
+ inDevelopment: false,
async handler(req) {
const type = req.params.type as StatType;
diff --git a/tsconfig.json b/tsconfig.json
index 8d1a8a7e4..d47b9a960 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
+ // "moduleResolution": "Node", TODO: implement Node module resolution
"moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
@@ -11,5 +12,5 @@
"resolveJsonModule": true,
"jsx": "react-jsx"
},
- "include": ["src/**/*.ts"]
+ "include": ["src/**/*.ts", "src/components/AllStatsOverview/components/WN8SurroundingStat.tsx"]
}