mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: migration 3
This commit is contained in:
1 parent
6285f33989
commit
4055988478
110 files changed
+28
-76
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { COMMANDS_RAW, CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const subcommands = [
|
||||
'embeds',
|
||||
'introduction',
|
||||
'invite',
|
||||
'timezones',
|
||||
'creators',
|
||||
] as const;
|
||||
type Subcommand = (typeof subcommands)[number];
|
||||
|
||||
const DOCS: Record<Subcommand, string> = {
|
||||
embeds: 'guide/embeds',
|
||||
introduction: 'guide/introduction',
|
||||
invite: 'guide/invite',
|
||||
timezones: 'guide/timezones',
|
||||
creators: 'guide/creators',
|
||||
};
|
||||
|
||||
export const aboutCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('about', [
|
||||
{ subcommand: 'commands' },
|
||||
...subcommands.map((subcommand) => ({ subcommand })),
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const { t } = translator(interaction.locale);
|
||||
|
||||
if (subcommand === 'commands') {
|
||||
return t`bot.commands.about.subcommands.commands.body${(
|
||||
await Promise.all(COMMANDS_RAW)
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(a.command.name_localizations?.[interaction.locale] ??
|
||||
a.command.name) <
|
||||
(b.command.name_localizations?.[interaction.locale] ??
|
||||
b.command.name)
|
||||
? -1
|
||||
: 1,
|
||||
)
|
||||
.map(
|
||||
(registry) =>
|
||||
`- \`/${
|
||||
registry.command.name_localizations?.[interaction.locale] ??
|
||||
registry.command.name
|
||||
}\`: ${
|
||||
registry.command.description_localizations?.[
|
||||
interaction.locale
|
||||
] ?? registry.command.description
|
||||
}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
const url = `https://raw.githubusercontent.com/tresabhi/blitzkit/main/docs/${DOCS[subcommand as Subcommand]}.md`;
|
||||
const content = fetch(url).then((response) => response.text());
|
||||
|
||||
return content;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getBlitzStarsLinkButton } from '../../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import addPeriodicFilterOptions from '../../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../../core/discord/buttonRefresh';
|
||||
import commandToURL from '../../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../../core/discord/getFiltersFromCommand';
|
||||
import resolvePeriodFromButton from '../../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand from '../../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand from '../../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistry } from '../../events/interactionCreate';
|
||||
import { renderBreakdown } from './render';
|
||||
|
||||
export const breakdownCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
resolve({
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('breakdown'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
...filters,
|
||||
});
|
||||
|
||||
return [
|
||||
...(await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
)),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await renderBreakdown(
|
||||
player,
|
||||
period,
|
||||
filters,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,286 @@
|
||||
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
|
||||
import { Locale } from 'discord.js';
|
||||
import { chunk } from 'lodash';
|
||||
import * as Breakdown from '../../components/Breakdown';
|
||||
import CommandWrapper from '../../components/CommandWrapper';
|
||||
import NoData from '../../components/NoData';
|
||||
import TitleBar from '../../components/TitleBar';
|
||||
import { getAccountInfo } from '../../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../../core/blitz/getTankStats';
|
||||
import { filtersToDescription } from '../../core/blitzkit/filtersToDescription';
|
||||
import {
|
||||
TankDefinition,
|
||||
tankDefinitions,
|
||||
} from '../../core/blitzkit/tankDefinitions';
|
||||
import { tankAverages } from '../../core/blitzstars/tankAverages';
|
||||
import { ResolvedPeriod } from '../../core/discord/resolvePeriodFromCommand';
|
||||
import { ResolvedPlayer } from '../../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import calculateWN8 from '../../core/statistics/calculateWN8';
|
||||
import { BlitzStats } from '../../core/statistics/compositeStats/constants';
|
||||
import { StatFilters, filterStats } from '../../core/statistics/filterStats';
|
||||
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
|
||||
import { UserError } from '../../hooks/userError';
|
||||
|
||||
const ROWS_PER_PAGE = 8;
|
||||
const MAX_PAGES = 9;
|
||||
|
||||
export async function renderBreakdown(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { t, translate } = translator(locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const statsInPeriod = await getStatsInPeriod(region, id, start, end, locale);
|
||||
const { filteredOrder } = await filterStats(statsInPeriod, filters);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanData = await getClanAccountInfo(region, id, ['clan']);
|
||||
const tankStats = await getTankStats(region, id);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const orderedCurrentStats: BlitzStats[] = [];
|
||||
const orderedCareerStats: BlitzStats[] = [];
|
||||
const orderedCurrentWN8: (number | undefined)[] = [];
|
||||
const orderedCareerWN8: (number | undefined)[] = [];
|
||||
const orderedCareerWN8Full: (number | undefined)[] = [];
|
||||
|
||||
if (tankStats === null) {
|
||||
throw new UserError(t`bot.common.errors.no_tank_stats`);
|
||||
}
|
||||
|
||||
filteredOrder.forEach((id) => {
|
||||
const tankAveragesEntry = awaitedTankAverages[id];
|
||||
const current = statsInPeriod.diff[id];
|
||||
const career = tankStats.find((tank) => tank.tank_id === id)!.all;
|
||||
const currentWN8 = tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.all, current)
|
||||
: undefined;
|
||||
const careerWN8 = tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.all, career)
|
||||
: undefined;
|
||||
|
||||
orderedCurrentStats.push(current);
|
||||
orderedCareerStats.push(career);
|
||||
orderedCurrentWN8.push(currentWN8);
|
||||
orderedCareerWN8.push(careerWN8);
|
||||
});
|
||||
tankStats.forEach((tank) => {
|
||||
const tankAveragesEntry = awaitedTankAverages[tank.tank_id];
|
||||
|
||||
orderedCareerWN8Full.push(
|
||||
tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.all, tank.all)
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const currentBattles = orderedCurrentStats.reduce(
|
||||
(accumulator, current) => accumulator + current.battles,
|
||||
0,
|
||||
);
|
||||
const careerBattles = tankStats.reduce(
|
||||
(accumulator, current) => accumulator + current.all.battles,
|
||||
0,
|
||||
);
|
||||
const currentWinrate =
|
||||
orderedCurrentStats.reduce(
|
||||
(accumulator, current) => accumulator + current.wins,
|
||||
0,
|
||||
) / currentBattles;
|
||||
const careerWinrate =
|
||||
tankStats.reduce(
|
||||
(accumulator, current) => accumulator + current.all.wins,
|
||||
0,
|
||||
) / careerBattles;
|
||||
const currentWN8 =
|
||||
orderedCurrentWN8.reduce<number>(
|
||||
(accumulator, current, index) =>
|
||||
accumulator + (current ?? 0) * orderedCurrentStats[index].battles,
|
||||
0,
|
||||
) /
|
||||
orderedCurrentStats.reduce(
|
||||
(accumulator, current, index) =>
|
||||
accumulator +
|
||||
(orderedCurrentWN8[index] === undefined ? 0 : current.battles),
|
||||
0,
|
||||
);
|
||||
const careerWN8 =
|
||||
orderedCareerWN8Full.reduce<number>(
|
||||
(accumulator, current) => accumulator + (current ?? 0),
|
||||
0,
|
||||
) /
|
||||
tankStats.reduce(
|
||||
(accumulator, current, index) =>
|
||||
accumulator +
|
||||
(orderedCareerWN8Full[index] === undefined ? 0 : current.all.battles),
|
||||
0,
|
||||
);
|
||||
const currentDamage =
|
||||
orderedCurrentStats.reduce(
|
||||
(accumulator, current) => accumulator + current.damage_dealt,
|
||||
0,
|
||||
) / currentBattles;
|
||||
const careerDamage =
|
||||
tankStats.reduce(
|
||||
(accumulator, current) => accumulator + current.all.damage_dealt,
|
||||
0,
|
||||
) / careerBattles;
|
||||
|
||||
const children: JSX.Element[] = [];
|
||||
|
||||
if (filteredOrder.length > 0) {
|
||||
children.push(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="summary"
|
||||
title={t`bot.commands.breakdown.body.total`}
|
||||
stats={[
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: currentBattles,
|
||||
career: careerBattles,
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * currentWinrate).toFixed(2)}%`,
|
||||
career: `${(100 * careerWinrate).toFixed(2)}%`,
|
||||
delta: currentWinrate - careerWinrate,
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(currentDamage).toLocaleString(),
|
||||
career: Math.round(careerDamage).toLocaleString(),
|
||||
delta: currentDamage - careerDamage,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
filteredOrder.forEach((id, index) => {
|
||||
const tankDefinition = awaitedTankDefinitions[id] as
|
||||
| TankDefinition
|
||||
| undefined;
|
||||
const current = orderedCurrentStats[index];
|
||||
const career = orderedCareerStats[index];
|
||||
const currentWN8 = orderedCurrentWN8[index];
|
||||
const careerWN8 = orderedCareerWN8[index];
|
||||
|
||||
children.push(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
type="tank"
|
||||
tankType={tankDefinition?.class}
|
||||
treeType={tankDefinition?.treeType}
|
||||
title={
|
||||
tankDefinition?.name ??
|
||||
translate('bot.commands.breakdown.body.unknown_tank', [`${id}`])
|
||||
}
|
||||
stats={[
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.battles`,
|
||||
current: current.battles.toLocaleString(),
|
||||
career: career.battles.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.winrate`,
|
||||
current: `${(100 * (current.wins / current.battles)).toFixed(
|
||||
2,
|
||||
)}%`,
|
||||
career: `${(100 * (career.wins / career.battles)).toFixed(2)}%`,
|
||||
delta:
|
||||
current.wins / current.battles - career.wins / career.battles,
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.wn8`,
|
||||
current:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(currentWN8).toLocaleString(),
|
||||
career:
|
||||
careerWN8 === undefined
|
||||
? undefined
|
||||
: Math.round(careerWN8).toLocaleString(),
|
||||
percentile:
|
||||
currentWN8 === undefined
|
||||
? undefined
|
||||
: getWN8Percentile(currentWN8),
|
||||
},
|
||||
{
|
||||
title: t`bot.commands.breakdown.body.damage`,
|
||||
current: Math.round(
|
||||
current.damage_dealt / current.battles,
|
||||
).toLocaleString(),
|
||||
career: Math.round(
|
||||
career.damage_dealt / career.battles,
|
||||
).toLocaleString(),
|
||||
delta:
|
||||
current.damage_dealt / current.battles -
|
||||
career.damage_dealt / career.battles,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const pages = chunk(
|
||||
children,
|
||||
Math.max(Math.ceil(children.length / MAX_PAGES), ROWS_PER_PAGE),
|
||||
);
|
||||
|
||||
if (filteredOrder.length > 0) {
|
||||
return pages
|
||||
.map((page, index) => (
|
||||
<CommandWrapper>
|
||||
{index === 0 && (
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
: undefined
|
||||
}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Breakdown.Root>{page}</Breakdown.Root>
|
||||
</CommandWrapper>
|
||||
))
|
||||
.reverse();
|
||||
} else {
|
||||
return [
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={
|
||||
clanData?.clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
|
||||
: undefined
|
||||
}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
</CommandWrapper>,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||
import { client } from '../core/discord/client';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const executionStart = new Date().getTime();
|
||||
|
||||
export const debugCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('debug'),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t } = translator(interaction.locale);
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
[t`bot.commands.debug.body.version`, packageJSON.version],
|
||||
[
|
||||
t`bot.commands.debug.body.shard`,
|
||||
client.shard?.ids[0] ?? t`bot.commands.debug.body.shard.default`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.debug.body.uptime`,
|
||||
`${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`,
|
||||
],
|
||||
];
|
||||
return t`bot.commands.debug.body.title${list
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join('\n')}`;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Locale, SlashCommandSubcommandGroupBuilder } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import * as Graph from '../components/Graph';
|
||||
import { LineColor } from '../components/Graph/components/Line/constants';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import resolveTankId from '../core/blitz/resolveTankId';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import getTankHistories from '../core/blitzkit/getTankHistories';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
|
||||
import { addPeriodSubCommands } from '../core/discord/addPeriodSubCommands';
|
||||
import addTankChoices from '../core/discord/addTankChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand, {
|
||||
ResolvedPeriod,
|
||||
} from '../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
type EvolutionStatType = 'player' | 'tank';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
type: 'player' | 'tank',
|
||||
tankId: number | null,
|
||||
locale: Locale,
|
||||
) {
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const logo = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
const histories = await (type === 'player'
|
||||
? getPlayerHistories(region, id, {
|
||||
start,
|
||||
end,
|
||||
includeLatestHistories: true,
|
||||
includePreviousHistories: true,
|
||||
})
|
||||
: getTankHistories(region, id, locale, {
|
||||
tankId: tankId!,
|
||||
start,
|
||||
end,
|
||||
includeLatestHistories: true,
|
||||
includePreviousHistories: true,
|
||||
}));
|
||||
const plot = histories.map(
|
||||
(history) =>
|
||||
[
|
||||
history.all.battles,
|
||||
(history.all.wins / history.all.battles) * 100,
|
||||
] as Graph.PlotItem,
|
||||
);
|
||||
const times = histories.map((history) => history.last_battle_time);
|
||||
const minTime = Math.min(...times);
|
||||
const maxTime = Math.max(...times);
|
||||
const ys = plot.map(([, y]) => y);
|
||||
const xs = plot.map(([x]) => x);
|
||||
const maxY = Math.max(...ys);
|
||||
const minY = Math.min(...ys);
|
||||
const maxX = Math.max(...xs);
|
||||
const minX = Math.min(...xs);
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar title={accountInfo.nickname} image={logo} description={name} />
|
||||
|
||||
{plot.length > 0 && (
|
||||
<Graph.Root
|
||||
verticalMargin={{
|
||||
min: minY,
|
||||
max: maxY,
|
||||
suffix: '%',
|
||||
precision: 2,
|
||||
}}
|
||||
horizontalMargin={{
|
||||
min: minX,
|
||||
max: maxX,
|
||||
precision: 0,
|
||||
}}
|
||||
xMinLabel={new Date(minTime * 1000).toLocaleDateString(locale)}
|
||||
xMaxLabel={new Date(maxTime * 1000).toLocaleDateString(locale)}
|
||||
>
|
||||
{Graph.Line({
|
||||
color: LineColor.Red,
|
||||
plot: plot,
|
||||
minY,
|
||||
maxY,
|
||||
})}
|
||||
</Graph.Root>
|
||||
)}
|
||||
|
||||
{plot.length === 0 && <NoData type="battles_in_period" locale={locale} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const evolutionCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('evolution', [
|
||||
{
|
||||
group: 'player',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'tank',
|
||||
modify(option: SlashCommandSubcommandGroupBuilder) {
|
||||
addPeriodSubCommands(option, (option) =>
|
||||
option
|
||||
.addStringOption(addTankChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const commandGroup =
|
||||
interaction.options.getSubcommandGroup() as EvolutionStatType;
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const tankIdRaw = interaction.options.getString('tank')!;
|
||||
const tankId =
|
||||
commandGroup === 'tank'
|
||||
? await resolveTankId(tankIdRaw, interaction.locale)
|
||||
: null;
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
tankId,
|
||||
});
|
||||
|
||||
return [
|
||||
await render(
|
||||
player,
|
||||
period,
|
||||
commandGroup,
|
||||
tankId,
|
||||
interaction.locale,
|
||||
),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(
|
||||
player.region,
|
||||
player.id,
|
||||
tankId ?? undefined,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const url = new URL(`https://example.com/${interaction.customId}`);
|
||||
const path = url.pathname.split('/');
|
||||
const commandGroup = path[2] as EvolutionStatType;
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
|
||||
return await render(
|
||||
player,
|
||||
period,
|
||||
commandGroup,
|
||||
parseInt(url.searchParams.get('tankId') ?? '0') || null,
|
||||
interaction.locale,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,120 @@
|
||||
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericAllStats from '../components/GenericAllStats';
|
||||
import NoData from '../components/NoData';
|
||||
import TierWeights from '../components/TierWeights';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import { filtersToDescription } from '../core/blitzkit/filtersToDescription';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand, {
|
||||
ResolvedPeriod,
|
||||
} from '../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import { getTierWeights } from '../core/statistics/getTierWeights';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
const diffedTankStats = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
);
|
||||
const { stats, supplementary, filteredOrder } = await filterStats(
|
||||
diffedTankStats,
|
||||
filters,
|
||||
);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder);
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{stats.battles === 0 && (
|
||||
<NoData type="battles_in_period" locale={locale} />
|
||||
)}
|
||||
{stats.battles > 0 && <TierWeights weights={tierWeights!} />}
|
||||
{stats.battles > 0 && (
|
||||
<GenericAllStats
|
||||
stats={stats}
|
||||
supplementaryStats={supplementary}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const fullStatsCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
resolve({
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('full-stats'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
...filters,
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
render(player, period, filters, interaction.locale),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import GenericStats from '../components/GenericStats';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanInfo } from '../core/blitz/getClanInfo';
|
||||
import addClanChoices from '../core/discord/addClanChoices';
|
||||
import autocompleteClan from '../core/discord/autocompleteClan';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const DEFAULT_THRESHOLD = 7;
|
||||
|
||||
export const inactiveCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('inactive')
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) => {
|
||||
const { translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName('threshold')
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.inactive.options.threshold'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.inactive.options.threshold.description'),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.inactive.options.threshold.description',
|
||||
),
|
||||
)
|
||||
.setMinValue(0);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const { region, id } = await resolveClanFromCommand(interaction);
|
||||
const threshold =
|
||||
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
|
||||
const time = new Date().getTime() / 1000;
|
||||
const clanInfo = await getClanInfo(region, id);
|
||||
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
|
||||
const inactive = accountInfo
|
||||
.map(
|
||||
(account) =>
|
||||
[
|
||||
account.nickname,
|
||||
(time - account.last_battle_time) / 60 / 60 / 24,
|
||||
] as [string, number],
|
||||
)
|
||||
.filter(([, inactiveDays]) => inactiveDays >= threshold)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([name, days]) =>
|
||||
[
|
||||
name,
|
||||
translate('bot.commands.inactive.body.listing', [
|
||||
days.toFixed(0),
|
||||
]),
|
||||
] as [string, string],
|
||||
);
|
||||
const hasInactiveMembers = inactive.length > 0;
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={clanInfo.name}
|
||||
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
|
||||
description={`${translate('bot.commands.inactive.body.subtitle', [`${threshold}`])} • ${new Date().toLocaleDateString(interaction.locale)}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && (
|
||||
<NoData type="players_in_period" locale={interaction.locale} />
|
||||
)}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteClan,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { teal } from '@radix-ui/colors';
|
||||
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { linkBlitzAndDiscord } from '../core/blitzkit/linkBlitzAndDiscord';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('link').addStringOption((option) =>
|
||||
addUsernameChoices(option).setRequired(true),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const discordId = BigInt(interaction.user.id);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
||||
|
||||
await linkBlitzAndDiscord(discordId, id);
|
||||
|
||||
return [
|
||||
<CommandWrapper fat>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Glow color={teal.teal9} rotation={90} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="avatar"
|
||||
src={
|
||||
interaction.user.avatarURL({ extension: 'png' }) ??
|
||||
'https://i.stack.imgur.com/l60Hf.png'
|
||||
}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{interaction.user.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
@{interaction.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
color: theme.colors.textLowContrast_teal,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="accounts linked"
|
||||
src="https://i.imgur.com/jIcRgog.png"
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: 16 }}
|
||||
>{t`bot.commands.link.body.accounts_linked`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan ? (
|
||||
<img
|
||||
alt="clan emblem"
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 900,
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{accountInfo.nickname}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
{clanAccountInfo?.clan
|
||||
? `[${clanAccountInfo?.clan?.tag}] • `
|
||||
: ''}
|
||||
{translate(`common.regions.normal.${region}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Glow color={teal.teal9} rotation={-90} />
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
|
||||
embedInfo(
|
||||
t`bot.commands.link.embed.title`,
|
||||
t`bot.commands.link.embed.description`,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { gameDefinitions } from '@blitzkit/core/src/blitzkit/gameDefinitions';
|
||||
import { chunk } from 'lodash';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { TANK_CLASSES } from '../components/Tanks/components/Item/constants';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
import {
|
||||
TankDefinition,
|
||||
Tier,
|
||||
flags,
|
||||
tankDefinitions,
|
||||
} from '../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../core/blitzkit/tankDefinitions/constants';
|
||||
import addTierChoices from '../core/discord/addTierChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const TANKS_PER_MESSAGE = 64;
|
||||
|
||||
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('owned-tanks')
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const tier = Number(interaction.options.getString('tier'));
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const tankStats = await getTankStats(region, id);
|
||||
|
||||
if (tankStats === null) {
|
||||
return t`bot.common.errors.no_tank_stats`;
|
||||
}
|
||||
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankDefinitions: (await tankDefinitions)[tankData.tank_id]!,
|
||||
id: tankData.tank_id,
|
||||
})),
|
||||
)
|
||||
).filter((tank) => tank.tankDefinitions?.tier === tier);
|
||||
const groupedTanks: Record<string, TankDefinition[]> = {};
|
||||
const nations: string[] = [];
|
||||
|
||||
filteredTanks.forEach((tank) => {
|
||||
if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
|
||||
groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
|
||||
nations.push(tank.tankDefinitions.nation);
|
||||
} else {
|
||||
groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
|
||||
}
|
||||
});
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedGameDefinitions = await gameDefinitions;
|
||||
const lines = tankStats
|
||||
.map(({ tank_id }) => awaitedTankDefinitions[tank_id])
|
||||
.filter((tank) => tank.tier === tier)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
awaitedGameDefinitions.nations.indexOf(a.nation) -
|
||||
awaitedGameDefinitions.nations.indexOf(b.nation),
|
||||
)
|
||||
.map((tank) => `${flags[tank.nation]} ${markdownEscape(tank.name)}`);
|
||||
|
||||
return chunk(lines, TANKS_PER_MESSAGE).map((lines, index) =>
|
||||
index === 0
|
||||
? `${translate('bot.commands.owned_tanks.body.title', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
TIER_ROMAN_NUMERALS[tier as Tier],
|
||||
])}${lines.join('\n')}`
|
||||
: lines.join('\n'),
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const permissionsCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('permissions'),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
// friday the 13th permissions easter egg
|
||||
const isFridayThe13th =
|
||||
new Date().getDay() === 5 && new Date().getDate() === 13;
|
||||
const permissions = [
|
||||
[
|
||||
interaction.appPermissions?.has('ViewChannel'),
|
||||
t`bot.commands.permissions.body.view_channels`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('ReadMessageHistory'),
|
||||
t`bot.commands.permissions.body.read_message_history`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('AttachFiles'),
|
||||
t`bot.commands.permissions.body.attach_files`,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has('UseExternalEmojis'),
|
||||
t`bot.commands.permissions.body.external_emojis`,
|
||||
],
|
||||
];
|
||||
const body = permissions
|
||||
.map(([hasPermission, description]) => {
|
||||
if (hasPermission) {
|
||||
return `${isFridayThe13th ? '😇' : '✅'} ${description}`;
|
||||
} else {
|
||||
return `${isFridayThe13th ? '💀' : '❌'} ${description}`;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return translate('bot.commands.permissions.body.title', [body]);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const pingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
handlesInteraction: true,
|
||||
|
||||
command: createLocalizedCommand('ping', [
|
||||
{ subcommand: 'blitzkit' },
|
||||
{ subcommand: 'wotb' },
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
|
||||
if (subcommand === 'blitzkit') {
|
||||
await interaction.editReply(t`bot.commands.ping.body.pong`);
|
||||
} else {
|
||||
await fetch('https://api.wotblitz.com/');
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(
|
||||
translate('bot.commands.ping.body.pong_with_time', [
|
||||
`${Math.round(executionTime)}`,
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import markdownTable from '../core/discord/markdownTable';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
export const playerInfoCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command:
|
||||
createLocalizedCommand('player-info').addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: region } = account;
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
|
||||
return embedInfo(
|
||||
translate('bot.commands.player_info.body.title', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
]),
|
||||
|
||||
markdownTable([
|
||||
[
|
||||
t`bot.commands.player_info.body.nickname`,
|
||||
`${accountInfo.nickname}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.battles`,
|
||||
`${accountInfo.statistics.all.battles}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.winrate`,
|
||||
`${(
|
||||
100 *
|
||||
(accountInfo.statistics.all.wins /
|
||||
accountInfo.statistics.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
],
|
||||
[],
|
||||
[
|
||||
t`bot.commands.player_info.body.account_id`,
|
||||
`${accountInfo.account_id}`,
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.created`,
|
||||
new Date(accountInfo.created_at * 1000).toLocaleDateString(
|
||||
interaction.locale,
|
||||
),
|
||||
],
|
||||
[
|
||||
t`bot.commands.player_info.body.last_battle`,
|
||||
new Date(accountInfo.last_battle_time * 1000).toLocaleDateString(
|
||||
interaction.locale,
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
import { SlashCommandSubcommandBuilder } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import { DeltaCaret } from '../components/DeltaCaret';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { getLeagueFromScore } from '../core/blitz/getLeagueFromScore';
|
||||
import getRatingInfo from '../core/blitz/getRatingInfo';
|
||||
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
|
||||
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import getArchivedRatingMidnightLeaderboard from '../core/blitzkit/getArchivedRatingMidnightLeaderboard';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { deltaBkrlBlitzStats } from '../core/statistics/deltaBkrlBlitzStats';
|
||||
import { BkrlFormat } from '../core/streams/bkrl';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
const LEAGUE_ACCENT = [
|
||||
'_purple',
|
||||
'_sand',
|
||||
'_amber',
|
||||
'_mauve',
|
||||
'_brown',
|
||||
] as const;
|
||||
|
||||
export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('rating', [
|
||||
{
|
||||
subcommand: 'today',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option.addStringOption(addUsernameChoices);
|
||||
},
|
||||
},
|
||||
{
|
||||
subcommand: 'season',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option.addStringOption(addUsernameChoices);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
async handler(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand(true) as
|
||||
| 'season'
|
||||
| 'today';
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
|
||||
const ratingInfo = await getRatingInfo(region);
|
||||
|
||||
if (ratingInfo.detail) {
|
||||
throw new Error(`Rating info not available for ${region}`);
|
||||
}
|
||||
|
||||
const leaderboard = await getArchivedRatingMidnightLeaderboard(
|
||||
region,
|
||||
ratingInfo.current_season - (subcommand === 'today' ? 0 : 1),
|
||||
);
|
||||
|
||||
if (leaderboard?.format === BkrlFormat.Base) {
|
||||
throw new Error(
|
||||
'Encountered bkrl minimal format in latest season. Wait till next cron?',
|
||||
);
|
||||
}
|
||||
|
||||
const accountInfo = await getAccountInfo(region, id, [
|
||||
'statistics.rating',
|
||||
]);
|
||||
const statsIndex = leaderboard?.entries.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
const statsA = leaderboard ? leaderboard.entries[statsIndex!] : null;
|
||||
const positionA = leaderboard ? statsIndex! + 1 : undefined;
|
||||
const statsB1 = accountInfo.statistics.rating!;
|
||||
const statsB2Array = (await getRatingNeighbors(region, id, 0)).neighbors;
|
||||
|
||||
if (statsB2Array === undefined) {
|
||||
return translate('bot.commands.rating.errors.no_participation', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
]);
|
||||
}
|
||||
|
||||
const [statsB2] = statsB2Array;
|
||||
const league = getLeagueFromScore(statsB2.score);
|
||||
|
||||
if (!statsA) {
|
||||
return t`bot.commands.rating.errors.unrecorded_stats`;
|
||||
}
|
||||
if (!statsB1) {
|
||||
return translate('bot.commands.rating.errors.no_participation', [
|
||||
markdownEscape(accountInfo.nickname),
|
||||
]);
|
||||
}
|
||||
|
||||
const reward = ratingInfo.rewards.find(
|
||||
(reward) =>
|
||||
statsB2.number >= reward.from_position &&
|
||||
statsB2.number <= reward.to_position,
|
||||
);
|
||||
const positionDelta = leaderboard
|
||||
? statsB2.number - positionA!
|
||||
: undefined;
|
||||
const delta = deltaBkrlBlitzStats(
|
||||
statsA,
|
||||
accountInfo.statistics.rating!,
|
||||
statsB2,
|
||||
);
|
||||
const leagueTheme = LEAGUE_ACCENT[league.index];
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={clanImage}
|
||||
description={`${translate(`bot.commands.rating.body.subtitle.${subcommand}`)} • ${new Date().toLocaleDateString(
|
||||
interaction.locale,
|
||||
)}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 16,
|
||||
backgroundColor: theme.colors[`appBackground2${leagueTheme}`],
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${theme.colors[`componentInteractive${leagueTheme}`]}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{reward === undefined && (
|
||||
<Glow
|
||||
endOpacity={3 / 4}
|
||||
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||
style={{
|
||||
transform: 'translateX(-16px) rotate(90deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={`League ${league.name}`}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={normalizeLeagueIcon(
|
||||
ratingInfo.leagues[league.index].big_icon,
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textHighContrast${leagueTheme}`],
|
||||
fontSize: 24,
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
{statsB2.score.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
{delta.score !== 0 && (
|
||||
<>
|
||||
<DeltaCaret delta={delta.score} />
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
delta.score > 0
|
||||
? theme.colors.textLowContrast_green
|
||||
: theme.colors.textLowContrast_red,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{Math.abs(delta.score).toLocaleString(
|
||||
interaction.locale,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textLowContrast${leagueTheme}`],
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
#{statsB2.number.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
{positionDelta !== undefined && positionDelta !== 0 && (
|
||||
<>
|
||||
<DeltaCaret delta={-positionDelta} />
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
positionDelta > 0
|
||||
? theme.colors.textLowContrast_red
|
||||
: theme.colors.textLowContrast_green,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{Math.abs(positionDelta).toLocaleString(
|
||||
interaction.locale,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reward && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textHighContrast${leagueTheme}`],
|
||||
fontSize: 16,
|
||||
maxWidth: 136,
|
||||
textAlign: 'right',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{reward.type === 'vehicle'
|
||||
? reward.vehicle.user_string
|
||||
: reward.stuff.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textLowContrast${leagueTheme}`],
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Reward
|
||||
{reward.type === 'stuff' ? ` x${reward.count}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={await iconPng(
|
||||
reward.type === 'vehicle'
|
||||
? reward.vehicle.preview_image_url
|
||||
: reward.stuff.image_url,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reward === undefined && (
|
||||
<Glow
|
||||
endOpacity={3 / 4}
|
||||
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||
style={{
|
||||
transform: 'translateX(16px) rotate(-90deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* separated because fragment middle man invalidates relative parent: https://github.com/vercel/satori/issues/605 */}
|
||||
{reward && (
|
||||
<Glow
|
||||
direction="reverse"
|
||||
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -40,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{reward && (
|
||||
<Glow
|
||||
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -40,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{delta.battles > 0 && (
|
||||
<div style={{ padding: '8px 0', display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{delta.battles.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Battles
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Winrate
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{Math.round(delta.damage / delta.battles).toLocaleString(
|
||||
interaction.locale,
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Survival
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{(delta.kills / delta.battles).toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Kills
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
APIApplicationCommandOptionChoice,
|
||||
Locale,
|
||||
SlashCommandSubcommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { range } from 'lodash';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import * as Leaderboard from '../components/Leaderboard';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { LEAGUES } from '../constants/leagues';
|
||||
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../constants/rating';
|
||||
import { Region } from '../constants/regions';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getRatingInfo from '../core/blitz/getRatingInfo';
|
||||
import { getRatingLeague } from '../core/blitz/getRatingLeague';
|
||||
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
|
||||
import { isOnGoingRatingSeason } from '../core/blitz/isOnGoingRatingSeason';
|
||||
import { getArchivedLatestSeasonNumber } from '../core/blitzkit/getArchivedLatestSeasonNumber';
|
||||
import getArchivedRatingInfo from '../core/blitzkit/getArchivedRatingInfo';
|
||||
import { getArchivedRatingLeaderboard } from '../core/blitzkit/getArchivedRatingLeaderboard';
|
||||
import getArchivedRatingMidnightLeaderboard from '../core/blitzkit/getArchivedRatingMidnightLeaderboard';
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { BkrlDiscriminatedEntries } from '../core/streams/bkrl';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { UserError } from '../hooks/userError';
|
||||
|
||||
export interface RatingPlayer {
|
||||
spa_id: number;
|
||||
mmr: number;
|
||||
season_number: number;
|
||||
calibrationBattlesLeft: number;
|
||||
number: number;
|
||||
percentile: number;
|
||||
skip: boolean;
|
||||
updated_at: string;
|
||||
score: number;
|
||||
nickname: string;
|
||||
clan_tag: string;
|
||||
}
|
||||
|
||||
export interface LeagueTop {
|
||||
result: RatingPlayer[];
|
||||
}
|
||||
|
||||
export type RatingReward = {
|
||||
from_position: number;
|
||||
to_position: number;
|
||||
count: number;
|
||||
} & (
|
||||
| {
|
||||
type: 'vehicle';
|
||||
|
||||
vehicle: {
|
||||
id: number;
|
||||
name: string;
|
||||
nation: string;
|
||||
subnation: string;
|
||||
use_subnation_flag: boolean;
|
||||
type_slug: string;
|
||||
level: number;
|
||||
roman_level: string;
|
||||
user_string: string;
|
||||
image_url: string;
|
||||
preview_image_url: string;
|
||||
is_premium: boolean;
|
||||
is_collectible: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'stuff';
|
||||
|
||||
stuff: {
|
||||
name: string;
|
||||
count: number;
|
||||
title: string;
|
||||
image_url: string;
|
||||
type: string;
|
||||
|
||||
sizes: {};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export interface RatingLeague {
|
||||
title: string;
|
||||
small_icon: string;
|
||||
big_icon: string;
|
||||
background: string;
|
||||
index: number;
|
||||
percentile: number;
|
||||
}
|
||||
|
||||
export type RatingInfo =
|
||||
| {
|
||||
detail: undefined;
|
||||
|
||||
title: string;
|
||||
icon: string | null;
|
||||
start_at: string;
|
||||
finish_at: string;
|
||||
current_season: number;
|
||||
updated_at: string;
|
||||
count: number;
|
||||
|
||||
rewards: RatingReward[];
|
||||
leagues: RatingLeague[];
|
||||
}
|
||||
| { detail: { error: string } };
|
||||
|
||||
export interface RatingNeighbors {
|
||||
neighbors: RatingPlayer[];
|
||||
}
|
||||
|
||||
interface SimplifiedPlayer {
|
||||
id: number;
|
||||
score: number;
|
||||
position: number;
|
||||
clan: string | undefined;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export const ratingLeaderboardCommand = new Promise<CommandRegistry>(
|
||||
async (resolve) => {
|
||||
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
|
||||
const onGoingSeason = await isOnGoingRatingSeason();
|
||||
const seasonNumbers = range(
|
||||
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
|
||||
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
|
||||
);
|
||||
|
||||
function addOptions(option: SlashCommandSubcommandBuilder) {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.rating_leaderboard.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.limit',
|
||||
),
|
||||
)
|
||||
.setDescription(
|
||||
translate(
|
||||
'bot.commands.rating_leaderboard.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setRequired(false)
|
||||
.setMinValue(5)
|
||||
.setMaxValue(30),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.rating_leaderboard.options.season`)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.season',
|
||||
),
|
||||
)
|
||||
.setDescription(
|
||||
translate(
|
||||
'bot.commands.rating_leaderboard.options.season.description',
|
||||
),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.season.description',
|
||||
),
|
||||
)
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
...seasonNumbers
|
||||
.map((number, index) => {
|
||||
const current =
|
||||
index === seasonNumbers.length - 1 && onGoingSeason;
|
||||
|
||||
return {
|
||||
name: current
|
||||
? translate(
|
||||
'bot.commands.rating_leaderboard.options.season.choices.current',
|
||||
[`${number}`],
|
||||
)
|
||||
: `${number}`,
|
||||
value: `${number}`,
|
||||
name_localizations: current
|
||||
? localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.season.choices.current',
|
||||
[`${number}`],
|
||||
)
|
||||
: undefined,
|
||||
} satisfies APIApplicationCommandOptionChoice<string>;
|
||||
})
|
||||
.reverse(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand('rating-leaderboard', [
|
||||
{
|
||||
subcommand: 'neighbors',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option.addStringOption(addUsernameChoices);
|
||||
addOptions(option);
|
||||
},
|
||||
},
|
||||
{
|
||||
subcommand: 'league',
|
||||
modify(option: SlashCommandSubcommandBuilder) {
|
||||
option
|
||||
.addStringOption((option) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName(t`bot.commands.rating_leaderboard.options.league`)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.league',
|
||||
),
|
||||
)
|
||||
.setDescription(
|
||||
translate(
|
||||
'bot.commands.rating_leaderboard.options.league.description',
|
||||
),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.rating_leaderboard.options.league.description',
|
||||
),
|
||||
)
|
||||
.addChoices(
|
||||
...LEAGUES.map(
|
||||
({ name }, value) =>
|
||||
({
|
||||
name: translate(`common.leagues.${name}`),
|
||||
value: `${value}`,
|
||||
name_localizations: localizationObject(
|
||||
`common.leagues.${name}`,
|
||||
),
|
||||
}) satisfies APIApplicationCommandOptionChoice<string>,
|
||||
),
|
||||
)
|
||||
.setRequired(true);
|
||||
})
|
||||
.addStringOption(addRegionChoices);
|
||||
addOptions(option);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
// BIG TODO: title seems to be in english
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand(true) as
|
||||
| 'league'
|
||||
| 'neighbors';
|
||||
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
||||
const seasonOption = interaction.options.getString('season');
|
||||
const season = seasonOption
|
||||
? parseInt(seasonOption)
|
||||
: onGoingSeason
|
||||
? latestArchivedSeasonNumber + 1
|
||||
: latestArchivedSeasonNumber;
|
||||
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
|
||||
let titleName: string;
|
||||
let titleImage: string | undefined;
|
||||
let titleDescription: string;
|
||||
let playersAfter: number;
|
||||
let players: SimplifiedPlayer[] | undefined;
|
||||
let midnightLeaderboard: BkrlDiscriminatedEntries | null;
|
||||
let playerId: number | undefined;
|
||||
let regionRatingInfo: RatingInfo;
|
||||
|
||||
if (subcommand === 'league') {
|
||||
const leagueIndex = parseInt(
|
||||
interaction.options.getString('league')!,
|
||||
);
|
||||
const region = interaction.options.getString('region') as Region;
|
||||
regionRatingInfo = await (isCurrentSeason
|
||||
? getRatingInfo(region)
|
||||
: getArchivedRatingInfo(region, season!));
|
||||
|
||||
if (regionRatingInfo.detail !== undefined)
|
||||
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
|
||||
|
||||
const result = isCurrentSeason
|
||||
? await getRatingLeague(region, leagueIndex).then(async (result) =>
|
||||
result.slice(0, limit).map(
|
||||
(player) =>
|
||||
({
|
||||
id: player.spa_id,
|
||||
score: player.score,
|
||||
position: player.number,
|
||||
clan: player.clan_tag,
|
||||
nickname: player.nickname,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
),
|
||||
)
|
||||
: await getArchivedRatingLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const firstPlayerIndex =
|
||||
leagueIndex === 0
|
||||
? 0
|
||||
: players.entries.findIndex(
|
||||
(player) =>
|
||||
player.score < LEAGUES[leagueIndex - 1].minScore,
|
||||
);
|
||||
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
||||
const trimmed = players.entries.slice(
|
||||
firstPlayerIndex,
|
||||
lastPlayerIndex + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map(({ id }) => id),
|
||||
['clan'],
|
||||
);
|
||||
|
||||
return trimmed.map(
|
||||
(player, index) =>
|
||||
({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: firstPlayerIndex + index + 1,
|
||||
clan: clanData[player.id]?.clan?.tag,
|
||||
nickname:
|
||||
clanData[index]?.account_name ??
|
||||
translate(
|
||||
'bot.commands.rating_leaderboard.body.deleted_player',
|
||||
[`${player.id}`],
|
||||
),
|
||||
}) satisfies SimplifiedPlayer,
|
||||
);
|
||||
},
|
||||
);
|
||||
const leagueInfo = regionRatingInfo.leagues[leagueIndex];
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingMidnightLeaderboard(region, season!);
|
||||
|
||||
players = result;
|
||||
playersAfter =
|
||||
regionRatingInfo.count - result[result.length - 1].position;
|
||||
titleName = `${
|
||||
leagueInfo
|
||||
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
|
||||
: ''
|
||||
} - ${translate(`common.regions.short.${region}`)}`;
|
||||
titleImage = leagueInfo.big_icon.startsWith('http')
|
||||
? leagueInfo.big_icon
|
||||
: `https:${leagueInfo.big_icon}`;
|
||||
titleDescription = translate(
|
||||
'bot.commands.rating_leaderboard.body.top_players',
|
||||
[`${limit}`],
|
||||
);
|
||||
} else {
|
||||
const { region, id } = await resolvePlayerFromCommand(interaction);
|
||||
regionRatingInfo = isCurrentSeason
|
||||
? await getRatingInfo(region)
|
||||
: await getArchivedRatingInfo(region, season!);
|
||||
|
||||
if (regionRatingInfo.detail !== undefined)
|
||||
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
|
||||
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const neighbors = isCurrentSeason
|
||||
? await getRatingNeighbors(region, id, Math.round(limit / 2)).then(
|
||||
(players) =>
|
||||
players.neighbors.map(
|
||||
(player) =>
|
||||
({
|
||||
id: player.spa_id,
|
||||
position: player.number,
|
||||
score: player.score,
|
||||
clan: player.clan_tag,
|
||||
nickname: player.nickname,
|
||||
}) satisfies SimplifiedPlayer,
|
||||
),
|
||||
)
|
||||
: await getArchivedRatingLeaderboard(region, season!).then(
|
||||
async (players) => {
|
||||
const playerIndex = players.entries.findIndex(
|
||||
(player) => player.id === id,
|
||||
);
|
||||
|
||||
if (playerIndex === -1) {
|
||||
throw new UserError(
|
||||
translate(
|
||||
'bot.commands.rating_leaderboard.body.no_participation',
|
||||
[markdownEscape(accountInfo.nickname), `${season}`],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const halfRange = Math.round(limit / 2);
|
||||
const trimmed = players.entries.slice(
|
||||
playerIndex - halfRange,
|
||||
playerIndex + halfRange + 1,
|
||||
);
|
||||
const clanData = await getClanAccountInfo(
|
||||
region,
|
||||
trimmed.map((player) => player.id),
|
||||
['clan'],
|
||||
);
|
||||
|
||||
return trimmed.map((player, index) => ({
|
||||
id: player.id,
|
||||
score: player.score,
|
||||
position: playerIndex + index + 1,
|
||||
clan: clanData[index]?.clan?.tag,
|
||||
nickname: clanData[index]
|
||||
? clanData[index]!.account_name
|
||||
: `Deleted ${player.id}`,
|
||||
}));
|
||||
},
|
||||
);
|
||||
midnightLeaderboard = isCurrentSeason
|
||||
? await getArchivedRatingMidnightLeaderboard(
|
||||
region,
|
||||
regionRatingInfo.current_season,
|
||||
)
|
||||
: await getArchivedRatingMidnightLeaderboard(region, season!);
|
||||
|
||||
players = neighbors;
|
||||
titleImage = clan
|
||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
|
||||
: undefined;
|
||||
titleName = neighbors ? accountInfo.nickname : '';
|
||||
titleDescription = t`bot.commands.rating_leaderboard.body.neighbors`;
|
||||
playersAfter =
|
||||
regionRatingInfo.count - neighbors[neighbors.length - 1].position;
|
||||
playerId = id;
|
||||
}
|
||||
|
||||
const items = players.map((player) => {
|
||||
const midnightIndex = midnightLeaderboard?.entries.findIndex(
|
||||
(item) => item.id === player.id,
|
||||
);
|
||||
const midnightScore =
|
||||
midnightIndex === undefined
|
||||
? player.score
|
||||
: midnightLeaderboard!.entries[midnightIndex].score;
|
||||
const midnightPosition =
|
||||
midnightIndex === undefined ? player.position : midnightIndex + 1;
|
||||
|
||||
return (
|
||||
<Leaderboard.Item
|
||||
nickname={player.nickname}
|
||||
score={player.score}
|
||||
position={player.position}
|
||||
clan={player.clan}
|
||||
deltaScore={player.score - midnightScore}
|
||||
deltaPosition={midnightPosition - player.position}
|
||||
key={player.id}
|
||||
highlight={player.id === playerId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={titleName}
|
||||
image={titleImage}
|
||||
description={titleDescription}
|
||||
/>
|
||||
|
||||
{items && (
|
||||
<Leaderboard.Root>
|
||||
{items}
|
||||
{playersAfter > 0 && (
|
||||
<Leaderboard.Gap
|
||||
message={translate(
|
||||
'bot.commands.rating_leaderboard.body.more',
|
||||
[`${playersAfter.toLocaleString(interaction.locale)}`],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Leaderboard.Root>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,563 @@
|
||||
import { tankIcon } from '@blitzkit/core/src/blitzkit/tankIcon';
|
||||
import { clamp } from 'lodash';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { idToRegion } from '../core/blitz/idToRegion';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import { mapDefinitions } from '../core/blitzkit/mapDefinitions';
|
||||
import { RenderConfiguration } from '../core/blitzkit/renderConfiguration';
|
||||
import { tankDefinitions } from '../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../core/blitzkit/tankDefinitions/constants';
|
||||
import { tankAverages } from '../core/blitzstars/tankAverages';
|
||||
import buttonLink from '../core/discord/buttonLink';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import calculateWN8 from '../core/statistics/calculateWN8';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
type WotInspectorReplaySubmission =
|
||||
| { status: 'error'; data: unknown; error: { message: string } }
|
||||
| {
|
||||
status: 'ok';
|
||||
data: {
|
||||
download_url: string;
|
||||
view_url: string;
|
||||
summary: {
|
||||
battle_start_time: string;
|
||||
map_name: string;
|
||||
battle_start_timestamp: number;
|
||||
battle_duration: number;
|
||||
title: string;
|
||||
arena_unique_id: string;
|
||||
allies: number[];
|
||||
enemies: number[];
|
||||
protagonist: number;
|
||||
player_name: string;
|
||||
vehicle: string;
|
||||
exp_base: number;
|
||||
exp_total: number;
|
||||
credits_base: number;
|
||||
credits_total: number;
|
||||
room_type: number;
|
||||
battle_type: number;
|
||||
mastery_badge: number;
|
||||
battle_result: number;
|
||||
vehicle_tier: number;
|
||||
vehicle_type: number;
|
||||
protagonist_team: number;
|
||||
winner_team: number;
|
||||
details: {
|
||||
achievements: unknown[];
|
||||
base_capture_points: number;
|
||||
base_defend_points: number;
|
||||
chassis_id: unknown | null;
|
||||
clan_tag: string;
|
||||
clanid: number;
|
||||
credits: number;
|
||||
damage_assisted: number;
|
||||
damage_assisted_track: number;
|
||||
damage_blocked: unknown | null;
|
||||
damage_made: number;
|
||||
damage_received: number;
|
||||
dbid: number;
|
||||
death_reason: number;
|
||||
distance_travelled: number;
|
||||
enemies_damaged: number;
|
||||
enemies_destroyed: number;
|
||||
enemies_spotted: number;
|
||||
entity_id: number;
|
||||
exp: number;
|
||||
exp_for_assist: number;
|
||||
exp_for_damage: number;
|
||||
exp_team_bonus: number;
|
||||
gun_id: unknown | null;
|
||||
hero_bonus_credits: number;
|
||||
hero_bonus_exp: number;
|
||||
hitpoints_left: number;
|
||||
hits_bounced: number;
|
||||
hits_pen: number;
|
||||
hits_received: number;
|
||||
hits_splash: number;
|
||||
killed_by: number;
|
||||
name: string;
|
||||
shots_hit: number;
|
||||
shots_made: number;
|
||||
shots_pen: number;
|
||||
shots_splash: number;
|
||||
squad_index: number;
|
||||
team: number;
|
||||
time_alive: number;
|
||||
turret_id: unknown | null;
|
||||
vehicle_descr: number;
|
||||
wp_points_earned: number;
|
||||
wp_points_stolen: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
error: {};
|
||||
};
|
||||
|
||||
interface WotInspectorReplay {
|
||||
id: string;
|
||||
map_id: number;
|
||||
battle_duration: number;
|
||||
title: unknown | null;
|
||||
player_name: string;
|
||||
protagonist: number;
|
||||
vehicle_descr: number;
|
||||
mastery_badge: number;
|
||||
exp_base: number;
|
||||
enemies_spotted: number;
|
||||
enemies_destroyed: number;
|
||||
damage_assisted: number;
|
||||
damage_made: number;
|
||||
details_url: string;
|
||||
download_url: string;
|
||||
game_version: {
|
||||
name: string;
|
||||
package: string;
|
||||
};
|
||||
arena_unique_id: string;
|
||||
download_count: number;
|
||||
data_version: number;
|
||||
private: boolean;
|
||||
private_clan: boolean;
|
||||
battle_start_time: Date;
|
||||
upload_time: Date;
|
||||
allies: number[];
|
||||
enemies: number[];
|
||||
protagonist_clan: number;
|
||||
protagonist_team: number;
|
||||
battle_result: number;
|
||||
credits_base: number;
|
||||
tags: number[];
|
||||
battle_type: number;
|
||||
room_type: number;
|
||||
last_accessed_time: Date;
|
||||
winner_team: number;
|
||||
finish_reason: number;
|
||||
players_data: WotInspectorReplayPlayerData[];
|
||||
exp_total: number;
|
||||
credits_total: number;
|
||||
repair_cost: number;
|
||||
exp_free: number;
|
||||
exp_free_base: number;
|
||||
exp_penalty: number;
|
||||
credits_penalty: number;
|
||||
credits_contribution_in: number;
|
||||
credits_contribution_out: number;
|
||||
camouflage_id: number;
|
||||
}
|
||||
|
||||
export interface WotInspectorReplayPlayerData {
|
||||
team: number;
|
||||
name: string;
|
||||
entity_id: number;
|
||||
dbid: number;
|
||||
clanid: number | null;
|
||||
clan_tag: null | string;
|
||||
hitpoints_left: number;
|
||||
credits: number;
|
||||
exp: number;
|
||||
shots_made: number;
|
||||
shots_hit: number;
|
||||
shots_splash: number;
|
||||
shots_pen: number;
|
||||
damage_made: number;
|
||||
damage_received: number;
|
||||
damage_assisted: number;
|
||||
damage_assisted_track: number;
|
||||
hits_received: number;
|
||||
hits_bounced: number;
|
||||
hits_splash: number;
|
||||
hits_pen: number;
|
||||
enemies_spotted: number;
|
||||
enemies_damaged: number;
|
||||
enemies_destroyed: number;
|
||||
time_alive: number;
|
||||
distance_travelled: number;
|
||||
killed_by: number;
|
||||
base_capture_points: number;
|
||||
base_defend_points: number;
|
||||
exp_for_damage: number;
|
||||
exp_for_assist: number;
|
||||
exp_team_bonus: number;
|
||||
wp_points_earned: number;
|
||||
wp_points_stolen: number;
|
||||
hero_bonus_credits: number;
|
||||
hero_bonus_exp: number;
|
||||
death_reason: number;
|
||||
achievements: any[];
|
||||
vehicle_descr: number;
|
||||
turret_id: number;
|
||||
gun_id: number;
|
||||
chassis_id: number;
|
||||
squad_index: number;
|
||||
damage_blocked: number;
|
||||
}
|
||||
|
||||
function stat(value: string | number, flex = 2) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function playerListing(
|
||||
player: WotInspectorReplayPlayerData,
|
||||
protagonistTeam: boolean,
|
||||
winningTeam: number,
|
||||
recordingPlayer = false,
|
||||
) {
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const tank = awaitedTankDefinitions[player.vehicle_descr];
|
||||
const blockAccent = recordingPlayer ? '_amber' : '';
|
||||
const healthAccent = protagonistTeam
|
||||
? recordingPlayer
|
||||
? '_amber'
|
||||
: '_green'
|
||||
: '_red';
|
||||
|
||||
// our turret ids are always off by 1 or 2 lol so imma just grab the closest
|
||||
// TODO: 💀
|
||||
const turret = tank.turrets.sort(
|
||||
(a, b) =>
|
||||
Math.abs(a.id - player.turret_id) - Math.abs(b.id - player.turret_id),
|
||||
)[0];
|
||||
const totalHealth = turret.health + tank.health;
|
||||
const healthLeft = clamp(player.hitpoints_left / totalHealth, 0, 1);
|
||||
const tankAveragesEntry = awaitedTankAverages[player.vehicle_descr];
|
||||
const wn8 =
|
||||
tankAveragesEntry === undefined
|
||||
? -1
|
||||
: calculateWN8(tankAveragesEntry.all, {
|
||||
battles: 1,
|
||||
damage_dealt: player.damage_made,
|
||||
dropped_capture_points: player.base_defend_points,
|
||||
frags: player.enemies_destroyed,
|
||||
spotted: player.enemies_spotted,
|
||||
wins: winningTeam === player.team ? 1 : 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.entity_id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: theme.colors[`appBackground2${blockAccent}`],
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={await iconPng(tankIcon(tank.id))}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// align
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textLowContrast${blockAccent}`],
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textHighContrast${blockAccent}`],
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors[`textLowContrast${blockAccent}`],
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{player.name}
|
||||
{player.clan_tag ? ` [${player.clan_tag}]` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{stat(player.damage_made.toLocaleString())}
|
||||
{stat(wn8 === -1 ? '--' : Math.round(wn8).toLocaleString())}
|
||||
{stat(player.exp.toLocaleString())}
|
||||
{stat(player.enemies_destroyed, 1)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors[`componentInteractive${healthAccent}`],
|
||||
display: 'flex',
|
||||
width: 4,
|
||||
height: '100%',
|
||||
transform: 'scaleY(-1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${healthLeft * 100}%`,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors[`solidBackground${healthAccent}`],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
WN8
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
XP
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textLowContrast,
|
||||
}}
|
||||
>
|
||||
Kills
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const replayCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('replay').addAttachmentOption((option) =>
|
||||
option.setName('file').setDescription('Replay file').setRequired(true),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const awaitedMapDefinitions = await mapDefinitions;
|
||||
const file = interaction.options.getAttachment('file')!;
|
||||
// const file = {
|
||||
// url: 'https://cdn.discordapp.com/ephemeral-attachments/1232434891652861972/1232781496016834581/20240328_1530__TresAbhi_M60_1157057448155406708.wotbreplay?ex=662ab4bc&is=6629633c&hm=c784cd10e80232350ddaf291fa987b879c4d0a8083b78298c9eee4fa0d507f6c&',
|
||||
// };
|
||||
const submissionData = (await fetch(
|
||||
`https://wotinspector.com/api/replay/upload/?url=${encodeURIComponent(
|
||||
file.url,
|
||||
)}`,
|
||||
).then((response) => response.json())) as WotInspectorReplaySubmission;
|
||||
|
||||
if (submissionData.status === 'error') {
|
||||
return `# Uh oh! WoT Inspector didn't like that.\n\nThere was an error processing your replay. Get help on [the official Discord server](https://discord.gg/nDt7AjGJQH).`;
|
||||
}
|
||||
|
||||
const replayId = submissionData.data.download_url.split('/').at(-1)!;
|
||||
const replayData = (await fetch(
|
||||
`https://api.wotinspector.com/v2/blitz/replays/${replayId}/`,
|
||||
).then((response) => response.json())) as WotInspectorReplay;
|
||||
const antagonistTeamId = 3 - replayData.protagonist_team;
|
||||
const players = replayData.players_data.sort(
|
||||
(a, b) => b.damage_made - a.damage_made,
|
||||
);
|
||||
const clan = (
|
||||
await getClanAccountInfo(
|
||||
idToRegion(replayData.protagonist),
|
||||
replayData.protagonist,
|
||||
['clan'],
|
||||
)
|
||||
)?.clan;
|
||||
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
|
||||
return [
|
||||
new RenderConfiguration({ width: 960 }),
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={replayData.player_name}
|
||||
image={clanImage}
|
||||
description={`${
|
||||
replayData.protagonist_team === replayData.winner_team
|
||||
? 'Victory'
|
||||
: 'Defeat'
|
||||
} • ${awaitedMapDefinitions[replayData.map_id].name}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
|
||||
{await Promise.all(
|
||||
players
|
||||
.filter(
|
||||
(player) => player.team === replayData.protagonist_team,
|
||||
)
|
||||
.map((player) =>
|
||||
playerListing(
|
||||
player,
|
||||
true,
|
||||
replayData.winner_team,
|
||||
player.dbid === replayData.protagonist,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
|
||||
{await Promise.all(
|
||||
players
|
||||
.filter((player) => player.team === antagonistTeamId)
|
||||
.map((player) =>
|
||||
playerListing(player, false, replayData.winner_team),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
buttonLink(submissionData.data.view_url, 'WoT Inspector'),
|
||||
buttonLink(submissionData.data.download_url, 'Download'),
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { Locale, escapeMarkdown } from 'discord.js';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { equipmentPriceMatrix } from '../constants/equipmentPrice';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import getTankStats from '../core/blitz/getTankStats';
|
||||
import resolveTankId from '../core/blitz/resolveTankId';
|
||||
import { asset } from '../core/blitzkit/asset';
|
||||
import { buildTechTreeLine } from '../core/blitzkit/buildTechTreeLine';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import { resolveAncestry } from '../core/blitzkit/resolveAncestry';
|
||||
import { tankDefinitions } from '../core/blitzkit/tankDefinitions';
|
||||
import {
|
||||
TANK_ICONS,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '../core/blitzkit/tankDefinitions/constants';
|
||||
import { tankIconPng } from '../core/blitzkit/tankIconPng';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
const { t } = translator(Locale.EnglishUS);
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand('research')
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.research.options.target_tank`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.research.options.target_tank'),
|
||||
)
|
||||
.setDescription(
|
||||
t`bot.commands.research.options.target_tank.description`,
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.research.options.target_tank.description',
|
||||
),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.research.options.starting_tank`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.research.options.starting_tank'),
|
||||
)
|
||||
.setDescription(
|
||||
t`bot.commands.research.options.starting_tank.description`,
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.research.options.starting_tank.description',
|
||||
),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const targetTankId = await resolveTankId(
|
||||
interaction.options.getString('target-tank', true),
|
||||
interaction.locale,
|
||||
true,
|
||||
);
|
||||
const startingTankRaw = interaction.options.getString('starting-tank');
|
||||
const targetTank = awaitedTankDefinitions[targetTankId];
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
let startingTankId: number;
|
||||
const targetTankAncestry = await resolveAncestry(targetTankId);
|
||||
|
||||
if (startingTankRaw) {
|
||||
startingTankId = await resolveTankId(
|
||||
startingTankRaw,
|
||||
interaction.locale,
|
||||
true,
|
||||
);
|
||||
|
||||
if (targetTankId === startingTankId) {
|
||||
return translate('bot.commands.research.errors.start_end_equal', [
|
||||
escapeMarkdown(targetTank.name),
|
||||
]);
|
||||
}
|
||||
|
||||
const startingTank = awaitedTankDefinitions[startingTankId];
|
||||
|
||||
if (startingTank.tier > targetTank.tier) {
|
||||
return translate('bot.commands.research.errors.unordered', [
|
||||
escapeMarkdown(startingTank.name),
|
||||
escapeMarkdown(targetTank.name),
|
||||
escapeMarkdown(targetTank.name),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!targetTankAncestry.includes(startingTankId)) {
|
||||
return translate('bot.commands.research.errors.tanks_not_on_line', [
|
||||
escapeMarkdown(targetTank.name),
|
||||
escapeMarkdown(startingTank.name),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
if (targetTank.treeType !== 'researchable') {
|
||||
return translate('bot.commands.research.errors.non_tech_tree', [
|
||||
escapeMarkdown(targetTank.name),
|
||||
]);
|
||||
}
|
||||
|
||||
if (targetTank.tier === 1) {
|
||||
return translate('bot.commands.research.errors.tier_1', [
|
||||
escapeMarkdown(targetTank.name),
|
||||
]);
|
||||
}
|
||||
|
||||
const tankStats = await getTankStats(region, id);
|
||||
|
||||
if (tankStats === null) {
|
||||
return t`bot.common.errors.no_tank_stats`;
|
||||
}
|
||||
|
||||
if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) {
|
||||
return translate('bot.commands.research.errors.already_researched', [
|
||||
escapeMarkdown(targetTank.name),
|
||||
]);
|
||||
}
|
||||
|
||||
const foundAncestor = targetTankAncestry.find(
|
||||
(id) =>
|
||||
awaitedTankDefinitions[id].tier === 1 ||
|
||||
tankStats.some(({ tank_id }) => tank_id === id),
|
||||
);
|
||||
|
||||
if (foundAncestor === undefined) {
|
||||
throw new Error('No first owned tech tree found.');
|
||||
}
|
||||
|
||||
startingTankId = foundAncestor;
|
||||
}
|
||||
|
||||
const techTreeLine = await buildTechTreeLine(
|
||||
startingTankId,
|
||||
targetTankId,
|
||||
);
|
||||
|
||||
const line = [...techTreeLine, startingTankId].reverse();
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
const costs = await Promise.all(
|
||||
line.map(async (id) => {
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const turretXps = new Map<number, number>();
|
||||
const gunXps = new Map<number, number>();
|
||||
const engineXps = new Map<number, number>();
|
||||
const trackXps = new Map<number, number>();
|
||||
|
||||
tank.turrets.forEach((turret) => {
|
||||
if (turret.xp !== undefined) {
|
||||
turretXps.set(turret.id, turret.xp);
|
||||
}
|
||||
|
||||
turret.guns.forEach((gun) => {
|
||||
if (gun.xp !== undefined) {
|
||||
gunXps.set(gun.id, gun.xp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tank.engines.forEach((engine) => {
|
||||
if (engine.xp !== undefined) {
|
||||
engineXps.set(engine.id, engine.xp);
|
||||
}
|
||||
});
|
||||
|
||||
tank.tracks.forEach((track) => {
|
||||
if (track.xp !== undefined) {
|
||||
trackXps.set(track.id, track.xp);
|
||||
}
|
||||
});
|
||||
|
||||
let moduleXp = 0;
|
||||
turretXps.forEach((value) => {
|
||||
moduleXp += value;
|
||||
});
|
||||
gunXps.forEach((value) => {
|
||||
moduleXp += value;
|
||||
});
|
||||
engineXps.forEach((value) => {
|
||||
moduleXp += value;
|
||||
});
|
||||
trackXps.forEach((value) => {
|
||||
moduleXp += value;
|
||||
});
|
||||
|
||||
return {
|
||||
research: tank.xp,
|
||||
upgrades: moduleXp,
|
||||
purchase: tank.price.value,
|
||||
equipment: equipmentPriceMatrix[tank.tier].reduce(
|
||||
(a, b) => a + 3 * b,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const image = (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${t`bot.commands.research.body.subtitle`} • ${targetTank.name}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.appBackground2,
|
||||
border: theme.borderStyles.subtle,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 48 }}></div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
right: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{t`bot.commands.research.body.total`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="XP icon"
|
||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
Experience:{' '}
|
||||
{costs
|
||||
.reduce((a, b) => a + (b.research ?? 0) + b.upgrades, 0)
|
||||
.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Silver icon"
|
||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
Purchase:{' '}
|
||||
{costs
|
||||
.reduce((a, b) => a + b.purchase, 0)
|
||||
.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Silver icon"
|
||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
Equipment:{' '}
|
||||
{costs
|
||||
.reduce((a, b) => a + b.equipment, 0)
|
||||
.toLocaleString(interaction.locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{await Promise.all(
|
||||
line.map(async (id, index) => {
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.appBackground2,
|
||||
border: theme.borderStyles.subtle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
right: '5%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Tank icon"
|
||||
src={await tankIconPng(id)}
|
||||
style={{
|
||||
height: 100,
|
||||
width: 100,
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
left: '25%',
|
||||
top: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={TANK_ICONS[tank.class]}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="XP icon"
|
||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{translate('bot.commands.research.body.research', [
|
||||
`${costs[index].research?.toLocaleString(
|
||||
interaction.locale,
|
||||
)}`,
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{costs[index].upgrades > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Free XP icon"
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/free-xp.webp'),
|
||||
)}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{translate('bot.commands.research.body.upgrades', [
|
||||
costs[index].upgrades.toLocaleString(),
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Silver icon"
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/silver.webp'),
|
||||
)}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{translate('bot.commands.research.body.purchase', [
|
||||
costs[index].purchase.toLocaleString(
|
||||
interaction.locale,
|
||||
),
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Silver icon"
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/silver.webp'),
|
||||
)}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{translate('bot.commands.research.body.equipment', [
|
||||
costs[index].equipment.toLocaleString(
|
||||
interaction.locale,
|
||||
),
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</CommandWrapper>
|
||||
);
|
||||
|
||||
return startingTankRaw
|
||||
? image
|
||||
: [t`bot.commands.research.body.estimation`, image];
|
||||
},
|
||||
|
||||
autocomplete(interaction) {
|
||||
autocompleteTanks(interaction, true, ['target-tank', 'starting-tank']);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { Region } from '../constants/regions';
|
||||
import fetchBlitz from '../core/blitz/fetchBlitz';
|
||||
import addClanChoices from '../core/discord/addClanChoices';
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { ClanList } from '../types/clanList';
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
export const searchClansCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand('search-clans')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addClanChoices(option).setAutocomplete(false),
|
||||
)
|
||||
.addIntegerOption((option) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName(t`bot.commands.search_clans.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.search_clans.options.limit'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.search_clans.options.limit.description', [
|
||||
`${DEFAULT_LIMIT}`,
|
||||
]),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.search_clans.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const region = interaction.options.getString('region') as Region;
|
||||
const clan = interaction.options.getString('clan')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
||||
const clanList = await fetchBlitz<ClanList>(region, 'clans/list', {
|
||||
search: clan,
|
||||
limit,
|
||||
});
|
||||
|
||||
return embedInfo(
|
||||
translate('bot.commands.search_clans.body.title', [
|
||||
markdownEscape(clan),
|
||||
translate(`common.regions.normal.${region}`),
|
||||
]),
|
||||
clanList.length === 0
|
||||
? translate('bot.commands.search_clans.body.no_results')
|
||||
: `\`\`\`\n${clanList
|
||||
.map((clan) => `${clan.name} [${clan.tag}]`)
|
||||
.join('\n')}\n\`\`\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { Region } from '../constants/regions';
|
||||
import fetchBlitz from '../core/blitz/fetchBlitz';
|
||||
import { AccountList } from '../core/blitz/searchPlayersAcrossRegions';
|
||||
import { usernamePattern } from '../core/blitz/searchPlayersAcrossRegions/constants';
|
||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import embedInfo from '../core/discord/embedInfo';
|
||||
import { localizationObject } from '../core/discord/localizationObject';
|
||||
import { translator } from '../core/localization/translator';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
export const searchPlayersCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
const { t, translate } = translator(Locale.EnglishUS);
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand('search-players')
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addUsernameChoices(option).setAutocomplete(false).setRequired(true),
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(t`bot.commands.search_players.options.limit`)
|
||||
.setNameLocalizations(
|
||||
localizationObject('bot.commands.search_players.options.limit'),
|
||||
)
|
||||
.setDescription(
|
||||
translate('bot.commands.search_players.options.limit.description', [
|
||||
`${DEFAULT_LIMIT}`,
|
||||
]),
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
'bot.commands.search_players.options.limit.description',
|
||||
[`${DEFAULT_LIMIT}`],
|
||||
),
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const { translate } = translator(interaction.locale);
|
||||
const region = interaction.options.getString('region') as Region;
|
||||
const name = interaction.options.getString('username')!;
|
||||
const limit = interaction.options.getInteger('limit') ?? 25;
|
||||
const trimmedSearch = name.trim();
|
||||
const players = usernamePattern.test(trimmedSearch)
|
||||
? await fetchBlitz<AccountList>(region, 'account/list', {
|
||||
search: trimmedSearch,
|
||||
limit,
|
||||
})
|
||||
: [];
|
||||
|
||||
return embedInfo(
|
||||
translate('bot.commands.search_players.body.title', [
|
||||
markdownEscape(trimmedSearch),
|
||||
translate(`common.regions.normal.${region}`),
|
||||
]),
|
||||
`\`\`\`${
|
||||
players.length === 0
|
||||
? translate('bot.commands.search_players.body.no_results')
|
||||
: players.map((player) => player.nickname).join('\n')
|
||||
}\`\`\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import getStatsInPeriod from '@blitzkit/core/src/blitzstars/getStatsInPeriod';
|
||||
import { Locale } from 'discord.js';
|
||||
import AllStatsOverview from '../components/AllStatsOverview';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import NoData from '../components/NoData';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import { filtersToDescription } from '../core/blitzkit/filtersToDescription';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand, {
|
||||
ResolvedPeriod,
|
||||
} from '../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand, {
|
||||
ResolvedPlayer,
|
||||
} from '../core/discord/resolvePlayerFromCommand';
|
||||
import { StatFilters, filterStats } from '../core/statistics/filterStats';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
|
||||
async function render(
|
||||
{ region, id }: ResolvedPlayer,
|
||||
{ start, end, name }: ResolvedPeriod,
|
||||
filters: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||
const diffedTankStats = await getStatsInPeriod(
|
||||
region,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
);
|
||||
const { stats, supplementary } = await filterStats(diffedTankStats, filters);
|
||||
const filterDescriptions = await filtersToDescription(filters, locale);
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={nickname}
|
||||
image={clanImage}
|
||||
description={`${name} • ${filterDescriptions}`}
|
||||
/>
|
||||
|
||||
{!stats.battles && <NoData type="battles_in_period" locale={locale} />}
|
||||
{stats.battles > 0 && (
|
||||
<AllStatsOverview
|
||||
locale={locale}
|
||||
stats={stats}
|
||||
supplementaryStats={supplementary}
|
||||
/>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const statsCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
resolve({
|
||||
command: await addPeriodicFilterOptions(
|
||||
createLocalizedCommand('stats'),
|
||||
(option) => option.addStringOption(addUsernameChoices),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(player.region, interaction);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction),
|
||||
...filters,
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
render(player, period, filters, interaction.locale),
|
||||
buttonRefresh(interaction, path),
|
||||
getBlitzStarsLinkButton(player.region, player.id),
|
||||
]);
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await render(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { gameDefinitions } from '@blitzkit/core/src/blitzkit/gameDefinitions';
|
||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||
import { addFilterOptions } from '../core/discord/addFilterOptions';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteTanks from '../core/discord/autocompleteTanks';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { buttonRefresh } from '../core/discord/buttonRefresh';
|
||||
import commandToURL from '../core/discord/commandToURL';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
|
||||
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
|
||||
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
|
||||
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
|
||||
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
|
||||
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { renderBreakdown } from './breakdown/render';
|
||||
|
||||
export const todayCommand = new Promise<CommandRegistry>(async (resolve) => {
|
||||
const awaitedGameDefinitions = await gameDefinitions;
|
||||
|
||||
resolve({
|
||||
command: addFilterOptions(
|
||||
createLocalizedCommand('today'),
|
||||
awaitedGameDefinitions.nations,
|
||||
).addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const player = await resolvePlayerFromCommand(interaction);
|
||||
const period = resolvePeriodFromCommand(
|
||||
player.region,
|
||||
interaction,
|
||||
'today',
|
||||
);
|
||||
const filters = await getFiltersFromCommand(interaction);
|
||||
const path = commandToURL(interaction, {
|
||||
...player,
|
||||
...getCustomPeriodParams(interaction, true),
|
||||
...filters,
|
||||
});
|
||||
|
||||
return [
|
||||
...(await renderBreakdown(player, period, filters, interaction.locale)),
|
||||
buttonRefresh(interaction, path),
|
||||
await getBlitzStarsLinkButton(player.region, player.id),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: (interaction) => {
|
||||
autocompleteUsername(interaction);
|
||||
autocompleteTanks(interaction);
|
||||
},
|
||||
|
||||
async button(interaction) {
|
||||
const player = await resolvePlayerFromButton(interaction);
|
||||
const period = resolvePeriodFromButton(player.region, interaction);
|
||||
const filters = getFiltersFromButton(interaction);
|
||||
|
||||
return await renderBreakdown(player, period, filters, interaction.locale);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export interface GlowProps extends ComponentProps<'div'> {
|
||||
color: string;
|
||||
direction?: 'default' | 'reverse';
|
||||
rotation?: number;
|
||||
startOpacity?: number;
|
||||
endOpacity?: number;
|
||||
}
|
||||
|
||||
const width = 16;
|
||||
|
||||
export function Glow({
|
||||
color,
|
||||
direction = 'default',
|
||||
rotation = 0,
|
||||
style,
|
||||
startOpacity = 0,
|
||||
endOpacity = 1,
|
||||
...props
|
||||
}: GlowProps) {
|
||||
const color1 = `${color}${Math.round(endOpacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}`;
|
||||
const color2 = `${color}${Math.round(startOpacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}`;
|
||||
const background = `linear-gradient(${
|
||||
direction === 'default' ? 0 : 180
|
||||
}deg, ${color1} 0%, ${color2} 100%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
|
||||
justifyContent: 'center',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div style={{ height: 54, width, background }} />
|
||||
<div style={{ height: 72, width, background }} />
|
||||
<div style={{ height: 80, width, background }} />
|
||||
<div style={{ height: 72, width, background }} />
|
||||
<div style={{ height: 54, width, background }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { Glow } from './components/Glow';
|
||||
|
||||
export interface WN8DisplayProps {
|
||||
subtitle: string;
|
||||
color: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export function HeroStat({ color, subtitle, value }: WN8DisplayProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 128 * 2, // idk why twice the width turns into the correct width
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Glow color={color} style={{ marginBottom: -16 }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: 128,
|
||||
height: 80,
|
||||
backgroundColor: color,
|
||||
borderRadius: 40,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 900,
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
lineHeight: '26px',
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 16,
|
||||
lineHeight: '16px',
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Glow color={color} direction="reverse" style={{ marginTop: -16 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems:
|
||||
align === WN8SurroundingStatAlign.Left ? 'flex-start' : 'flex-end',
|
||||
paddingLeft: padded && align === WN8SurroundingStatAlign.Left ? 32 : 0,
|
||||
paddingRight:
|
||||
padded && align === WN8SurroundingStatAlign.Right ? 32 : 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import { Locale } from 'discord.js';
|
||||
import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
|
||||
import { PERCENTILE_COLORS } from '../PercentileIndicator/constants';
|
||||
import { HeroStat } from './components/HeroStat';
|
||||
import {
|
||||
WN8SurroundingStat,
|
||||
WN8SurroundingStatAlign,
|
||||
} from './components/WN8SurroundingStat';
|
||||
|
||||
export interface AllStatsOverviewProps {
|
||||
stats: {
|
||||
battles: number;
|
||||
wins: number;
|
||||
damage_dealt: number;
|
||||
damage_received: number;
|
||||
survived_battles: number;
|
||||
hits: number;
|
||||
shots: number;
|
||||
frags: number;
|
||||
};
|
||||
supplementaryStats: SupplementaryStats;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function AllStatsOverview({
|
||||
stats,
|
||||
locale,
|
||||
supplementaryStats,
|
||||
}: AllStatsOverviewProps) {
|
||||
const { t, translate } = translator(locale);
|
||||
const percentile =
|
||||
typeof supplementaryStats.WN8 === 'number'
|
||||
? getWN8Percentile(supplementaryStats.WN8)
|
||||
: Percentile.VeryBad;
|
||||
const color = PERCENTILE_COLORS[percentile];
|
||||
|
||||
const heroStat =
|
||||
typeof supplementaryStats.WN8 === 'number'
|
||||
? supplementaryStats.WN8.toFixed(0)
|
||||
: '--';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: -64,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
alignItems: 'flex-end',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.battles`}
|
||||
value={stats.battles.toLocaleString()}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.winrate`}
|
||||
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.damage`}
|
||||
value={Math.round(
|
||||
stats.damage_dealt / stats.battles,
|
||||
).toLocaleString()}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.damage_ratio`}
|
||||
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroStat
|
||||
value={heroStat}
|
||||
subtitle={translate(`common.wn8_percentile.${percentile}`)}
|
||||
color={color}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.tier`}
|
||||
value={
|
||||
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
|
||||
}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.survival`}
|
||||
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
|
||||
2,
|
||||
)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.accuracy`}
|
||||
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={t`bot.common.hero_stats.kills`}
|
||||
value={(stats.frags / stats.battles).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div
|
||||
className="session-tracker-root"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import { createColors } from 'bepaint';
|
||||
import {
|
||||
AccentColor,
|
||||
GrayColor,
|
||||
PALETTES,
|
||||
} from '../../../constants/radixColors';
|
||||
import { TankClass, TreeType } from '../../Tanks';
|
||||
import { TREE_TYPE_ICONS } from '../../Tanks/components/Item/constants';
|
||||
import { RowStat } from './RowStat';
|
||||
|
||||
export interface RowStatItem {
|
||||
title: string;
|
||||
current?: number | string;
|
||||
career?: number | string;
|
||||
delta?: number;
|
||||
percentile?: Percentile;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
type?: 'tank' | 'summary';
|
||||
naked?: boolean;
|
||||
title: string;
|
||||
minimized?: boolean;
|
||||
treeType?: TreeType;
|
||||
tankType?: TankClass;
|
||||
color?: AccentColor | GrayColor;
|
||||
stats: (RowStatItem | undefined)[];
|
||||
}
|
||||
|
||||
export function Row({
|
||||
color = 'slate',
|
||||
stats,
|
||||
title,
|
||||
minimized,
|
||||
tankType,
|
||||
treeType,
|
||||
naked = false,
|
||||
type,
|
||||
}: RowProps) {
|
||||
const palette = PALETTES[`${color}Dark`];
|
||||
const theme = createColors(palette);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="session-tracker-row"
|
||||
style={{
|
||||
maxWidth: naked ? 600 : Infinity,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 4,
|
||||
backgroundColor: naked
|
||||
? `${theme.appBackground2}c0`
|
||||
: theme.appBackground2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 8,
|
||||
gap: 4,
|
||||
backgroundColor: naked
|
||||
? `${theme.componentInteractive}c0`
|
||||
: theme.componentInteractive,
|
||||
}}
|
||||
>
|
||||
{type === 'tank' && tankType !== undefined && (
|
||||
<img
|
||||
alt={tankType}
|
||||
src={TREE_TYPE_ICONS[treeType ?? 'researchable'][tankType]}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
type === 'tank'
|
||||
? treeType === 'collector'
|
||||
? theme.textLowContrast_blue
|
||||
: treeType === 'premium'
|
||||
? theme.textLowContrast_amber
|
||||
: theme.textHighContrast
|
||||
: theme.textLowContrast,
|
||||
fontWeight: 900,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
{stats.map(
|
||||
(row, index) =>
|
||||
row && (
|
||||
<RowStat
|
||||
minimized={minimized}
|
||||
key={index}
|
||||
name={`${row.title} • ${row.career ?? '--'}`}
|
||||
value={row.current ?? '--'}
|
||||
delta={row.delta}
|
||||
percentile={row.percentile}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import { createColors } from 'bepaint';
|
||||
import {
|
||||
AccentColor,
|
||||
GrayColor,
|
||||
PALETTES,
|
||||
} from '../../../constants/radixColors';
|
||||
import PercentileIndicator from '../../PercentileIndicator';
|
||||
|
||||
interface RowStatProps {
|
||||
name: string;
|
||||
value?: string | number;
|
||||
delta?: number;
|
||||
percentile?: Percentile;
|
||||
minimized?: boolean;
|
||||
color?: AccentColor | GrayColor;
|
||||
}
|
||||
|
||||
export function RowStat({
|
||||
name,
|
||||
value,
|
||||
delta,
|
||||
percentile,
|
||||
minimized = false,
|
||||
color = 'slate',
|
||||
}: RowStatProps) {
|
||||
const theme = createColors(PALETTES[`${color}Dark`]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="session-tracker-row-stat"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{percentile === undefined && delta !== undefined && delta !== 0 && (
|
||||
<img
|
||||
alt={delta > 0 ? 'Increase' : 'Decrease'}
|
||||
src={
|
||||
(delta ?? 0) > 0
|
||||
? 'https://i.imgur.com/qbjiXa1.png'
|
||||
: 'https://i.imgur.com/3uyNhun.png'
|
||||
}
|
||||
style={{ width: 12, height: 12 }}
|
||||
/>
|
||||
)}
|
||||
{percentile !== undefined && (
|
||||
<PercentileIndicator percentile={percentile} />
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: theme.textHighContrast,
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!minimized && (
|
||||
<span
|
||||
style={{
|
||||
color: theme.textLowContrast,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './components/Root';
|
||||
export * from './components/Row';
|
||||
export * from './components/RowStat';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
interface CommandWrapperProps {
|
||||
children: ReactNode;
|
||||
fat?: boolean;
|
||||
}
|
||||
|
||||
export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: `16px ${fat ? 0 : 16}px`,
|
||||
gap: 16,
|
||||
width: '100%',
|
||||
color: theme.colors.textHighContrast,
|
||||
position: 'relative',
|
||||
background: theme.colors.appBackground1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
interface DeltaCaretProps {
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export function DeltaCaret({ delta }: DeltaCaretProps) {
|
||||
return (
|
||||
<img
|
||||
alt={delta > 0 ? 'Increase' : 'Decrease'}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={
|
||||
delta > 0
|
||||
? 'https://i.imgur.com/gjI4a5f.png'
|
||||
: 'https://i.imgur.com/lLGZygN.png'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { asset } from '@blitzkit/core/src/blitzkit/asset';
|
||||
|
||||
interface FlagProps {
|
||||
nation: string;
|
||||
}
|
||||
|
||||
export function Flag({ nation }: FlagProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img alt={nation} src={asset(`flags/circle/${nation}.webp`)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import { theme } from '../../../stitches.config';
|
||||
import PercentileIndicator from '../../PercentileIndicator';
|
||||
|
||||
interface CardItem {
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
percentile?: Percentile;
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
items: CardItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function Card({ items, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
flex: 1,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.componentInteractive,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 24,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
borderRadius: 0.5,
|
||||
backgroundColor: theme.colors.borderNonInteractive,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.percentile !== undefined && (
|
||||
<PercentileIndicator percentile={item.percentile} />
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{item.value === undefined || Number.isNaN(item.value)
|
||||
? '--'
|
||||
: item.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface RootProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface RowProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Row({ children }: RowProps) {
|
||||
return <div style={{ display: 'flex', gap: 8 }}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import isNumber from '@blitzkit/core/src/math/isNumber';
|
||||
import { Locale } from 'discord.js';
|
||||
import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import { BlitzStats } from '../../core/statistics/compositeStats/constants';
|
||||
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
|
||||
import { Card } from './components/Card';
|
||||
import { Root } from './components/Root';
|
||||
import { Row } from './components/Row';
|
||||
|
||||
interface GenericAllStatsProps {
|
||||
stats: BlitzStats;
|
||||
supplementaryStats?: SupplementaryStats;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function GenericAllStats({
|
||||
stats,
|
||||
supplementaryStats,
|
||||
locale,
|
||||
}: GenericAllStatsProps) {
|
||||
const { t } = translator(locale);
|
||||
const damageRatio = stats.damage_dealt / stats.damage_received;
|
||||
const killsToDeathRatio =
|
||||
stats.frags / (stats.battles - stats.survived_battles);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Row>
|
||||
<Card
|
||||
title={t`bot.commands.full_stats.body.cards.games`}
|
||||
items={[
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.games.winrate`,
|
||||
value: `${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.games.battles`,
|
||||
value: stats.battles,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.games.wins_losses`,
|
||||
value: `${stats.wins} / ${stats.losses}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title={t`bot.commands.full_stats.body.cards.efficiency`}
|
||||
items={[
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.wn8`,
|
||||
value:
|
||||
supplementaryStats && isNumber(supplementaryStats.WN8)
|
||||
? supplementaryStats.WN8!.toFixed(0)
|
||||
: undefined,
|
||||
percentile: isNumber(supplementaryStats?.WN8)
|
||||
? getWN8Percentile(supplementaryStats!.WN8!)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.survival`,
|
||||
value: `${(
|
||||
100 *
|
||||
(stats.survived_battles / stats.battles)
|
||||
).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.efficiency.accuracy`,
|
||||
value: `${((stats.hits / stats.shots) * 100).toFixed(2)}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Card
|
||||
title={t`bot.commands.full_stats.body.cards.lethality`}
|
||||
items={[
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.average_damage`,
|
||||
value: (stats.damage_dealt / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.damage_per_tier`,
|
||||
value: supplementaryStats?.tier
|
||||
? (
|
||||
stats.damage_dealt /
|
||||
stats.battles /
|
||||
supplementaryStats.tier
|
||||
).toFixed(0)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.damage_ratio`,
|
||||
value: isFinite(damageRatio) ? damageRatio.toFixed(2) : undefined,
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.lethality.average_kills`,
|
||||
value: (stats.frags / stats.battles).toFixed(0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title={t`bot.commands.full_stats.body.cards.miscellaneous`}
|
||||
items={[
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_tier`,
|
||||
value: supplementaryStats?.tier?.toFixed(2),
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_spots`,
|
||||
value: (stats.spotted / stats.battles).toFixed(2),
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.average_xp`,
|
||||
value: (stats.xp / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label: t`bot.commands.full_stats.body.cards.miscellaneous.kills_to_death_ratio`,
|
||||
value: isFinite(killsToDeathRatio)
|
||||
? killsToDeathRatio.toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { GenericStat } from '..';
|
||||
import GenericStatsRow from './GenericStatsRow';
|
||||
|
||||
interface GenericStatsColumnProps {
|
||||
stats: GenericStat[];
|
||||
}
|
||||
|
||||
export default function GenericStatsColumn({ stats }: GenericStatsColumnProps) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{stats.map((stat) => (
|
||||
<GenericStatsRow key={stat[0]} stat={stat} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GenericStat } from '..';
|
||||
import { theme } from '../../../stitches.config';
|
||||
import PercentileIndicator from '../../PercentileIndicator';
|
||||
|
||||
interface GenericStatsRowProps {
|
||||
stat: GenericStat;
|
||||
}
|
||||
|
||||
export default function GenericStatsRow({ stat }: GenericStatsRowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 128,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{stat[0]}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{stat[2] !== undefined && <PercentileIndicator percentile={stat[2]} />}
|
||||
{stat[1]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import GenericStatsColumn from './components/GenericStatsColumn';
|
||||
|
||||
export type GenericStat =
|
||||
| [string, string | number | undefined]
|
||||
| [string, string | number | undefined, Percentile | undefined];
|
||||
|
||||
export interface GenericStatsProps {
|
||||
stats: GenericStat[];
|
||||
columnCount?: number;
|
||||
}
|
||||
|
||||
export default function GenericStats({
|
||||
stats,
|
||||
columnCount = 2,
|
||||
}: GenericStatsProps) {
|
||||
const filteredStats = stats.filter(([, value]) => value !== undefined);
|
||||
const itemsPerRow = Math.ceil(filteredStats.length / columnCount);
|
||||
const columns: GenericStat[][] = [];
|
||||
|
||||
filteredStats.forEach((stat, index) => {
|
||||
const column = Math.floor(index / itemsPerRow);
|
||||
if (!columns[column]) columns[column] = [];
|
||||
columns[column].push(stat);
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 32 }}>
|
||||
{columns.map((stats) => (
|
||||
<GenericStatsColumn
|
||||
key={stats.map((stat) => stat[0]).join('-')}
|
||||
stats={stats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum LineColor {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
White,
|
||||
}
|
||||
export enum LineStyle {
|
||||
Solid,
|
||||
Dashes,
|
||||
DashesAndDots,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { blue, green, red, slateDark } from '@radix-ui/colors';
|
||||
import { GRAPH_HEIGHT, GRAPH_WIDTH } from '../Root/constants';
|
||||
import { LineColor, LineStyle } from './constants';
|
||||
|
||||
const LINE_COLORS = {
|
||||
[LineColor.Red]: red.red9,
|
||||
[LineColor.Green]: green.green9,
|
||||
[LineColor.Blue]: blue.blue9,
|
||||
[LineColor.White]: slateDark.slate11,
|
||||
};
|
||||
|
||||
const LINE_STYLES = {
|
||||
[LineStyle.Solid]: undefined,
|
||||
[LineStyle.Dashes]: '8, 8',
|
||||
[LineStyle.DashesAndDots]: '1, 4, 8, 4',
|
||||
};
|
||||
|
||||
export type PlotItem = [number, number];
|
||||
type Plot = PlotItem[];
|
||||
|
||||
interface LineProps {
|
||||
plot: Plot;
|
||||
minY?: number;
|
||||
maxY?: number;
|
||||
color?: LineColor;
|
||||
style?: LineStyle;
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
export function Line({
|
||||
plot,
|
||||
minY: providedMinY,
|
||||
maxY: providedMaxY,
|
||||
color = LineColor.Red,
|
||||
style = LineStyle.Solid,
|
||||
lineWidth = 2,
|
||||
}: LineProps) {
|
||||
const xs = plot.map(([x]) => x);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const ys = plot.map(([, y]) => y);
|
||||
const minY = providedMinY ?? Math.min(...ys);
|
||||
const maxY = providedMaxY ?? Math.max(...ys);
|
||||
const rangeX = maxX - minX;
|
||||
const rangeY = maxY - minY;
|
||||
const points = plot
|
||||
.map(
|
||||
([x, y]) =>
|
||||
`${(x - minX) * (GRAPH_WIDTH / rangeX)},${
|
||||
GRAPH_HEIGHT - (y - minY) * (GRAPH_HEIGHT / rangeY)
|
||||
}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<polyline
|
||||
vector-effect="non-scaling-stroke"
|
||||
fill="none"
|
||||
stroke={LINE_COLORS[color]}
|
||||
stroke-width={lineWidth}
|
||||
stroke-dasharray={LINE_STYLES[style]}
|
||||
points={points}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum MarginOrientation {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { times } from 'lodash';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { MarginInputProps } from '../Root';
|
||||
import { MarginOrientation } from './constants';
|
||||
|
||||
interface MarginProps extends MarginInputProps {
|
||||
verticalSeparations: number;
|
||||
orientation?: MarginOrientation;
|
||||
}
|
||||
|
||||
export function Margin({
|
||||
verticalSeparations,
|
||||
max,
|
||||
min,
|
||||
suffix,
|
||||
precision = 0,
|
||||
orientation = MarginOrientation.Vertical,
|
||||
}: MarginProps) {
|
||||
const isVertical = orientation === MarginOrientation.Vertical;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: isVertical ? 0 : 64,
|
||||
width: isVertical ? 48 : '100%',
|
||||
display: 'flex',
|
||||
flexDirection: isVertical ? 'column' : 'row-reverse',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{times(verticalSeparations, (index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{`${(
|
||||
(max - min) *
|
||||
((verticalSeparations - (index + 1)) /
|
||||
(verticalSeparations - 1)) +
|
||||
min
|
||||
).toFixed(precision)}${suffix ?? ''}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const GRAPH_WIDTH = 384;
|
||||
export const GRAPH_HEIGHT = 320;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { times } from 'lodash';
|
||||
import { ReactNode } from 'react';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { Margin } from '../Margin';
|
||||
import { MarginOrientation } from '../Margin/constants';
|
||||
import { GRAPH_HEIGHT, GRAPH_WIDTH } from './constants';
|
||||
|
||||
export interface MarginInputProps {
|
||||
min: number;
|
||||
max: number;
|
||||
suffix?: string;
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
verticalMargin?: MarginInputProps;
|
||||
horizontalMargin?: MarginInputProps;
|
||||
separations?: number;
|
||||
xMinLabel: string;
|
||||
xMaxLabel: string;
|
||||
xTitle?: string;
|
||||
}
|
||||
|
||||
export function Root({
|
||||
children,
|
||||
verticalMargin,
|
||||
horizontalMargin,
|
||||
separations = 5,
|
||||
xMinLabel,
|
||||
xMaxLabel,
|
||||
xTitle,
|
||||
}: RootProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 64,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textHighContrast }}>
|
||||
{xMinLabel}
|
||||
</span>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textHighContrast }}>
|
||||
{xMaxLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{verticalMargin && (
|
||||
<Margin {...verticalMargin} verticalSeparations={separations} />
|
||||
)}
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.appBackground2,
|
||||
borderRadius: 4,
|
||||
border: theme.borderStyles.nonInteractive,
|
||||
}}
|
||||
>
|
||||
{times(separations - 2, (index) => {
|
||||
const y = GRAPH_HEIGHT * ((index + 1) / (separations - 1));
|
||||
|
||||
return (
|
||||
<polyline
|
||||
key={index}
|
||||
vector-effect="non-scaling-stroke"
|
||||
fill="none"
|
||||
stroke={theme.colors.borderNonInteractive}
|
||||
stroke-width="2"
|
||||
points={`0,${y}, ${GRAPH_WIDTH},${y}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{children}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{horizontalMargin && (
|
||||
<Margin
|
||||
{...horizontalMargin}
|
||||
verticalSeparations={separations}
|
||||
orientation={MarginOrientation.Horizontal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{xTitle !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
|
||||
{xTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './components/Line';
|
||||
export * from './components/Margin';
|
||||
export * from './components/Root';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
interface GapProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function Gap({ message: number }: GapProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: 8,
|
||||
backgroundColor: theme.colors.componentNonInteractive,
|
||||
borderRadius: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{number.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
interface ItemProps extends ComponentProps<'div'> {
|
||||
position: number;
|
||||
deltaPosition?: number;
|
||||
score?: number;
|
||||
deltaScore?: number;
|
||||
nickname: string;
|
||||
clan?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function Item({
|
||||
position,
|
||||
deltaPosition,
|
||||
score,
|
||||
deltaScore,
|
||||
nickname,
|
||||
clan,
|
||||
highlight,
|
||||
...props
|
||||
}: ItemProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
padding: 8,
|
||||
backgroundColor: highlight
|
||||
? theme.colors.componentInteractive_blue
|
||||
: theme.colors.componentInteractive,
|
||||
borderRadius: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textLowContrast_blue
|
||||
: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{position.toLocaleString()}.
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textHighContrast_blue
|
||||
: theme.colors.textHighContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{nickname}
|
||||
</span>
|
||||
{clan && (
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textLowContrast_blue
|
||||
: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
[{clan}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deltaPosition !== undefined && deltaPosition !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={deltaPosition > 0 ? 'Increase' : 'Decrease'}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
}}
|
||||
src={
|
||||
deltaPosition > 0
|
||||
? 'https://i.imgur.com/qbjiXa1.png'
|
||||
: 'https://i.imgur.com/3uyNhun.png'
|
||||
}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textLowContrast_blue
|
||||
: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{Math.abs(deltaPosition).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: lol remove this spacer div for a better solution */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
width: 96,
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
{deltaScore !== 0 && deltaScore !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textLowContrast_blue
|
||||
: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{Math.abs(deltaScore).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{deltaScore !== 0 && deltaScore !== undefined && (
|
||||
<img
|
||||
alt={deltaScore > 0 ? 'Increase' : 'Decrease'}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
}}
|
||||
src={
|
||||
deltaScore > 0
|
||||
? 'https://i.imgur.com/qbjiXa1.png'
|
||||
: 'https://i.imgur.com/3uyNhun.png'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{score !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
color: highlight
|
||||
? theme.colors.textHighContrast_blue
|
||||
: theme.colors.textHighContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{score.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './components/Gap';
|
||||
export * from './components/Item';
|
||||
export * from './components/Root';
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum NoDataType {
|
||||
BattlesInPeriod,
|
||||
PlayersInPeriod,
|
||||
TanksFound,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Locale } from 'discord.js';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import { theme } from '../../stitches.config';
|
||||
|
||||
interface NoDataProps {
|
||||
type:
|
||||
| 'battles_in_period'
|
||||
| 'players_in_period'
|
||||
| 'tanks_found'
|
||||
| 'battles_in_season';
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function NoData({ type, locale }: NoDataProps) {
|
||||
const { translate } = translator(locale);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
|
||||
{translate(`bot.common.no_data.${type}`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import {
|
||||
amber,
|
||||
blue,
|
||||
green,
|
||||
lime,
|
||||
orange,
|
||||
pink,
|
||||
purple,
|
||||
red,
|
||||
slate,
|
||||
teal,
|
||||
} from '@radix-ui/colors';
|
||||
|
||||
export const PERCENTILE_COLORS = {
|
||||
[Percentile.VeryBad]: slate.slate9,
|
||||
[Percentile.Bad]: red.red9,
|
||||
[Percentile.BelowAverage]: orange.orange9,
|
||||
[Percentile.Average]: amber.amber10,
|
||||
[Percentile.AboveAverage]: lime.lime8,
|
||||
[Percentile.Good]: green.green9,
|
||||
[Percentile.VeryGood]: teal.teal9,
|
||||
[Percentile.Great]: blue.blue9,
|
||||
[Percentile.Unicum]: pink.pink9,
|
||||
[Percentile.SuperUnicum]: purple.purple9,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Percentile } from '@blitzkit/core/src/statistics/percentiles';
|
||||
import { PERCENTILE_COLORS } from './constants';
|
||||
|
||||
interface PercentileIndicatorProps {
|
||||
percentile: Percentile;
|
||||
}
|
||||
|
||||
export default function PercentileIndicator({
|
||||
percentile,
|
||||
}: PercentileIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: PERCENTILE_COLORS[percentile],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ColumnProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Column({ children }: ColumnProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TankClass, TreeType } from '.';
|
||||
import {
|
||||
TANK_ICONS,
|
||||
TANK_ICONS_COLLECTOR,
|
||||
TANK_ICONS_PREMIUM,
|
||||
} from '../../../../core/blitzkit/tankDefinitions/constants';
|
||||
|
||||
export const TREE_TYPE_NAMES: Record<TreeType, string> = {
|
||||
researchable: 'Tech tree',
|
||||
premium: 'Premium',
|
||||
collector: 'Collector',
|
||||
};
|
||||
export const TREE_TYPE_IMAGES: Record<TreeType, string> = {
|
||||
researchable: 'https://i.imgur.com/pJxO2XY.png',
|
||||
premium: 'https://i.imgur.com/mZzSwOU.png',
|
||||
collector: 'https://i.imgur.com/7A0RsG5.png',
|
||||
};
|
||||
export const TREE_TYPE_ICONS: Record<TreeType, Record<TankClass, string>> = {
|
||||
researchable: TANK_ICONS,
|
||||
premium: TANK_ICONS_PREMIUM,
|
||||
collector: TANK_ICONS_COLLECTOR,
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { TANK_CLASSES, TREE_TYPES, TREE_TYPE_ICONS } from './constants';
|
||||
|
||||
export type TankClass = (typeof TANK_CLASSES)[number];
|
||||
export type TreeType = (typeof TREE_TYPES)[number];
|
||||
|
||||
export interface ItemProps {
|
||||
tankType?: TankClass;
|
||||
name: string;
|
||||
treeType: TreeType;
|
||||
}
|
||||
|
||||
const TREE_TYPE_COLOR = {
|
||||
researchable: '',
|
||||
premium: '_amber',
|
||||
collector: '_blue',
|
||||
} as const;
|
||||
|
||||
export function Item({ tankType, name, treeType }: ItemProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 32,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
backgroundColor:
|
||||
theme.colors[`componentInteractive${TREE_TYPE_COLOR[treeType]}`],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tankType && (
|
||||
<img
|
||||
alt={tankType}
|
||||
src={TREE_TYPE_ICONS[treeType][tankType]}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.colors[`textLowContrast${TREE_TYPE_COLOR[treeType]}`],
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* {image && (
|
||||
// TODO: remove hardcoded dimensions when satori fixes non-width unloadable images
|
||||
<img
|
||||
alt={name}
|
||||
src={image}
|
||||
width={106}
|
||||
height={32}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface RowProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Row({ children }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
interface TitleProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function Title({ children }: TitleProps) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
color: theme.colors.textHighContrast,
|
||||
fontSize: 24,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './components/Column';
|
||||
export * from './components/Item';
|
||||
export * from './components/Root';
|
||||
export * from './components/Row';
|
||||
export * from './components/Title';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Tier } from '../core/blitzkit/tankDefinitions';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
export type TierWeightsRecord = Partial<Record<Tier, number>>;
|
||||
|
||||
interface TierWeightsProps {
|
||||
weights: TierWeightsRecord;
|
||||
}
|
||||
|
||||
export default function TierWeights({ weights }: TierWeightsProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{Object.entries(weights).map(([tier, weight]) => (
|
||||
<div
|
||||
key={tier}
|
||||
style={{
|
||||
backgroundColor: theme.colors.appBackground1_light,
|
||||
flex: weight,
|
||||
color: theme.colors.textHighContrast_light,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
minWidth: 16,
|
||||
}}
|
||||
>
|
||||
{tier}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
interface TitleBarProps {
|
||||
title: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function TitleBar({ title, image, description }: TitleBarProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
gap: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
gap: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
alt={title}
|
||||
style={{ width: 32, height: 32, objectFit: 'contain' }}
|
||||
src={image}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 900,
|
||||
background: `linear-gradient(180deg, ${theme.colors.textHighContrast} 0%, ${theme.colors.textLowContrast} 100%)`,
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './getStatsInPeriod';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ACCENT_COLOR = '#ecedee';
|
||||
export const POSITIVE_COLOR = '#82ff29';
|
||||
export const NEGATIVE_COLOR = '#ff4747';
|
||||
export const WARNING_COLOR = '#ffab00';
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as colors from '@radix-ui/colors';
|
||||
import { Theme } from '@radix-ui/themes';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export type AccentColor = Exclude<
|
||||
ComponentProps<typeof Theme>['accentColor'],
|
||||
'gray' | undefined
|
||||
>;
|
||||
export type AccentColorDark = `${AccentColor}Dark`;
|
||||
export type GrayColor = Exclude<
|
||||
ComponentProps<typeof Theme>['grayColor'],
|
||||
'auto' | undefined
|
||||
>;
|
||||
export type GrayColorDark = `${GrayColor}Dark`;
|
||||
|
||||
export const ACCENT_COLORS: AccentColor[] = [
|
||||
'tomato',
|
||||
'red',
|
||||
'ruby',
|
||||
'crimson',
|
||||
'pink',
|
||||
'plum',
|
||||
'purple',
|
||||
'violet',
|
||||
'iris',
|
||||
'indigo',
|
||||
'blue',
|
||||
'cyan',
|
||||
'teal',
|
||||
'jade',
|
||||
'green',
|
||||
'grass',
|
||||
'brown',
|
||||
'orange',
|
||||
'sky',
|
||||
'mint',
|
||||
'lime',
|
||||
'yellow',
|
||||
'amber',
|
||||
'gold',
|
||||
'bronze',
|
||||
];
|
||||
export const GRAY_COLORS: GrayColor[] = [
|
||||
'gray',
|
||||
'mauve',
|
||||
'slate',
|
||||
'sage',
|
||||
'olive',
|
||||
'sand',
|
||||
];
|
||||
|
||||
export const PALETTES: Record<
|
||||
AccentColor | AccentColorDark | GrayColor | GrayColorDark,
|
||||
{ [key: `${string}${number}`]: string }
|
||||
> = colors;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutocompleteInteraction, CacheType } from 'discord.js';
|
||||
import { commands } from '..';
|
||||
|
||||
export default async function handleAutocomplete(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const command = (await commands)[interaction.commandName];
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.autocomplete) command.autocomplete(interaction);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ButtonInteraction, CacheType } from 'discord.js';
|
||||
import { commands } from '..';
|
||||
import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable';
|
||||
|
||||
export default async function handleButton(
|
||||
interaction: ButtonInteraction<CacheType>,
|
||||
) {
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const url = new URL(`https://example.com/${interaction.customId}`);
|
||||
const commandName = url.pathname.split('/')[1];
|
||||
const button = (await commands)[commandName]?.button;
|
||||
|
||||
if (!button) {
|
||||
throw new Error(`Button handler not found for "${commandName}"`);
|
||||
}
|
||||
|
||||
if (interaction.message.interaction?.user.id !== interaction.user.id) {
|
||||
interaction.followUp({
|
||||
ephemeral: true,
|
||||
content: 'You are not the author of this command.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.message.edit(
|
||||
await normalizeInteractionReturnable(button(interaction)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
AttachmentBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
CacheType,
|
||||
ChatInputCommandInteraction,
|
||||
InteractionReplyOptions,
|
||||
} from 'discord.js';
|
||||
import { InteractionRawReturnable, commands } from '..';
|
||||
import buttonLink from '../../../core/discord/buttonLink';
|
||||
import embedWarning from '../../../core/discord/embedWarning';
|
||||
import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable';
|
||||
import { psa } from '../../../core/discord/psa';
|
||||
import { translator } from '../../../core/localization/translator';
|
||||
import { UserError } from '../../../hooks/userError';
|
||||
import { Writeable } from '../../../types/writable';
|
||||
|
||||
export default async function handleChatInputCommand(
|
||||
interaction: ChatInputCommandInteraction<CacheType>,
|
||||
) {
|
||||
const awaitedCommands = await commands;
|
||||
const registry = awaitedCommands[interaction.commandName];
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const returnable = await registry.handler(interaction);
|
||||
|
||||
if (registry.handlesInteraction) return;
|
||||
|
||||
const chunks: InteractionRawReturnable[][] = [[]];
|
||||
|
||||
(Array.isArray(returnable) ? returnable : [returnable]).forEach(
|
||||
(object) => {
|
||||
if (typeof object === 'string') {
|
||||
chunks.push([object]);
|
||||
} else chunks.at(-1)?.push(object!);
|
||||
},
|
||||
);
|
||||
|
||||
let index = 0;
|
||||
for (const chunk of chunks.filter((chunk) => chunk.length > 0)) {
|
||||
const reply = await normalizeInteractionReturnable(chunk);
|
||||
|
||||
if (
|
||||
psa.data &&
|
||||
!psa.data.secret &&
|
||||
(psa.data.commands === undefined ||
|
||||
psa.data.commands.includes(interaction.commandName))
|
||||
) {
|
||||
if (psa.data.type === undefined || psa.data.type === 'embed') {
|
||||
if (!reply.embeds) reply.embeds = [];
|
||||
|
||||
const embed = embedWarning(psa.data.title, psa.data.description);
|
||||
|
||||
if (psa.data.image) embed.setImage(psa.data.image);
|
||||
(reply.embeds as Writeable<typeof reply.embeds>).push(embed);
|
||||
} else if (psa.data.type === 'image') {
|
||||
if (!reply.files) reply.files = [];
|
||||
(reply.files as Writeable<typeof reply.files>).push(
|
||||
new AttachmentBuilder(psa.data.image),
|
||||
);
|
||||
reply.content = reply.content ?? psa.data.title;
|
||||
}
|
||||
|
||||
if (psa.data.links) {
|
||||
if (!reply.components?.[0]) {
|
||||
reply.components = [new ActionRowBuilder<ButtonBuilder>()];
|
||||
}
|
||||
|
||||
(
|
||||
reply.components[0] as ActionRowBuilder<ButtonBuilder>
|
||||
).addComponents(
|
||||
psa.data.links.map(({ title, url }) => buttonLink(url, title)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
await interaction.editReply(reply);
|
||||
} else {
|
||||
await interaction.followUp(reply);
|
||||
}
|
||||
|
||||
if (
|
||||
psa.data &&
|
||||
psa.data.secret &&
|
||||
(psa.data.commands === undefined ||
|
||||
psa.data.commands.includes(interaction.commandName))
|
||||
) {
|
||||
const followUp: InteractionReplyOptions = {
|
||||
ephemeral: true,
|
||||
};
|
||||
|
||||
if (psa.data.type === undefined || psa.data.type === 'embed') {
|
||||
if (!followUp.embeds) followUp.embeds = [];
|
||||
|
||||
const embed = embedWarning(psa.data.title, psa.data.description);
|
||||
|
||||
if (psa.data.image) embed.setImage(psa.data.image);
|
||||
(followUp.embeds as Writeable<typeof followUp.embeds>).push(embed);
|
||||
} else if (psa.data.type === 'image') {
|
||||
if (!followUp.files) followUp.files = [];
|
||||
(followUp.files as Writeable<typeof followUp.files>).push(
|
||||
new AttachmentBuilder(psa.data.image),
|
||||
);
|
||||
followUp.content = followUp.content ?? psa.data.title;
|
||||
}
|
||||
|
||||
if (psa.data.links) {
|
||||
if (!followUp.components?.[0]) {
|
||||
followUp.components = [new ActionRowBuilder<ButtonBuilder>()];
|
||||
}
|
||||
|
||||
(
|
||||
followUp.components[0] as ActionRowBuilder<ButtonBuilder>
|
||||
).addComponents(
|
||||
psa.data.links.map(({ title, url }) => buttonLink(url, title)),
|
||||
);
|
||||
}
|
||||
|
||||
interaction.followUp(followUp);
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
} catch (error) {
|
||||
const { t } = translator(interaction.locale);
|
||||
const components = [
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setLabel(t`bot.common.errors.get_help`)
|
||||
.setURL('https://discord.gg/nDt7AjGJQH')
|
||||
.setStyle(ButtonStyle.Link),
|
||||
),
|
||||
];
|
||||
|
||||
if (error instanceof UserError) {
|
||||
interaction.editReply({
|
||||
content: error.message,
|
||||
components,
|
||||
});
|
||||
} else {
|
||||
console.error(interaction.commandName, error);
|
||||
|
||||
interaction.editReply({
|
||||
content: t`bot.common.errors.uncaught_error`,
|
||||
components,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
AttachmentBuilder,
|
||||
AutocompleteInteraction,
|
||||
ButtonBuilder,
|
||||
ButtonInteraction,
|
||||
CacheType,
|
||||
ChatInputCommandInteraction,
|
||||
EmbedBuilder,
|
||||
Interaction,
|
||||
REST,
|
||||
Routes,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandOptionsOnlyBuilder,
|
||||
SlashCommandSubcommandsOnlyBuilder,
|
||||
} from 'discord.js';
|
||||
import { assertSecret } from '../../../packages/core/src/blitzkit/assertSecret';
|
||||
import { aboutCommand } from '../../commands/about';
|
||||
import { breakdownCommand } from '../../commands/breakdown';
|
||||
import { debugCommand } from '../../commands/debug';
|
||||
import { evolutionCommand } from '../../commands/evolution';
|
||||
import { fullStatsCommand } from '../../commands/fullStats';
|
||||
import { inactiveCommand } from '../../commands/inactive';
|
||||
import { verifyCommand } from '../../commands/link';
|
||||
import { ownedTanksCommand } from '../../commands/ownedTanks';
|
||||
import { permissionsCommand } from '../../commands/permissions';
|
||||
import { pingCommand } from '../../commands/ping';
|
||||
import { playerInfoCommand } from '../../commands/playerInfo';
|
||||
import { ratingCommand } from '../../commands/rating';
|
||||
import { ratingLeaderboardCommand } from '../../commands/ratingLeaderboard';
|
||||
import { replayCommand } from '../../commands/replay';
|
||||
import { researchCommand } from '../../commands/research';
|
||||
import { searchClansCommand } from '../../commands/searchClans';
|
||||
import { searchPlayersCommand } from '../../commands/searchPlayers';
|
||||
import { statsCommand } from '../../commands/stats';
|
||||
import { todayCommand } from '../../commands/today';
|
||||
import { RenderConfiguration } from '../../core/blitzkit/renderConfiguration';
|
||||
import handleAutocomplete from './handlers/autocomplete';
|
||||
import handleButton from './handlers/button';
|
||||
import handleChatInputCommand from './handlers/chatInputCommand';
|
||||
|
||||
export type InteractionRawReturnable =
|
||||
| string
|
||||
| EmbedBuilder
|
||||
| ButtonBuilder
|
||||
| AttachmentBuilder
|
||||
| RenderConfiguration
|
||||
| JSX.Element
|
||||
| null;
|
||||
export type InteractionIterableReturnable =
|
||||
| InteractionRawReturnable
|
||||
| InteractionRawReturnable[];
|
||||
export type InteractionReturnable =
|
||||
| InteractionIterableReturnable
|
||||
| Promise<InteractionIterableReturnable>;
|
||||
|
||||
interface CommandRegistryBase {
|
||||
command:
|
||||
| SlashCommandBuilder
|
||||
| SlashCommandSubcommandsOnlyBuilder
|
||||
| SlashCommandOptionsOnlyBuilder
|
||||
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
||||
|
||||
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
|
||||
button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable;
|
||||
}
|
||||
interface CommandRegistryImplicit extends CommandRegistryBase {
|
||||
handlesInteraction: true;
|
||||
handler: (interaction: ChatInputCommandInteraction<CacheType>) => void;
|
||||
}
|
||||
interface CommandRegistryExplicit extends CommandRegistryBase {
|
||||
handlesInteraction?: false;
|
||||
handler: (
|
||||
interaction: ChatInputCommandInteraction<CacheType>,
|
||||
) => InteractionReturnable;
|
||||
}
|
||||
export type CommandRegistry = CommandRegistryImplicit | CommandRegistryExplicit;
|
||||
|
||||
export const COMMANDS_RAW: Promise<CommandRegistry>[] = [
|
||||
permissionsCommand,
|
||||
debugCommand,
|
||||
aboutCommand,
|
||||
inactiveCommand,
|
||||
ownedTanksCommand,
|
||||
playerInfoCommand,
|
||||
searchClansCommand,
|
||||
searchPlayersCommand,
|
||||
fullStatsCommand,
|
||||
breakdownCommand,
|
||||
verifyCommand,
|
||||
pingCommand,
|
||||
evolutionCommand,
|
||||
statsCommand,
|
||||
ratingLeaderboardCommand,
|
||||
todayCommand,
|
||||
researchCommand,
|
||||
ratingCommand,
|
||||
replayCommand,
|
||||
];
|
||||
|
||||
export const commands = Promise.allSettled(COMMANDS_RAW).then((rawCommands) => {
|
||||
return rawCommands.reduce<Record<string, CommandRegistry>>(
|
||||
(commands, registry, index) => {
|
||||
if (registry.status === 'rejected') {
|
||||
console.warn(
|
||||
`Command ${index} failed to load; skipping...`,
|
||||
registry.reason,
|
||||
);
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
return { ...commands, [registry.value.command.name]: registry.value };
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
const rest = new REST().setToken(assertSecret(process.env.DISCORD_TOKEN));
|
||||
|
||||
commands.then((awaitedCommands) => {
|
||||
const body = Object.values(awaitedCommands).map((registry) =>
|
||||
registry.command.toJSON(),
|
||||
);
|
||||
|
||||
rest.put(
|
||||
Routes.applicationCommands(assertSecret(process.env.DISCORD_CLIENT_ID)),
|
||||
{
|
||||
body,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export default async function interactionCreate(
|
||||
interaction: Interaction<CacheType>,
|
||||
) {
|
||||
if (interaction.isAutocomplete()) {
|
||||
handleAutocomplete(interaction);
|
||||
} else if (interaction.isChatInputCommand()) {
|
||||
handleChatInputCommand(interaction);
|
||||
} else if (interaction.isButton()) {
|
||||
handleButton(interaction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ActivityType, Client } from 'discord.js';
|
||||
|
||||
export default function ready(client: Client<true>) {
|
||||
console.log(`🟢 Launched bot ${client.shard?.ids[0] ?? 'default'}`);
|
||||
|
||||
const activities: (() => string)[] = [
|
||||
() => `Living in ${client.guilds.cache.size.toLocaleString()} servers`,
|
||||
() =>
|
||||
`Serving ${client.guilds.cache
|
||||
.reduce((accumulator, guild) => accumulator + guild.memberCount, 0)
|
||||
.toLocaleString()} users`,
|
||||
() =>
|
||||
`Observing ${client.guilds.cache
|
||||
.reduce(
|
||||
(accumulator, guild) => accumulator + guild.channels.cache.size,
|
||||
0,
|
||||
)
|
||||
.toLocaleString()} channels`,
|
||||
];
|
||||
|
||||
client.user.setPresence({
|
||||
activities: [
|
||||
{
|
||||
type: ActivityType.Custom,
|
||||
name: activities[Math.floor(Math.random() * activities.length)](),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { manager } from './core/discord/manager';
|
||||
|
||||
manager
|
||||
.on('shardCreate', (shard) => {
|
||||
console.log(`🟡 Launching shard ${shard.id}`);
|
||||
shard.on('ready', () => console.log(`🟢 Launched shard ${shard.id}`));
|
||||
})
|
||||
.spawn();
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
amberDark,
|
||||
amberDarkA,
|
||||
blueDark,
|
||||
brownDark,
|
||||
crimsonDark,
|
||||
greenDark,
|
||||
mauveDark,
|
||||
orangeDark,
|
||||
purpleDark,
|
||||
redDark,
|
||||
rubyDark,
|
||||
sandDark,
|
||||
slate,
|
||||
slateDark,
|
||||
slateDarkA,
|
||||
tealDark,
|
||||
} from '@radix-ui/colors';
|
||||
import { createBorderStyles, createColors, createSpaces } from 'bepaint';
|
||||
|
||||
// this isn't really a stitches config, but the object is compliant
|
||||
export const theme = {
|
||||
spaces: createSpaces(),
|
||||
|
||||
colors: {
|
||||
...createColors(slateDark),
|
||||
...createColors(slateDarkA, 'alpha'),
|
||||
...createColors(slate, 'light'),
|
||||
|
||||
...createColors(amberDark, 'amber'),
|
||||
...createColors(amberDarkA, 'amberAlpha'),
|
||||
...createColors(orangeDark, 'orange'),
|
||||
...createColors(purpleDark, 'purple'),
|
||||
...createColors(blueDark, 'blue'),
|
||||
...createColors(tealDark, 'teal'),
|
||||
...createColors(greenDark, 'green'),
|
||||
...createColors(redDark, 'red'),
|
||||
...createColors(brownDark, 'brown'),
|
||||
...createColors(mauveDark, 'mauve'),
|
||||
...createColors(sandDark, 'sand'),
|
||||
...createColors(crimsonDark, 'crimson'),
|
||||
...createColors(rubyDark, 'ruby'),
|
||||
},
|
||||
|
||||
borderStyles: {
|
||||
subtle: `1px solid ${slateDark.slate2}`,
|
||||
...createBorderStyles(slateDark),
|
||||
...createBorderStyles(blueDark, undefined, 'blue'),
|
||||
},
|
||||
|
||||
durations: {
|
||||
regular: '0.2s',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Clan {
|
||||
members_count: number;
|
||||
created_at: number;
|
||||
clan_id: number;
|
||||
tag: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ClanList = Clan[];
|
||||
@@ -0,0 +1,24 @@
|
||||
// a cleaner simplification of Blitz Stars' histories because it's a mess there
|
||||
|
||||
import { BlitzStats } from '../core/statistics/compositeStats/constants';
|
||||
|
||||
export interface History {
|
||||
all: BlitzStats;
|
||||
last_battle_time: number;
|
||||
}
|
||||
|
||||
export type Histories = History[];
|
||||
|
||||
export interface GetHistoriesOptions {
|
||||
start: number;
|
||||
end: number;
|
||||
includeLatestHistories: boolean;
|
||||
includePreviousHistories: boolean;
|
||||
}
|
||||
|
||||
export const getHistoriesDefaultOptions: GetHistoriesOptions = {
|
||||
start: -Infinity,
|
||||
end: Infinity,
|
||||
includeLatestHistories: false,
|
||||
includePreviousHistories: false,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type PossiblyPromise<Type> = Promise<Type> | Type;
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.ttf' {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { assertSecret } from '../../packages/core/src/blitzkit/assertSecret';
|
||||
import { registerProcesses } from '../core/blitzkit/processes';
|
||||
import { client } from '../core/discord/client';
|
||||
|
||||
console.log(`🟡 Launching bot ${client.shard?.ids[0]}`);
|
||||
|
||||
registerProcesses();
|
||||
|
||||
client.login(assertSecret(process.env.DISCORD_TOKEN));
|
||||
@@ -0,0 +1,9 @@
|
||||
import { parentPort, threadId } from 'worker_threads';
|
||||
import svgToPng from '../core/blitzkit/svgToPng';
|
||||
|
||||
console.log(`🟢 Launched render worker ${threadId}`);
|
||||
|
||||
parentPort?.on('message', async (svg: string) => {
|
||||
const png = svgToPng(svg);
|
||||
parentPort?.postMessage(png);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { Tier } from '../core/blitzkit/tankDefinitions';
|
||||
|
||||
export const equipmentPriceMatrix: Record<Tier, Vector3Tuple> = {
|
||||
1: [100, 200, 300],
|
||||
2: [200, 300, 400],
|
||||
3: [400, 500, 600],
|
||||
4: [4000, 5000, 6000],
|
||||
5: [40_000, 50_000, 60_000],
|
||||
6: [100_000, 120_000, 140_000],
|
||||
7: [150_000, 170_000, 190_000],
|
||||
8: [200_000, 225_000, 250_000],
|
||||
9: [250_000, 275_000, 300_000],
|
||||
10: [300_000, 350_000, 400_000],
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
export * from './constants';
|
||||
export * from './equipmentPrice';
|
||||
export * from './idToRegion';
|
||||
export * from './leagues';
|
||||
export * from './nations';
|
||||
export * from './permanentSkills';
|
||||
export * from './rating';
|
||||
export * from './regions';
|
||||
export * from './resolveNearPenetration';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const LEAGUES = [
|
||||
{ name: 'diamond', minScore: 5000, index: 0 },
|
||||
{ name: 'platinum', minScore: 4000, index: 1 },
|
||||
{ name: 'gold', minScore: 3000, index: 2 },
|
||||
{ name: 'silver', minScore: 2000, index: 3 },
|
||||
{ name: 'bronze', minScore: -Infinity, index: 4 },
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
export const NATION_IDS: Record<string, number> = {
|
||||
ussr: 0,
|
||||
germany: 1,
|
||||
usa: 2,
|
||||
china: 3,
|
||||
france: 4,
|
||||
uk: 5,
|
||||
japan: 6,
|
||||
other: 7,
|
||||
european: 8,
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const FIRST_MINIMAL_ARCHIVED_RATING_SEASON = 49;
|
||||
export const FIRST_COMPREHENSIVE1_ARCHIVED_RATING_SEASON = 53;
|
||||
@@ -0,0 +1,22 @@
|
||||
export const REGIONS = ['com', 'eu', 'asia'] as const;
|
||||
|
||||
export type Region = (typeof REGIONS)[number];
|
||||
export type RegionSubdomain = 'na' | 'eu' | 'asia';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const UNLOCALIZED_REGION_NAMES: Record<Region, string> = {
|
||||
com: 'North America',
|
||||
asia: 'Asia',
|
||||
eu: 'Europe',
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const UNLOCALIZED_REGION_NAMES_SHORT: Record<Region, string> = {
|
||||
com: 'NA',
|
||||
asia: 'APAC',
|
||||
eu: 'EU',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './users';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const usersDatabase = new PrismaClient();
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './blitz';
|
||||
export * from './blitzkit';
|
||||
export * from './databases';
|
||||
export * from './imgur';
|
||||
export * from './math';
|
||||
export * from './patreon';
|
||||
@@ -7,4 +8,5 @@ export * from './protobuf';
|
||||
export * from './radix';
|
||||
export * from './statistics';
|
||||
export * from './streams';
|
||||
export * from './types';
|
||||
export * from './zustand';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './tanksStats';
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.proto' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { emptyAllStats } from '@blitzkit/core/src/blitzstars/getStatsInPeriod/constants';
|
||||
import { BlitzStats } from '../core/statistics/compositeStats/constants';
|
||||
|
||||
export interface IndividualTankStats {
|
||||
all: BlitzStats;
|
||||
last_battle_time: number;
|
||||
account_id: number;
|
||||
max_xp: number;
|
||||
in_garage_updated: number;
|
||||
max_frags: number;
|
||||
frags: number | null;
|
||||
mark_of_mastery: number;
|
||||
battle_life_time: number;
|
||||
in_garage: number | null;
|
||||
tank_id: number;
|
||||
}
|
||||
|
||||
export interface TanksStats {
|
||||
[accountId: number]: null | IndividualTankStats[];
|
||||
}
|
||||
|
||||
export interface NormalizedTankStats {
|
||||
[tankId: number]: IndividualTankStats;
|
||||
}
|
||||
|
||||
export const emptyIndividualTankStats: IndividualTankStats = {
|
||||
all: emptyAllStats,
|
||||
last_battle_time: 0,
|
||||
account_id: 0,
|
||||
max_xp: 0,
|
||||
in_garage_updated: 0,
|
||||
max_frags: 0,
|
||||
frags: 0,
|
||||
battle_life_time: 0,
|
||||
in_garage: 0,
|
||||
tank_id: 0,
|
||||
mark_of_mastery: 0,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button, ButtonProps, Tooltip } from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CopyButtonProps extends ButtonProps {
|
||||
copy: () => string | undefined;
|
||||
}
|
||||
|
||||
export function CopyButton({ copy, ...props }: CopyButtonProps) {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip open={tooltipOpen} content="Copied!">
|
||||
<Button
|
||||
{...props}
|
||||
onClick={() => {
|
||||
const content = copy();
|
||||
|
||||
if (content === undefined) return;
|
||||
|
||||
navigator.clipboard.writeText(content);
|
||||
setTooltipOpen(true);
|
||||
setTimeout(() => setTooltipOpen(false), 2000);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
ReloadIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
ChevronDownIcon,
|
||||
Flex,
|
||||
Grid,
|
||||
IconButton,
|
||||
Link,
|
||||
Popover,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { useState } from 'react';
|
||||
|
||||
const DAY_TITLES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const YEAR_OPTIONS = 7;
|
||||
|
||||
interface DatePickerProps {
|
||||
onDateChange?: (date: Date) => void;
|
||||
defaultDate?: Date;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
onDateChange,
|
||||
defaultDate = new Date(),
|
||||
}: DatePickerProps) {
|
||||
const [year, setYear] = useState(defaultDate.getFullYear());
|
||||
const [month, setMonth] = useState(defaultDate.getMonth());
|
||||
const [day, setDay] = useState(defaultDate.getDate());
|
||||
const [yearScroll, setYearScroll] = useState(0);
|
||||
const [yearOffset, setYearOffset] = useState(0);
|
||||
const [monthOffset, setMonthOffset] = useState(0);
|
||||
const date = new Date(year + yearOffset, month + monthOffset, day);
|
||||
|
||||
const daysInLastMonth = new Date(
|
||||
year + yearOffset,
|
||||
month + monthOffset,
|
||||
0,
|
||||
).getDate();
|
||||
const daysInMonth = new Date(
|
||||
year + yearOffset,
|
||||
month + monthOffset + 1,
|
||||
0,
|
||||
).getDate();
|
||||
const firstDayOfMonth = new Date(
|
||||
year + yearOffset,
|
||||
month + monthOffset,
|
||||
1,
|
||||
).getDay();
|
||||
const lastDayOfMonth = new Date(
|
||||
year + yearOffset,
|
||||
month + monthOffset,
|
||||
daysInMonth,
|
||||
).getDay();
|
||||
|
||||
return (
|
||||
<Flex direction="column" width="fit-content">
|
||||
<Flex justify="between" mb="2" align="center" py="2">
|
||||
<IconButton
|
||||
size="3"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (monthOffset === 0) {
|
||||
setYearOffset((state) => state - 1);
|
||||
setMonthOffset(11);
|
||||
} else {
|
||||
setMonthOffset((state) => state - 1);
|
||||
}
|
||||
}}
|
||||
ml="2"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
|
||||
<Flex align="center" gap="2">
|
||||
<IconButton
|
||||
size="3"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setYear(defaultDate.getFullYear());
|
||||
setMonth(defaultDate.getMonth());
|
||||
setDay(defaultDate.getDate());
|
||||
setYearOffset(0);
|
||||
setMonthOffset(0);
|
||||
}}
|
||||
>
|
||||
<ReloadIcon />
|
||||
</IconButton>
|
||||
|
||||
<Flex gap="1">
|
||||
<Text>
|
||||
{date.toLocaleString(undefined, {
|
||||
month: 'long',
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Popover.Root onOpenChange={() => setYearScroll(0)}>
|
||||
<Popover.Trigger>
|
||||
<Link underline="always" href="#">
|
||||
{date.getFullYear()}
|
||||
</Link>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content side="right" align="center">
|
||||
<Flex direction="column" gap="1">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => setYearScroll((state) => state + 1)}
|
||||
mb="2"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</IconButton>
|
||||
|
||||
{times(YEAR_OPTIONS, (index) => {
|
||||
const thisYear =
|
||||
year +
|
||||
yearOffset -
|
||||
index +
|
||||
Math.floor(YEAR_OPTIONS / 2) +
|
||||
yearScroll;
|
||||
const selected = year + yearOffset === thisYear;
|
||||
|
||||
return (
|
||||
<Popover.Close key={thisYear}>
|
||||
<Button
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="large"
|
||||
onClick={() => {
|
||||
setYearOffset(thisYear - year);
|
||||
}}
|
||||
>
|
||||
{thisYear}
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
);
|
||||
})}
|
||||
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => setYearScroll((state) => state - 1)}
|
||||
mt="2"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<IconButton
|
||||
size="3"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (monthOffset === 11) {
|
||||
setYearOffset((state) => state + 1);
|
||||
setMonthOffset(0);
|
||||
} else {
|
||||
setMonthOffset((state) => state + 1);
|
||||
}
|
||||
}}
|
||||
mr="2"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
|
||||
<Flex mb="1">
|
||||
{times(7, (index) => (
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
size="2"
|
||||
align="center"
|
||||
key={index}
|
||||
color="gray"
|
||||
>
|
||||
{DAY_TITLES[index]}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Grid columns="7" gap="1" width="fit-content">
|
||||
{times(firstDayOfMonth, (index) => (
|
||||
<IconButton radius="large" key={index} disabled>
|
||||
{daysInLastMonth - firstDayOfMonth + index + 1}
|
||||
</IconButton>
|
||||
))}
|
||||
|
||||
{times(daysInMonth, (index) => {
|
||||
const selected =
|
||||
index === day - 1 && monthOffset === 0 && yearOffset === 0;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
radius="large"
|
||||
key={index}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
onClick={() => {
|
||||
const newYear =
|
||||
year + yearOffset + Math.floor((month + monthOffset) / 12);
|
||||
const newMonth = (month + monthOffset) % 12;
|
||||
const newDay = index + 1;
|
||||
|
||||
setYear(newYear);
|
||||
setMonth(newMonth);
|
||||
setDay(newDay);
|
||||
setYearOffset(0);
|
||||
setMonthOffset(0);
|
||||
|
||||
onDateChange?.(new Date(newYear, newMonth, newDay));
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
|
||||
{times(6 - lastDayOfMonth, (index) => (
|
||||
<IconButton radius="large" key={index} disabled>
|
||||
{index + 1}
|
||||
</IconButton>
|
||||
))}
|
||||
</Grid>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Link, LinkProps } from '@radix-ui/themes';
|
||||
|
||||
export function DummyLink({ href = '#', onClick, ...props }: LinkProps) {
|
||||
return (
|
||||
<Link href={href} onClick={(event) => event.preventDefault()} {...props} />
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { assertSecret } from '@blitzkit/core';
|
||||
import strings from '@blitzkit/core/lang/en-US.json';
|
||||
import { REGIONS } from '@blitzkit/core/src/blitzkit/regions';
|
||||
import { Button, ButtonProps, Dialog, Flex } from '@radix-ui/themes';
|
||||
import { REGIONS } from '../../../../../../src/constants/regions';
|
||||
import { Link } from '../../Link';
|
||||
import { WargamingIcon } from '../../WargamingIcon';
|
||||
|
||||
@@ -29,14 +29,10 @@ export function WargamingLoginButton({ children, ...props }: ButtonProps) {
|
||||
<Link
|
||||
href={
|
||||
typeof window !== 'undefined'
|
||||
? `https://api.worldoftanks.${
|
||||
region
|
||||
}/wot/auth/login/?application_id=${assertSecret(
|
||||
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${assertSecret(
|
||||
process.env.WARGAMING_APPLICATION_ID,
|
||||
)}&redirect_uri=${encodeURIComponent(
|
||||
`${location.origin}/auth/wargaming?return=${
|
||||
location.origin
|
||||
}${location.pathname}`,
|
||||
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { slateDarkA } from '@radix-ui/colors';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const animation = keyframes({
|
||||
'0%': { backgroundColor: slateDarkA.slateA2 },
|
||||
'50%': { backgroundColor: slateDarkA.slateA3 },
|
||||
'100%': { backgroundColor: slateDarkA.slateA2 },
|
||||
});
|
||||
|
||||
export const skeleton = style({
|
||||
borderRadius: 8,
|
||||
animation: `${animation} 2s ease-in-out infinite`,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { skeleton } from './index.css';
|
||||
|
||||
export function Skeleton({ className, ...props }: ComponentProps<'div'>) {
|
||||
return <div {...props} className={`${className} ${skeleton}`} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { asset } from '../core/blitzkit/asset';
|
||||
|
||||
interface SmallTankIconProps {
|
||||
id: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const WIDTH = 70;
|
||||
const HEIGHT = 24;
|
||||
|
||||
export function SmallTankIcon({ id, size = HEIGHT }: SmallTankIconProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: size,
|
||||
overflow: 'hidden',
|
||||
width: WIDTH * (size / HEIGHT),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={`Tank ${id}`}
|
||||
src={asset(`icons/tanks/small/${id}.webp`)}
|
||||
style={{
|
||||
transform: `scale(${(size / HEIGHT) * 100}%)`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Flex, FlexProps, Text } from '@radix-ui/themes';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type SpeedometerProps = FlexProps & {
|
||||
value: ReactNode;
|
||||
color: string;
|
||||
fill: number;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export function Speedometer({
|
||||
value,
|
||||
color,
|
||||
fill,
|
||||
label,
|
||||
...props
|
||||
}: SpeedometerProps) {
|
||||
return (
|
||||
<Flex direction="column" align="center" {...props}>
|
||||
<svg
|
||||
width={180}
|
||||
height={98}
|
||||
style={{
|
||||
transform: 'scaleX(-1)',
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx={90}
|
||||
cy={90}
|
||||
r={80}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke={`var(--${color}-3)`}
|
||||
strokeWidth={16}
|
||||
strokeDasharray={80 * Math.PI}
|
||||
strokeDashoffset={80 * Math.PI}
|
||||
/>
|
||||
<circle
|
||||
cx={90}
|
||||
cy={90}
|
||||
r={80}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke={`var(--${color}-indicator)`}
|
||||
strokeWidth={16}
|
||||
strokeDasharray={(160 - 80 * fill) * Math.PI}
|
||||
strokeDashoffset={(160 - 80 * fill) * Math.PI}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Flex direction="column" align="center" mt="-8">
|
||||
<Text weight="bold" size="8" mt="-1">
|
||||
{value}
|
||||
</Text>
|
||||
|
||||
{label}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import { ColumnHeaderCellProps } from '@radix-ui/themes/dist/cjs/components/table';
|
||||
|
||||
interface StickyColumnHeaderCellProps extends ColumnHeaderCellProps {
|
||||
top?: number | string;
|
||||
}
|
||||
|
||||
export function StickyColumnHeaderCell({
|
||||
style,
|
||||
top = 0,
|
||||
...props
|
||||
}: StickyColumnHeaderCellProps) {
|
||||
return (
|
||||
<Table.ColumnHeaderCell
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top,
|
||||
background: 'var(--gray-3)',
|
||||
zIndex: 1,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user