mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
remove bot
This commit is contained in:
1 parent
50d21918b4
commit
514999e588
160 files changed
+1
-9068
No files matched your search
@@ -27,7 +27,6 @@ target
|
||||
.astro
|
||||
.vercel
|
||||
/src
|
||||
/packages/bot/src/prisma
|
||||
dev-dist
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -9,9 +9,8 @@ If you intend to contribute to the code, note that you may need to check out sen
|
||||
## Packages
|
||||
|
||||
| Package | Title | Source | Memo |
|
||||
| ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [packages/app](packages/app) | BlitzKit | [Play Store](https://play.google.com/store/apps/details?id=com.tresabhi.blitzkit), [App Store](https://apps.apple.com/app/4321), [Microsoft Store](https://apps.microsoft.com/detail/zyxw) | The native app of BlitzKit for all platforms. Unavailable from stores at this time. |
|
||||
| [packages/bot](packages/bot) | BlitzKit Discord Bot | [Discord Application Directory](https://discord.com/discovery/applications/1097673957865443370) | The one-in-all Discord bot for statistics. |
|
||||
| [packages/core](packages/core) | | | Mutual internal code shared between all other packages. |
|
||||
| [packages/i18n](packages/i18n) | | [Crowdin](https://crowdin.com/project/blitzkrieg) | Translations served as JSON shared across all other packages, including some utility code. |
|
||||
| [packages/scripts](packages/scripts) | | | CLI utilities for the development and deployment of BlitzKit services. |
|
||||
@@ -40,8 +39,6 @@ If you intend to contribute to the code, note that you may need to check out sen
|
||||
| `PUBLIC_BRANCH` | | `main` | The branch that the website is operating on. This is completely nominal. |
|
||||
| `PUBLIC_GUIDE_WEBSITE` | [GuidesBlitz](https://guidesblitz.com/) | `https://guidesblitz.com` | The website that serves the encyclopedia-like content in BlitzKit. |
|
||||
| `SERVICING` | | `false` | Wether the website is down for servicing. |
|
||||
| `DISCORD_CLIENT_ID` | [Discord Developer Portal](https://discord.com/developers/applications) | `1097673957865443370` | The BlitzKit Discord Bot's user ID. |
|
||||
| `GH_TOKEN` | [GitHub Fine-Grained Tokens](https://github.com/settings/personal-access-tokens) | | The GitHub API key used for checking out private submodules and pushing content to the asset repository. |
|
||||
| `DISCORD_TOKEN` | [Discord Developer Portal](https://discord.com/developers/applications) | | The Discord API Key for the BlitzKit Discord Bot. |
|
||||
| `DATABASE_URL` | [CockroachDB](https://www.cockroachlabs.com/) | | The CockroachDB-SQL database URL used to link Wargaming and Discord users. |
|
||||
| `WOTB_DLC_CDN` | | | |
|
||||
@@ -2,17 +2,6 @@
|
||||
|
||||
🎉 Services for everything and anything World of Tanks Blitz
|
||||
|
||||
## Bot
|
||||
|
||||
BlitzKit is available as a Discord bot and can be [installed very easily](/guide/invite).
|
||||
|
||||
- Track statistics
|
||||
- Find inactive players in a clan
|
||||
- Lookup owned tanks
|
||||
- Create embeds for streams
|
||||
- Track a player/tanks statistical evolution
|
||||
- ...and a variety of other similar things
|
||||
|
||||
## Website
|
||||
|
||||
Provides a bunch of very helpful tools; https://blitzkit.app/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Inviting the Bot
|
||||
|
||||
Go to [application directory for BlitzKit](https://discord.com/application-directory/1097673957865443370) and click "Add to Server." Voila! You're ready to go.
|
||||
@@ -6,15 +6,12 @@
|
||||
"scripts": {
|
||||
"build:assets": "cd packages/scripts && bun run build:assets",
|
||||
"build:averages": "bun --filter=@blitzkit/scripts run build:averages",
|
||||
"build:bot": "cd packages/scripts && bun run build:bot",
|
||||
"build:website": "cd packages/website && bun run build",
|
||||
"build:prisma": "prisma generate",
|
||||
"build:proxyclient": "cd packages/scripts && bun run build:proxyclient",
|
||||
"build:catalogitemaccesssor": "cd packages/scripts && bun run build:catalogitemaccesssor",
|
||||
"build:ratingleaderboard": "cd packages/scripts && bun run build:ratingleaderboard",
|
||||
"build:app": "cd packages/app && bun run build",
|
||||
"dev:bot": "bun run build:bot --dev && bun start:bot --dev",
|
||||
"start:bot": "bun dist/bot/main.js",
|
||||
"dev:docs": "vitepress dev docs",
|
||||
"dev:website": "cd packages/website && bun run dev",
|
||||
"dev:app": "cd packages/app && bun run dev",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@blitzkit/bot",
|
||||
"dependencies": {
|
||||
"@blitzkit/core": "workspace:*",
|
||||
"@blitzkit/i18n": "workspace:*",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-escape": "^1.1.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bepaint": "^0.1.0-beta.3",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"markdown-escape": "^2.0.0",
|
||||
"pickleparser": "^0.2.1",
|
||||
"prisma": "^6.19.2",
|
||||
"protobufjs": "^7.5.4",
|
||||
"react": "^19.2.4",
|
||||
"satori": "^0.18.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/main.ts",
|
||||
"start": "bun src/main.ts"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "src/types.d.ts"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import {
|
||||
commandsArray,
|
||||
type CommandRegistry,
|
||||
} from "../events/interactionCreate";
|
||||
|
||||
const subcommands = [
|
||||
"embeds",
|
||||
"introduction",
|
||||
"invite",
|
||||
"timezones",
|
||||
"credits",
|
||||
] as const;
|
||||
type Subcommand = (typeof subcommands)[number];
|
||||
|
||||
const DOCS: Record<Subcommand, string> = {
|
||||
embeds: "guide/embeds",
|
||||
introduction: "guide/introduction",
|
||||
invite: "guide/invite",
|
||||
timezones: "guide/timezones",
|
||||
credits: "about/credits",
|
||||
};
|
||||
|
||||
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 { strings } = translator(interaction.locale);
|
||||
|
||||
if (subcommand === "commands") {
|
||||
return `${strings.bot.commands.about.subcommands.commands.body}${(
|
||||
await Promise.all(commandsArray)
|
||||
)
|
||||
.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;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
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 { createLocalizedCommand } from "../../core/discord/createLocalizedCommand";
|
||||
import { getFiltersFromButton } from "../../core/discord/getFiltersFromButton";
|
||||
import { resolvePeriodFromButton } from "../../core/discord/resolvePeriodFromButton";
|
||||
import { resolvePlayerFromButton } from "../../core/discord/resolvePlayerFromButton";
|
||||
import { translator } from "../../core/localization/translator";
|
||||
import type { 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 { strings } = translator(interaction.locale);
|
||||
return strings.bot.commands.dead;
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,295 +0,0 @@
|
||||
import {
|
||||
type BlitzStats,
|
||||
calculateWN8,
|
||||
getAccountInfo,
|
||||
getClanAccountInfo,
|
||||
getTankStats,
|
||||
getWN8Percentile,
|
||||
TankDefinition,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { Locale } from "discord.js";
|
||||
import { chunk } from "lodash-es";
|
||||
import * as Breakdown from "../../components/Breakdown";
|
||||
import { CommandWrapper } from "../../components/CommandWrapper";
|
||||
import { NoData } from "../../components/NoData";
|
||||
import { TitleBar } from "../../components/TitleBar";
|
||||
import { filtersToDescription } from "../../core/blitzkit/filtersToDescription";
|
||||
import {
|
||||
averageDefinitions,
|
||||
tankDefinitions,
|
||||
} from "../../core/blitzkit/nonBlockingPromises";
|
||||
import { UserError } from "../../core/blitzkit/userError";
|
||||
import {
|
||||
filterStats,
|
||||
type StatFilters,
|
||||
} from "../../core/blitzstars/filterStats";
|
||||
import { getStatsInPeriod } from "../../core/blitzstars/getStatsInPeriod";
|
||||
import type { ResolvedPeriod } from "../../core/discord/resolvePeriodFromCommand";
|
||||
import type { ResolvedPlayer } from "../../core/discord/resolvePlayerFromCommand";
|
||||
import { translator } from "../../core/localization/translator";
|
||||
|
||||
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 { strings, unwrap } = translator(locale);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await averageDefinitions;
|
||||
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(strings.bot.common.errors.no_tank_stats);
|
||||
}
|
||||
|
||||
filteredOrder.forEach((id) => {
|
||||
const tankAveragesEntry = awaitedTankAverages.averages[id];
|
||||
const current = statsInPeriod.diff[id];
|
||||
const career = tankStats.find((tank) => tank.tank_id === id)!.all;
|
||||
const currentWN8 = tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.mu, current)
|
||||
: undefined;
|
||||
const careerWN8 = tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.mu, career)
|
||||
: undefined;
|
||||
|
||||
orderedCurrentStats.push(current);
|
||||
orderedCareerStats.push(career);
|
||||
orderedCurrentWN8.push(currentWN8);
|
||||
orderedCareerWN8.push(careerWN8);
|
||||
});
|
||||
tankStats.forEach((tank) => {
|
||||
const tankAveragesEntry = awaitedTankAverages.averages[tank.tank_id];
|
||||
|
||||
orderedCareerWN8Full.push(
|
||||
tankAveragesEntry
|
||||
? calculateWN8(tankAveragesEntry.mu, 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: React.JSX.Element[] = [];
|
||||
|
||||
if (filteredOrder.length > 0) {
|
||||
children.push(
|
||||
<Breakdown.Row
|
||||
key={id}
|
||||
displayType="summary"
|
||||
title={strings.bot.commands.breakdown.body.total}
|
||||
stats={[
|
||||
{
|
||||
title: strings.bot.commands.breakdown.body.battles,
|
||||
current: currentBattles,
|
||||
career: careerBattles,
|
||||
},
|
||||
{
|
||||
title: strings.bot.commands.breakdown.body.winrate,
|
||||
current: `${(100 * currentWinrate).toFixed(2)}%`,
|
||||
career: `${(100 * careerWinrate).toFixed(2)}%`,
|
||||
delta: currentWinrate - careerWinrate,
|
||||
},
|
||||
{
|
||||
title: strings.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: strings.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.tanks[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}
|
||||
displayType="tank"
|
||||
class={tankDefinition?.class}
|
||||
type={tankDefinition?.type}
|
||||
title={
|
||||
tankDefinition
|
||||
? unwrap(tankDefinition.name)
|
||||
: literals(strings.bot.commands.breakdown.body.unknown_tank, {
|
||||
id,
|
||||
})
|
||||
}
|
||||
stats={[
|
||||
{
|
||||
title: strings.bot.commands.breakdown.body.battles,
|
||||
current: current.battles.toLocaleString(),
|
||||
career: career.battles.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: strings.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: strings.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: strings.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>,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import packageJSON from "../../../../package.json";
|
||||
import { client } from "../core/discord/client";
|
||||
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import type { CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
const executionStart = new Date().getTime();
|
||||
|
||||
export const debugCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand("debug"),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings } = translator(interaction.locale);
|
||||
const currentTime = new Date().getTime();
|
||||
const uptime = currentTime - executionStart;
|
||||
const list = [
|
||||
[strings.bot.commands.debug.body.version, packageJSON.version],
|
||||
[
|
||||
strings.bot.commands.debug.body.shard.label,
|
||||
client.shard?.ids[0] ?? strings.bot.commands.debug.body.shard.default,
|
||||
],
|
||||
[
|
||||
strings.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 `${strings.bot.commands.debug.body.title}${list
|
||||
.map(([key, value]) => `- ${key}: ${value}`)
|
||||
.join("\n")}`;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { emblemURL, getAccountInfo, getClanAccountInfo } from "@blitzkit/core";
|
||||
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 { getPlayerHistories } from "../core/blitzstars/getPlayerHistories";
|
||||
import { getTankHistories } from "../core/blitzstars/getTankHistories";
|
||||
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
|
||||
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
|
||||
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
|
||||
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import type { 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 { strings } = translator(interaction.locale);
|
||||
return strings.bot.commands.dead;
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
emblemURL,
|
||||
getAccountInfo,
|
||||
getClanAccountInfo,
|
||||
getTierWeights,
|
||||
} from "@blitzkit/core";
|
||||
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 { filtersToDescription } from "../core/blitzkit/filtersToDescription";
|
||||
import { filterStats, type StatFilters } from "../core/blitzstars/filterStats";
|
||||
import { getStatsInPeriod } from "../core/blitzstars/getStatsInPeriod";
|
||||
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
|
||||
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
|
||||
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
|
||||
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
|
||||
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import type { 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 { strings } = translator(interaction.locale);
|
||||
return strings.bot.commands.dead;
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,96 +0,0 @@
|
||||
import { getAccountInfo, getClanInfo } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
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 { 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 type { CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
const DEFAULT_THRESHOLD = 7;
|
||||
|
||||
export const inactiveCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand("inactive")
|
||||
.addStringOption(addClanChoices)
|
||||
.addNumberOption((option) => {
|
||||
const { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName("threshold")
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) => strings.bot.commands.inactive.options.threshold.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
strings.bot.commands.inactive.options.threshold.description
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.inactive.options.threshold.description
|
||||
)
|
||||
)
|
||||
.setMinValue(0);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings } = 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,
|
||||
literals(strings.bot.commands.inactive.body.listing, {
|
||||
days: 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={`${literals(
|
||||
strings.bot.commands.inactive.body.subtitle,
|
||||
{ days: threshold }
|
||||
)} • ${new Date().toLocaleDateString(interaction.locale)}`}
|
||||
/>
|
||||
|
||||
{!hasInactiveMembers && (
|
||||
<NoData type="players_in_period" locale={interaction.locale} />
|
||||
)}
|
||||
{hasInactiveMembers && <GenericStats stats={inactive} />}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteClan,
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { getAccountInfo, getClanAccountInfo } from '@blitzkit/core';
|
||||
import { teal } from '@radix-ui/colors';
|
||||
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
|
||||
import { CommandWrapper } from '../components/CommandWrapper';
|
||||
import { linkBlitzAndDiscord } from '../core/db/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 type { 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 { strings } = 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 }}>
|
||||
{strings.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}] • `
|
||||
: ''}
|
||||
{strings.common.regions.normal[region]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Glow color={teal.teal9} rotation={-90} />
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
|
||||
embedInfo(
|
||||
strings.bot.commands.link.embed.title,
|
||||
strings.bot.commands.link.embed.description,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
TANK_CLASSES,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
TankDefinition,
|
||||
flags,
|
||||
getAccountInfo,
|
||||
getTankStats,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import markdownEscape from "markdown-escape";
|
||||
import {
|
||||
gameDefinitions,
|
||||
tankDefinitions,
|
||||
} from "../core/blitzkit/nonBlockingPromises";
|
||||
import { addTierChoices } from "../core/discord/addTierChoices";
|
||||
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
|
||||
import { autocompleteUsername } from "../core/discord/autocompleteUsername";
|
||||
import { chunkLines } from "../core/discord/chunkLines";
|
||||
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { resolvePlayerFromCommand } from "../core/discord/resolvePlayerFromCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import { type CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
command: createLocalizedCommand("owned-tanks")
|
||||
.addStringOption(addTierChoices)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings, unwrap } = 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 strings.bot.common.errors.no_tank_stats;
|
||||
}
|
||||
|
||||
const filteredTanks = (
|
||||
await Promise.all(
|
||||
tankStats.map(async (tankData) => ({
|
||||
tankDefinitions: (await tankDefinitions).tanks[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 title = literals(strings.bot.commands.owned_tanks.body.title, {
|
||||
name: markdownEscape(accountInfo.nickname),
|
||||
tier: TIER_ROMAN_NUMERALS[tier],
|
||||
});
|
||||
const tankList = tankStats
|
||||
.map(({ tank_id }) => awaitedTankDefinitions.tanks[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(unwrap(tank.name!))}](<https://blitzkit.app/tanks/${tank.slug}>)`,
|
||||
);
|
||||
const lines = [title, ...tankList];
|
||||
|
||||
return chunkLines(lines);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
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 { strings } = 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"),
|
||||
strings.bot.commands.permissions.body.view_channels,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has("ReadMessageHistory"),
|
||||
strings.bot.commands.permissions.body.read_message_history,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has("AttachFiles"),
|
||||
strings.bot.commands.permissions.body.attach_files,
|
||||
],
|
||||
[
|
||||
interaction.appPermissions?.has("UseExternalEmojis"),
|
||||
strings.bot.commands.permissions.body.external_emojis,
|
||||
],
|
||||
];
|
||||
const body = permissions
|
||||
.map(([hasPermission, description]) => {
|
||||
if (hasPermission) {
|
||||
return `${isFridayThe13th ? "😇" : "✅"} ${description}`;
|
||||
} else {
|
||||
return `${isFridayThe13th ? "💀" : "❌"} ${description}`;
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return literals(strings.bot.commands.permissions.body.title, { body });
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import type { 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 { strings } = translator(interaction.locale);
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const executionStart = Date.now();
|
||||
|
||||
if (subcommand === "blitzkit") {
|
||||
await interaction.editReply(strings.bot.commands.ping.body.pong);
|
||||
} else {
|
||||
await fetch("https://api.wotblitz.com/");
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - executionStart;
|
||||
interaction.editReply(
|
||||
literals(strings.bot.commands.ping.body.pong_with_time, {
|
||||
time: executionTime.toFixed(0),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { getAccountInfo } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import markdownEscape from "markdown-escape";
|
||||
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 { strings } = translator(interaction.locale);
|
||||
const account = await resolvePlayerFromCommand(interaction);
|
||||
const { id, region: region } = account;
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
|
||||
return embedInfo(
|
||||
literals(strings.bot.commands.player_info.body.title, {
|
||||
name: markdownEscape(accountInfo.nickname),
|
||||
}),
|
||||
|
||||
markdownTable([
|
||||
[
|
||||
strings.bot.commands.player_info.body.nickname,
|
||||
`${accountInfo.nickname}`,
|
||||
],
|
||||
[
|
||||
strings.bot.commands.player_info.body.battles,
|
||||
`${accountInfo.statistics.all.battles}`,
|
||||
],
|
||||
[
|
||||
strings.bot.commands.player_info.body.winrate,
|
||||
`${(
|
||||
100 *
|
||||
(accountInfo.statistics.all.wins /
|
||||
accountInfo.statistics.all.battles)
|
||||
).toFixed(2)}%`,
|
||||
],
|
||||
[],
|
||||
[
|
||||
strings.bot.commands.player_info.body.account_id,
|
||||
`${accountInfo.account_id}`,
|
||||
],
|
||||
[
|
||||
strings.bot.commands.player_info.body.created,
|
||||
new Date(accountInfo.created_at * 1000).toLocaleDateString(
|
||||
interaction.locale
|
||||
),
|
||||
],
|
||||
[
|
||||
strings.bot.commands.player_info.body.last_battle,
|
||||
new Date(accountInfo.last_battle_time * 1000).toLocaleDateString(
|
||||
interaction.locale
|
||||
),
|
||||
],
|
||||
])
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
});
|
||||
@@ -1,535 +0,0 @@
|
||||
import {
|
||||
deltaBkrlBlitzStats,
|
||||
emblemURL,
|
||||
getAccountInfo,
|
||||
getArchivedRatingLeaderboard,
|
||||
getClanAccountInfo,
|
||||
getLeagueFromScore,
|
||||
getRatingInfo,
|
||||
getRatingNeighbors,
|
||||
imgur,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
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 { normalizeLeagueIcon } from "../core/blitz/normalizeLeagueIcon";
|
||||
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 type { 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 { strings } = 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 getArchivedRatingLeaderboard(
|
||||
region,
|
||||
ratingInfo.current_season - (subcommand === "today" ? 0 : 1)
|
||||
);
|
||||
|
||||
if (leaderboard.version!.$case === "v1") {
|
||||
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.version!.value.entries.findIndex(
|
||||
(entry) => entry.id === id
|
||||
);
|
||||
const statsA = leaderboard
|
||||
? leaderboard.version!.value.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 literals(strings.bot.commands.rating.errors.no_participation, {
|
||||
name: markdownEscape(accountInfo.nickname),
|
||||
});
|
||||
}
|
||||
|
||||
const [statsB2] = statsB2Array;
|
||||
const league = getLeagueFromScore(statsB2.score);
|
||||
|
||||
if (!statsA) {
|
||||
return strings.bot.commands.rating.errors.unrecorded_stats;
|
||||
}
|
||||
if (!statsB1) {
|
||||
return literals(strings.bot.commands.rating.errors.no_participation, {
|
||||
name: 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];
|
||||
let rewardImage = reward
|
||||
? reward.type === "vehicle"
|
||||
? reward.vehicle.preview_image_url
|
||||
: reward.stuff.image_url
|
||||
: undefined;
|
||||
|
||||
if (rewardImage) {
|
||||
const { ok } = await fetch(rewardImage);
|
||||
|
||||
if (!ok) {
|
||||
rewardImage = imgur("kP88vqr");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={accountInfo.nickname}
|
||||
image={clanImage}
|
||||
description={`${
|
||||
strings.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(rewardImage!)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,468 +0,0 @@
|
||||
import {
|
||||
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
|
||||
getAccountInfo,
|
||||
getArchivedLatestSeasonNumber,
|
||||
getArchivedRatingLeaderboard,
|
||||
getClanAccountInfo,
|
||||
getRatingInfo,
|
||||
getRatingLeague,
|
||||
getRatingNeighbors,
|
||||
imgur,
|
||||
isOnGoingRatingSeason,
|
||||
LEAGUES,
|
||||
type RatingInfo,
|
||||
RatingLeaderboard,
|
||||
type Region,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import {
|
||||
type APIApplicationCommandOptionChoice,
|
||||
Locale,
|
||||
SlashCommandSubcommandBuilder,
|
||||
} from "discord.js";
|
||||
import { range } from "lodash-es";
|
||||
import markdownEscape from "markdown-escape";
|
||||
import { CommandWrapper } from "../components/CommandWrapper";
|
||||
import * as Leaderboard from "../components/Leaderboard";
|
||||
import { TitleBar } from "../components/TitleBar";
|
||||
import { UserError } from "../core/blitzkit/userError";
|
||||
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 type { CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
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
|
||||
).slice(-25);
|
||||
|
||||
function addOptions(option: SlashCommandSubcommandBuilder) {
|
||||
const { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(strings.bot.commands.rating_leaderboard.options.limit.name)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.limit.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
literals(
|
||||
strings.bot.commands.rating_leaderboard.options.limit
|
||||
.description,
|
||||
{ count: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.limit
|
||||
.description,
|
||||
{ count: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setRequired(false)
|
||||
.setMinValue(5)
|
||||
.setMaxValue(30)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(
|
||||
strings.bot.commands.rating_leaderboard.options.season.name
|
||||
)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.season.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
strings.bot.commands.rating_leaderboard.options.season.description
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.season
|
||||
.description
|
||||
)
|
||||
)
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
...seasonNumbers
|
||||
.map((number, index) => {
|
||||
const current =
|
||||
index === seasonNumbers.length - 1 && onGoingSeason;
|
||||
|
||||
return {
|
||||
name: current
|
||||
? literals(
|
||||
strings.bot.commands.rating_leaderboard.options.season
|
||||
.choices.current,
|
||||
{ season: number }
|
||||
)
|
||||
: `${number}`,
|
||||
value: `${number}`,
|
||||
name_localizations: current
|
||||
? localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options
|
||||
.season.choices.current,
|
||||
{ season: 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 { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName(
|
||||
strings.bot.commands.rating_leaderboard.options.league.name
|
||||
)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.league
|
||||
.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
strings.bot.commands.rating_leaderboard.options.league
|
||||
.description
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.rating_leaderboard.options.league
|
||||
.description
|
||||
)
|
||||
)
|
||||
.addChoices(
|
||||
...LEAGUES.map(
|
||||
({ name }, value) =>
|
||||
({
|
||||
name: (
|
||||
strings.common.leagues as Record<string, string>
|
||||
)[name],
|
||||
value: `${value}`,
|
||||
name_localizations: localizationObject(
|
||||
(strings) =>
|
||||
(
|
||||
strings.common.leagues as Record<string, string>
|
||||
)[name]
|
||||
),
|
||||
} satisfies APIApplicationCommandOptionChoice<string>)
|
||||
)
|
||||
)
|
||||
.setRequired(true);
|
||||
})
|
||||
.addStringOption(addRegionChoices);
|
||||
addOptions(option);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
// BIG TODO: title seems to be in english
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings } = 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 archivedLeaderboard: RatingLeaderboard | null;
|
||||
let playerId: number | undefined;
|
||||
let regionRatingInfo: RatingInfo | null;
|
||||
|
||||
if (subcommand === "league") {
|
||||
const leagueIndex = parseInt(
|
||||
interaction.options.getString("league")!
|
||||
);
|
||||
const region = interaction.options.getString("region") as Region;
|
||||
regionRatingInfo = await (isCurrentSeason
|
||||
? getRatingInfo(region)
|
||||
: null);
|
||||
|
||||
if (
|
||||
regionRatingInfo !== null &&
|
||||
regionRatingInfo.detail !== undefined
|
||||
) {
|
||||
return strings.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.version!.value.entries.findIndex(
|
||||
(player) =>
|
||||
player.score < LEAGUES[leagueIndex - 1].minScore
|
||||
);
|
||||
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
||||
const trimmed = players.version!.value.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 ??
|
||||
literals(
|
||||
strings.bot.commands.rating_leaderboard.body
|
||||
.deleted_player,
|
||||
{ id: player.id }
|
||||
),
|
||||
} satisfies SimplifiedPlayer)
|
||||
);
|
||||
}
|
||||
);
|
||||
const leagueInfo = LEAGUES[leagueIndex];
|
||||
archivedLeaderboard = await getArchivedRatingLeaderboard(
|
||||
region,
|
||||
season
|
||||
);
|
||||
|
||||
players = result;
|
||||
playersAfter =
|
||||
(regionRatingInfo === null
|
||||
? archivedLeaderboard.version!.value.entries.length
|
||||
: regionRatingInfo.count) - result[result.length - 1].position;
|
||||
titleName = `${
|
||||
leagueInfo
|
||||
? (strings.common.leagues as Record<string, string>)[
|
||||
LEAGUES[leagueInfo.index].name
|
||||
]
|
||||
: ""
|
||||
} - ${strings.common.regions.short[region]}`;
|
||||
titleImage = imgur(leagueInfo.icon);
|
||||
titleDescription = literals(
|
||||
strings.bot.commands.rating_leaderboard.body.top_players,
|
||||
{ count: limit }
|
||||
);
|
||||
} else {
|
||||
const { region, id } = await resolvePlayerFromCommand(interaction);
|
||||
regionRatingInfo = isCurrentSeason
|
||||
? await getRatingInfo(region)
|
||||
: null;
|
||||
|
||||
if (
|
||||
regionRatingInfo !== null &&
|
||||
regionRatingInfo.detail !== undefined
|
||||
) {
|
||||
return strings.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.version!.value.entries.findIndex(
|
||||
(player) => player.id === id
|
||||
);
|
||||
|
||||
if (playerIndex === -1) {
|
||||
throw new UserError(
|
||||
literals(
|
||||
strings.bot.commands.rating_leaderboard.body
|
||||
.no_participation,
|
||||
{ player: markdownEscape(accountInfo.nickname), season }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const halfRange = Math.round(limit / 2);
|
||||
const trimmed = players.version!.value.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}`,
|
||||
}));
|
||||
}
|
||||
);
|
||||
archivedLeaderboard = await getArchivedRatingLeaderboard(
|
||||
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 =
|
||||
strings.bot.commands.rating_leaderboard.body.neighbors;
|
||||
playersAfter =
|
||||
(regionRatingInfo === null
|
||||
? archivedLeaderboard.version!.value.entries.length
|
||||
: regionRatingInfo.count) -
|
||||
neighbors[neighbors.length - 1].position;
|
||||
playerId = id;
|
||||
}
|
||||
|
||||
const items = players.map((player) => {
|
||||
const midnightIndex =
|
||||
archivedLeaderboard?.version!.value.entries.findIndex(
|
||||
(item) => item.id === player.id
|
||||
);
|
||||
const midnightScore =
|
||||
midnightIndex === undefined
|
||||
? player.score
|
||||
: archivedLeaderboard!.version!.value.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={literals(
|
||||
strings.bot.commands.rating_leaderboard.body.more,
|
||||
{ count: playersAfter.toLocaleString(interaction.locale) }
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Leaderboard.Root>
|
||||
)}
|
||||
</CommandWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
autocomplete: autocompleteUsername,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,592 +0,0 @@
|
||||
import {
|
||||
calculateWN8,
|
||||
emblemURL,
|
||||
getClanAccountInfo,
|
||||
idToRegion,
|
||||
tankIcon,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from "@blitzkit/core";
|
||||
import { Locale } from "discord.js";
|
||||
import { clamp } from "lodash-es";
|
||||
import { CommandWrapper } from "../components/CommandWrapper";
|
||||
import { TitleBar } from "../components/TitleBar";
|
||||
import { iconPng } from "../core/blitzkit/iconPng";
|
||||
import {
|
||||
averageDefinitions,
|
||||
mapDefinitions,
|
||||
tankDefinitions,
|
||||
} from "../core/blitzkit/nonBlockingPromises";
|
||||
import { RenderConfiguration } from "../core/blitzkit/renderConfiguration";
|
||||
import { buttonLink } from "../core/discord/buttonLink";
|
||||
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { localizationObject } from "../core/discord/localizationObject";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import { type 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(
|
||||
locale: Locale,
|
||||
player: WotInspectorReplayPlayerData,
|
||||
protagonistTeam: boolean,
|
||||
winningTeam: number,
|
||||
recordingPlayer = false,
|
||||
) {
|
||||
const { unwrap } = translator(locale);
|
||||
const awaitedTankAverages = await averageDefinitions;
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const tank = awaitedTankDefinitions.tanks[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.averages[player.vehicle_descr];
|
||||
const wn8 =
|
||||
tankAveragesEntry === undefined
|
||||
? -1
|
||||
: calculateWN8(tankAveragesEntry.mu, {
|
||||
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={unwrap(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,
|
||||
}}
|
||||
>
|
||||
{unwrap(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) => {
|
||||
const { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand("replay").addAttachmentOption((option) =>
|
||||
option
|
||||
.setName(strings.bot.commands.replay.options.file.name)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) => strings.bot.commands.replay.options.file.name,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
)
|
||||
.setDescription(strings.bot.commands.replay.options.file.description)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) => strings.bot.commands.replay.options.file.description,
|
||||
),
|
||||
)
|
||||
.setRequired(true),
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const awaitedMapDefinitions = await mapDefinitions;
|
||||
const file = interaction.options.getAttachment("file")!;
|
||||
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.maps[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(
|
||||
interaction.locale,
|
||||
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(
|
||||
interaction.locale,
|
||||
player,
|
||||
false,
|
||||
replayData.winner_team,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
buttonLink(submissionData.data.view_url, "WoT Inspector"),
|
||||
buttonLink(submissionData.data.download_url, "Download"),
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,626 +0,0 @@
|
||||
import {
|
||||
asset,
|
||||
emblemURL,
|
||||
equipmentPriceMatrix,
|
||||
getAccountInfo,
|
||||
getClanAccountInfo,
|
||||
getTankStats,
|
||||
TANK_ICONS,
|
||||
TankType,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { escapeMarkdown, Locale } from "discord.js";
|
||||
import { CommandWrapper } from "../components/CommandWrapper";
|
||||
import { TitleBar } from "../components/TitleBar";
|
||||
import { resolveTankId } from "../core/blitz/resolveTankId";
|
||||
import { buildTechTreeLine } from "../core/blitzkit/buildTechTreeLine";
|
||||
import { iconPng } from "../core/blitzkit/iconPng";
|
||||
import { tankDefinitions } from "../core/blitzkit/nonBlockingPromises";
|
||||
import { resolveAncestry } from "../core/blitzkit/resolveAncestry";
|
||||
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 { type CommandRegistry } from "../events/interactionCreate";
|
||||
import { theme } from "../stitches.config";
|
||||
|
||||
export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
const { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand("research")
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(strings.bot.commands.research.options.target_tank.name!)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.research.options.target_tank.name,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
)
|
||||
.setDescription(
|
||||
strings.bot.commands.research.options.target_tank.description,
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.research.options.target_tank.description,
|
||||
),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName(strings.bot.commands.research.options.starting_tank.name!)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.research.options.starting_tank.name,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
)
|
||||
.setDescription(
|
||||
strings.bot.commands.research.options.starting_tank.description,
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.research.options.starting_tank.description,
|
||||
),
|
||||
)
|
||||
.setAutocomplete(true)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption(addUsernameChoices),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings, unwrap } = 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.tanks[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 literals(
|
||||
strings.bot.commands.research.errors.start_end_equal,
|
||||
{ tank: escapeMarkdown(unwrap(targettank.name!)) },
|
||||
);
|
||||
}
|
||||
|
||||
const startingTank = awaitedTankDefinitions.tanks[startingTankId];
|
||||
|
||||
if (startingTank.tier > targetTank.tier) {
|
||||
return literals(strings.bot.commands.research.errors.unordered, {
|
||||
tank_1: escapeMarkdown(unwrap(startingtank.name!)),
|
||||
tank_2: escapeMarkdown(unwrap(targettank.name!)),
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetTankAncestry.includes(startingTankId)) {
|
||||
return literals(
|
||||
strings.bot.commands.research.errors.tanks_not_on_line,
|
||||
{
|
||||
tank_1: escapeMarkdown(unwrap(targettank.name!)),
|
||||
tank_2: escapeMarkdown(unwrap(startingtank.name!)),
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (targetTank.type !== TankType.TANK_TYPE_RESEARCHABLE) {
|
||||
return literals(strings.bot.commands.research.errors.non_tech_tree, {
|
||||
tank: escapeMarkdown(unwrap(targettank.name!)),
|
||||
});
|
||||
}
|
||||
|
||||
if (targetTank.tier === 1) {
|
||||
return literals(strings.bot.commands.research.errors.tier_1, {
|
||||
tank: escapeMarkdown(unwrap(targettank.name!)),
|
||||
});
|
||||
}
|
||||
|
||||
const tankStats = await getTankStats(region, id);
|
||||
|
||||
if (tankStats === null) {
|
||||
return strings.bot.common.errors.no_tank_stats;
|
||||
}
|
||||
|
||||
if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) {
|
||||
return literals(
|
||||
strings.bot.commands.research.errors.already_researched,
|
||||
{ tank: escapeMarkdown(unwrap(targettank.name!)) },
|
||||
);
|
||||
}
|
||||
|
||||
const foundAncestor = targetTankAncestry.find(
|
||||
(id) =>
|
||||
awaitedTankDefinitions.tanks[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.tanks[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.research_cost !== undefined &&
|
||||
typeof turret.research_cost.research_cost_type!.value === "number"
|
||||
) {
|
||||
turretXps.set(
|
||||
turret.id,
|
||||
turret.research_cost.research_cost_type!.value,
|
||||
);
|
||||
}
|
||||
|
||||
turret.guns.forEach((gun) => {
|
||||
if (
|
||||
gun.research_cost !== undefined &&
|
||||
typeof gun.research_cost.research_cost_type!.value === "number"
|
||||
) {
|
||||
gunXps.set(gun.id, gun.research_cost.research_cost_type!.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tank.engines.forEach((engine) => {
|
||||
if (
|
||||
engine.research_cost !== undefined &&
|
||||
typeof engine.research_cost.research_cost_type!.value === "number"
|
||||
) {
|
||||
engineXps.set(
|
||||
engine.id,
|
||||
engine.research_cost.research_cost_type!.value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tank.tracks.forEach((track) => {
|
||||
if (
|
||||
track.research_cost !== undefined &&
|
||||
typeof track.research_cost.research_cost_type!.value === "number"
|
||||
) {
|
||||
trackXps.set(
|
||||
track.id,
|
||||
track.research_cost.research_cost_type!.value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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.research_cost!.research_cost_type!.value as number,
|
||||
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={`${strings.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,
|
||||
}}
|
||||
>
|
||||
{strings.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.tanks[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={unwrap(tank.name!)}
|
||||
src={TANK_ICONS[tank.class]}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
}}
|
||||
>
|
||||
{unwrap(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,
|
||||
}}
|
||||
>
|
||||
{literals(
|
||||
strings.bot.commands.research.body.research,
|
||||
{
|
||||
cost: 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,
|
||||
}}
|
||||
>
|
||||
{literals(
|
||||
strings.bot.commands.research.body.upgrades,
|
||||
{ cost: 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,
|
||||
}}
|
||||
>
|
||||
{literals(
|
||||
strings.bot.commands.research.body.purchase,
|
||||
{
|
||||
cost: 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,
|
||||
}}
|
||||
>
|
||||
{literals(
|
||||
strings.bot.commands.research.body.equipment,
|
||||
{
|
||||
cost: costs[index].equipment.toLocaleString(
|
||||
interaction.locale,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</CommandWrapper>
|
||||
);
|
||||
|
||||
return startingTankRaw
|
||||
? image
|
||||
: [strings.bot.commands.research.body.estimation, image];
|
||||
},
|
||||
|
||||
autocomplete(interaction) {
|
||||
autocompleteTanks(interaction, true, ["target-tank", "starting-tank"]);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { type ClanList, fetchBlitz, type Region } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { Locale } from "discord.js";
|
||||
import markdownEscape from "markdown-escape";
|
||||
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 type { CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
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 { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
return option
|
||||
.setName(strings.bot.commands.search_clans.options.limit.name)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) => strings.bot.commands.search_clans.options.limit.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
literals(
|
||||
strings.bot.commands.search_clans.options.limit.description,
|
||||
{ limit: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.search_clans.options.limit.description,
|
||||
{ limit: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100);
|
||||
}),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings } = 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(
|
||||
literals(strings.bot.commands.search_clans.body.title, {
|
||||
search: markdownEscape(clan),
|
||||
region: strings.common.regions.normal[region],
|
||||
}),
|
||||
clanList.length === 0
|
||||
? strings.bot.commands.search_clans.body.no_results
|
||||
: `\`\`\`\n${clanList
|
||||
.map((clan) => `${clan.name} [${clan.tag}]`)
|
||||
.join("\n")}\n\`\`\``
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import {
|
||||
type AccountList,
|
||||
fetchBlitz,
|
||||
type Region,
|
||||
usernamePattern,
|
||||
} from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { Locale } from "discord.js";
|
||||
import markdownEscape from "markdown-escape";
|
||||
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 type { CommandRegistry } from "../events/interactionCreate";
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
export const searchPlayersCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
const { strings } = translator(Locale.EnglishUS);
|
||||
|
||||
resolve({
|
||||
command: createLocalizedCommand("search-players")
|
||||
.addStringOption(addRegionChoices)
|
||||
.addStringOption((option) =>
|
||||
addUsernameChoices(option).setAutocomplete(false).setRequired(true)
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName(strings.bot.commands.search_players.options.limit.name)
|
||||
.setNameLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.search_players.options.limit.name,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
)
|
||||
.setDescription(
|
||||
literals(
|
||||
strings.bot.commands.search_players.options.limit.description,
|
||||
{ limit: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setDescriptionLocalizations(
|
||||
localizationObject(
|
||||
(strings) =>
|
||||
strings.bot.commands.search_players.options.limit.description,
|
||||
{ limit: DEFAULT_LIMIT }
|
||||
)
|
||||
)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100)
|
||||
),
|
||||
|
||||
async handler(interaction) {
|
||||
const { strings } = 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(
|
||||
literals(strings.bot.commands.search_players.body.title, {
|
||||
search: markdownEscape(trimmedSearch),
|
||||
region: strings.common.regions.normal[region],
|
||||
}),
|
||||
`\`\`\`${
|
||||
players.length === 0
|
||||
? strings.bot.commands.search_players.body.no_results
|
||||
: players.map((player) => player.nickname).join("\n")
|
||||
}\`\`\``
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { emblemURL, getAccountInfo, getClanAccountInfo } from "@blitzkit/core";
|
||||
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 { filtersToDescription } from "../core/blitzkit/filtersToDescription";
|
||||
import { filterStats, type StatFilters } from "../core/blitzstars/filterStats";
|
||||
import { getStatsInPeriod } from "../core/blitzstars/getStatsInPeriod";
|
||||
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
|
||||
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
|
||||
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
|
||||
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
|
||||
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import type { 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 { strings } = translator(interaction.locale);
|
||||
return strings.bot.commands.dead;
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { gameDefinitions } from "../core/blitzkit/nonBlockingPromises";
|
||||
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
|
||||
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
|
||||
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
|
||||
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
|
||||
import { translator } from "../core/localization/translator";
|
||||
import { type 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 { strings } = translator(interaction.locale);
|
||||
return strings.bot.commands.dead;
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
getWN8Percentile,
|
||||
Percentile,
|
||||
type SupplementaryStats,
|
||||
} from '@blitzkit/core';
|
||||
import { Locale } from 'discord.js';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
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 function AllStatsOverview({
|
||||
stats,
|
||||
locale,
|
||||
supplementaryStats,
|
||||
}: AllStatsOverviewProps) {
|
||||
const { strings } = 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={strings.bot.common.hero_stats.battles}
|
||||
value={stats.battles.toLocaleString()}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.winrate}
|
||||
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.damage}
|
||||
value={Math.round(
|
||||
stats.damage_dealt / stats.battles,
|
||||
).toLocaleString()}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.damage_ratio}
|
||||
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
|
||||
align={WN8SurroundingStatAlign.Right}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroStat
|
||||
value={heroStat}
|
||||
subtitle={strings.common.wn8_percentile[percentile]}
|
||||
color={color}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.tier}
|
||||
value={
|
||||
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
|
||||
}
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.survival}
|
||||
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
|
||||
2,
|
||||
)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.accuracy}
|
||||
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
|
||||
padded
|
||||
/>
|
||||
<WN8SurroundingStat
|
||||
label={strings.bot.common.hero_stats.kills}
|
||||
value={(stats.frags / stats.battles).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
Percentile,
|
||||
TankClass,
|
||||
TankType,
|
||||
TREE_TYPE_ICONS,
|
||||
} from "@blitzkit/core";
|
||||
import { createColors } from "bepaint";
|
||||
import {
|
||||
type AccentColor,
|
||||
type GrayColor,
|
||||
PALETTES,
|
||||
} from "../../../core/radix/radixColors";
|
||||
import { RowStat } from "./RowStat";
|
||||
|
||||
export interface RowStatItem {
|
||||
title: string;
|
||||
current?: number | string;
|
||||
career?: number | string;
|
||||
delta?: number;
|
||||
percentile?: Percentile;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
displayType?: "tank" | "summary";
|
||||
naked?: boolean;
|
||||
title: string;
|
||||
minimized?: boolean;
|
||||
type?: TankType;
|
||||
class?: TankClass;
|
||||
color?: AccentColor | GrayColor;
|
||||
stats: (RowStatItem | undefined)[];
|
||||
}
|
||||
|
||||
export function Row({
|
||||
color = "slate",
|
||||
stats,
|
||||
title,
|
||||
minimized,
|
||||
class: tankClass,
|
||||
type,
|
||||
naked = false,
|
||||
displayType,
|
||||
}: 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,
|
||||
}}
|
||||
>
|
||||
{displayType === "tank" && tankClass !== undefined && (
|
||||
<img
|
||||
alt={TankClass[tankClass]}
|
||||
src={
|
||||
TREE_TYPE_ICONS[type ?? TankType.TANK_TYPE_RESEARCHABLE][
|
||||
tankClass
|
||||
]
|
||||
}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
displayType === "tank"
|
||||
? type === TankType.TANK_TYPE_COLLECTOR
|
||||
? theme.textLowContrast_blue
|
||||
: type === TankType.TANK_TYPE_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>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Percentile } from "@blitzkit/core";
|
||||
import { createColors } from "bepaint";
|
||||
import {
|
||||
type AccentColor,
|
||||
type GrayColor,
|
||||
PALETTES,
|
||||
} from "../../../core/radix/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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './components/Root';
|
||||
export * from './components/Row';
|
||||
export * from './components/RowStat';
|
||||
@@ -1,26 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { theme } from "../stitches.config";
|
||||
|
||||
interface CommandWrapperProps {
|
||||
children: ReactNode;
|
||||
fat?: boolean;
|
||||
}
|
||||
|
||||
export 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,
|
||||
backgroundColor: theme.colors.appBackground1,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { asset } from '@blitzkit/core';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Percentile } from '@blitzkit/core';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface RootProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
interface RowProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Row({ children }: RowProps) {
|
||||
return <div style={{ display: 'flex', gap: 8 }}>{children}</div>;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
type BlitzStats,
|
||||
getWN8Percentile,
|
||||
isNumber,
|
||||
type SupplementaryStats,
|
||||
} from '@blitzkit/core';
|
||||
import { Locale } from 'discord.js';
|
||||
import { translator } from '../../core/localization/translator';
|
||||
import { Card } from './components/Card';
|
||||
import { Root } from './components/Root';
|
||||
import { Row } from './components/Row';
|
||||
|
||||
interface GenericAllStatsProps {
|
||||
stats: BlitzStats;
|
||||
supplementaryStats?: SupplementaryStats;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function GenericAllStats({
|
||||
stats,
|
||||
supplementaryStats,
|
||||
locale,
|
||||
}: GenericAllStatsProps) {
|
||||
const { strings } = translator(locale);
|
||||
const damageRatio = stats.damage_dealt / stats.damage_received;
|
||||
const killsToDeathRatio =
|
||||
stats.frags / (stats.battles - stats.survived_battles);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Row>
|
||||
<Card
|
||||
title={strings.bot.commands.full_stats.body.cards.games.title}
|
||||
items={[
|
||||
{
|
||||
label: strings.bot.commands.full_stats.body.cards.games.winrate,
|
||||
value: `${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label: strings.bot.commands.full_stats.body.cards.games.battles,
|
||||
value: stats.battles,
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.games.wins_losses,
|
||||
value: `${stats.wins} / ${stats.losses}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title={strings.bot.commands.full_stats.body.cards.efficiency.title}
|
||||
items={[
|
||||
{
|
||||
label: strings.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:
|
||||
strings.bot.commands.full_stats.body.cards.efficiency.survival,
|
||||
value: `${(
|
||||
100 *
|
||||
(stats.survived_battles / stats.battles)
|
||||
).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.efficiency.accuracy,
|
||||
value: `${((stats.hits / stats.shots) * 100).toFixed(2)}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Card
|
||||
title={strings.bot.commands.full_stats.body.cards.lethality.title}
|
||||
items={[
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.lethality
|
||||
.average_damage,
|
||||
value: (stats.damage_dealt / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.lethality
|
||||
.damage_per_tier,
|
||||
value: supplementaryStats?.tier
|
||||
? (
|
||||
stats.damage_dealt /
|
||||
stats.battles /
|
||||
supplementaryStats.tier
|
||||
).toFixed(0)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.lethality
|
||||
.damage_ratio,
|
||||
value: isFinite(damageRatio) ? damageRatio.toFixed(2) : undefined,
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.lethality
|
||||
.average_kills,
|
||||
value: (stats.frags / stats.battles).toFixed(0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card
|
||||
title={strings.bot.commands.full_stats.body.cards.miscellaneous.title}
|
||||
items={[
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.miscellaneous
|
||||
.average_tier,
|
||||
value: supplementaryStats?.tier?.toFixed(2),
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.miscellaneous
|
||||
.average_spots,
|
||||
value: (stats.spotted / stats.battles).toFixed(2),
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.miscellaneous
|
||||
.average_xp,
|
||||
value: (stats.xp / stats.battles).toFixed(0),
|
||||
},
|
||||
{
|
||||
label:
|
||||
strings.bot.commands.full_stats.body.cards.miscellaneous
|
||||
.kills_to_death_ratio,
|
||||
value: isFinite(killsToDeathRatio)
|
||||
? killsToDeathRatio.toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { type GenericStat } from "..";
|
||||
import { GenericStatsRow } from "./GenericStatsRow";
|
||||
|
||||
interface GenericStatsColumnProps {
|
||||
stats: GenericStat[];
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { type GenericStat } from "..";
|
||||
import { theme } from "../../../stitches.config";
|
||||
import { PercentileIndicator } from "../../PercentileIndicator";
|
||||
|
||||
interface GenericStatsRowProps {
|
||||
stat: GenericStat;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Percentile } from '@blitzkit/core';
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export enum LineColor {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
White,
|
||||
}
|
||||
export enum LineStyle {
|
||||
Solid,
|
||||
Dashes,
|
||||
DashesAndDots,
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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]}
|
||||
strokeWidth={lineWidth}
|
||||
stroke-dasharray={LINE_STYLES[style]}
|
||||
points={points}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum MarginOrientation {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { times } from 'lodash-es';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import { type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const GRAPH_WIDTH = 384;
|
||||
export const GRAPH_HEIGHT = 320;
|
||||
@@ -1,109 +0,0 @@
|
||||
import { times } from "lodash-es";
|
||||
import { type 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}
|
||||
strokeWidth="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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './components/Line';
|
||||
export * from './components/Margin';
|
||||
export * from './components/Root';
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './components/Gap';
|
||||
export * from './components/Item';
|
||||
export * from './components/Root';
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum NoDataType {
|
||||
BattlesInPeriod,
|
||||
PlayersInPeriod,
|
||||
TanksFound,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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 function NoData({ type, locale }: NoDataProps) {
|
||||
const { strings } = translator(locale);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
|
||||
{strings.bot.common.no_data[type]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Percentile } from '@blitzkit/core';
|
||||
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,
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Percentile } from '@blitzkit/core';
|
||||
import { PERCENTILE_COLORS } from './constants';
|
||||
|
||||
interface PercentileIndicatorProps {
|
||||
percentile: Percentile;
|
||||
}
|
||||
|
||||
export function PercentileIndicator({ percentile }: PercentileIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: PERCENTILE_COLORS[percentile],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { TankClass, TankType, TREE_TYPE_ICONS } from "@blitzkit/core";
|
||||
import { theme } from "../../../../stitches.config";
|
||||
|
||||
export interface ItemProps {
|
||||
tankClass?: TankClass;
|
||||
name: string;
|
||||
tankType: TankType;
|
||||
}
|
||||
|
||||
const TREE_TYPE_COLOR = {
|
||||
[TankType.TANK_TYPE_RESEARCHABLE]: "",
|
||||
[TankType.TANK_TYPE_PREMIUM]: "_amber",
|
||||
[TankType.TANK_TYPE_COLLECTOR]: "_blue",
|
||||
} as const;
|
||||
|
||||
export function Item({
|
||||
tankClass: tankType,
|
||||
name,
|
||||
tankType: 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[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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface RootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface RowProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Row({ children }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './components/Column';
|
||||
export * from './components/Item';
|
||||
export * from './components/Root';
|
||||
export * from './components/Row';
|
||||
export * from './components/Title';
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TierWeightsRecord } from "@blitzkit/core";
|
||||
import { theme } from "../stitches.config";
|
||||
|
||||
interface TierWeightsProps {
|
||||
weights: TierWeightsRecord;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
interface TitleBarProps {
|
||||
title: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export 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,3 +0,0 @@
|
||||
export function normalizeLeagueIcon(url: string) {
|
||||
return url.startsWith('http') ? url : `https:${url}`;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { SEARCH_KEYS } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { Locale } from "discord.js";
|
||||
import { go } from "fuzzysort";
|
||||
import markdownEscape from "markdown-escape";
|
||||
import { tankDefinitions, tankNames } from "../blitzkit/nonBlockingPromises";
|
||||
import { UserError } from "../blitzkit/userError";
|
||||
import { tankNamesTechTreeOnly } from "../discord/autocompleteTanks";
|
||||
import { translator } from "../localization/translator";
|
||||
|
||||
export async function resolveTankId(
|
||||
tank: string | number,
|
||||
locale: Locale,
|
||||
techTreeOnly = false
|
||||
) {
|
||||
const { strings } = translator(locale);
|
||||
const number = typeof tank === "string" ? Number(tank) : tank;
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
const searchResult = go(
|
||||
`${tank}`,
|
||||
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
|
||||
{ keys: SEARCH_KEYS, limit: 1 }
|
||||
);
|
||||
|
||||
if (searchResult.length === 0) {
|
||||
throw new UserError(
|
||||
literals(strings.bot.common.errors.tank_name_not_found, {
|
||||
search: markdownEscape(`${tank}`),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return searchResult[0].obj.id;
|
||||
}
|
||||
} else {
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
if (awaitedTankDefinitions.tanks[number]) {
|
||||
return number;
|
||||
} else {
|
||||
throw new UserError(
|
||||
literals(strings.bot.common.errors.tank_id_not_found, {
|
||||
search: number,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { searchClansAcrossRegions } from '@blitzkit/core';
|
||||
import { Locale } from 'discord.js';
|
||||
import { UserError } from '../blitzkit/userError';
|
||||
import { translator } from '../localization/translator';
|
||||
|
||||
export async function searchClansAcrossRegionsBotWrapper(
|
||||
search: string,
|
||||
locale: Locale,
|
||||
limit?: number,
|
||||
) {
|
||||
const { strings } = translator(locale);
|
||||
const trimmed = search.trim();
|
||||
|
||||
if (trimmed.length < 3) {
|
||||
throw new UserError(strings.bot.common.errors.query_too_small);
|
||||
}
|
||||
|
||||
if (trimmed.length > 100) {
|
||||
throw new UserError(strings.bot.common.errors.query_too_long);
|
||||
}
|
||||
|
||||
return await searchClansAcrossRegions(trimmed, limit);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { searchPlayersAcrossRegions } from '@blitzkit/core';
|
||||
import { Locale } from 'discord.js';
|
||||
import { UserError } from '../blitzkit/userError';
|
||||
import { translator } from '../localization/translator';
|
||||
|
||||
export async function searchPlayersAcrossRegionsBotWrapper(
|
||||
search: string,
|
||||
locale: Locale,
|
||||
limit?: number,
|
||||
) {
|
||||
const { strings } = translator(locale);
|
||||
const trimmed = search.trim();
|
||||
|
||||
if (trimmed.length < 3) {
|
||||
throw new UserError(strings.bot.common.errors.query_too_small);
|
||||
}
|
||||
|
||||
if (trimmed.length > 100) {
|
||||
throw new UserError(strings.bot.common.errors.query_too_long);
|
||||
}
|
||||
|
||||
return await searchPlayersAcrossRegions(trimmed, limit);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { fetchTankDefinitions } from '@blitzkit/core';
|
||||
import { resolveAncestry } from './resolveAncestry';
|
||||
|
||||
export async function buildTechTreeLine(start: number, end: number) {
|
||||
const tankDefinitions = await fetchTankDefinitions();
|
||||
const endTank = tankDefinitions.tanks[end];
|
||||
if (!endTank.ancestors) throw new Error('End tank has no ancestors');
|
||||
const line = [end];
|
||||
|
||||
if (start === end) return line;
|
||||
for (const ancestorId of endTank.ancestors) {
|
||||
if ((await resolveAncestry(ancestorId)).includes(start)) {
|
||||
const ancestorTree = await buildTechTreeLine(start, ancestorId);
|
||||
line.push(...ancestorTree);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { fetchTankDefinitions, TIER_ROMAN_NUMERALS } from "@blitzkit/core";
|
||||
import { literals } from "@blitzkit/i18n";
|
||||
import { Locale } from "discord.js";
|
||||
import { type StatFilters } from "../blitzstars/filterStats";
|
||||
import { translator } from "../localization/translator";
|
||||
|
||||
export async function filtersToDescription(
|
||||
{ nation, tier, type, class: tankClass, tank }: StatFilters,
|
||||
locale: Locale,
|
||||
) {
|
||||
const { strings, bkLocale } = translator(locale);
|
||||
const awaitedTankDefinitions = await fetchTankDefinitions();
|
||||
const info: string[] = [];
|
||||
|
||||
// TODO: somehow localize tank names?
|
||||
if (tank) return awaitedTankDefinitions.tanks[tank].name!.locales[bkLocale];
|
||||
if (nation)
|
||||
info.push((strings.common.nations as Record<string, string>)[nation]);
|
||||
if (tier) {
|
||||
info.push(
|
||||
literals(strings.bot.common.filters.tier, {
|
||||
tier: TIER_ROMAN_NUMERALS[tier],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (tankClass) info.push(strings.common.tank_class_short[tankClass]);
|
||||
if (type) info.push(strings.common.tree_type[type]);
|
||||
|
||||
return info.length === 0 ? strings.bot.common.filters.none : info.join(", ");
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { idToRegion } from '@blitzkit/core';
|
||||
import { flagUserActivity } from '../db/flagUserActivity';
|
||||
import { usersDatabase } from '../db/users';
|
||||
|
||||
export async function getBlitzFromDiscord(discordId: bigint) {
|
||||
const unique = await usersDatabase.discordUser.findUnique({
|
||||
where: { discord_id: discordId },
|
||||
});
|
||||
|
||||
if (unique === null) return null;
|
||||
|
||||
const id = Number(unique.blitz_id);
|
||||
|
||||
await flagUserActivity(id);
|
||||
return { id, region: idToRegion(id) };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getPeriodNow() {
|
||||
return Date.now() / 1000;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { getTimeDaysAgo, type Region } from '@blitzkit/core';
|
||||
import type { PeriodSize } from '../discord/addPeriodSubCommands';
|
||||
|
||||
export function getPeriodStart(region: Region, period: PeriodSize) {
|
||||
if (period === 'career') return 0;
|
||||
if (period === 'today') return getTimeDaysAgo(region, 1);
|
||||
return getTimeDaysAgo(region, parseInt(period));
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
const cache: Record<string, Buffer> = {};
|
||||
|
||||
export async function iconPng(url: string) {
|
||||
if (!url.endsWith(".webp")) return url;
|
||||
|
||||
if (!cache[url]) {
|
||||
const imageResponse = await fetch(url);
|
||||
const imageBufferWEBP = await imageResponse.arrayBuffer();
|
||||
const imageBufferPNG = await sharp(imageBufferWEBP).png().toBuffer();
|
||||
cache[url] = imageBufferPNG;
|
||||
}
|
||||
|
||||
return `data:image/png;base64,${cache[url].toString("base64")}`;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { jsxToSvg } from "./jsxToSvg";
|
||||
import { svgToPng } from "./svgToPng";
|
||||
|
||||
export async function jsxToPng(jsx: React.JSX.Element) {
|
||||
const svg = await jsxToSvg(jsx);
|
||||
const png = svgToPng(svg);
|
||||
|
||||
return png;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { jsxToSvg } from "./jsxToSvg";
|
||||
import { RenderConfiguration } from "./renderConfiguration";
|
||||
import { svgToPngThreaded } from "./svgToPngThreaded";
|
||||
|
||||
export async function jsxToPngThreaded(
|
||||
jsx: React.JSX.Element,
|
||||
renderConfiguration: RenderConfiguration
|
||||
) {
|
||||
const svg = await jsxToSvg(jsx, renderConfiguration);
|
||||
const png = await svgToPngThreaded(svg);
|
||||
return png;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import satori from "satori";
|
||||
import { RenderConfiguration } from "../renderConfiguration";
|
||||
import robotoBlack from "./Roboto-Black.ttf";
|
||||
import robotoBold from "./Roboto-Bold.ttf";
|
||||
import roboto from "./Roboto.ttf";
|
||||
|
||||
const fontsPromise = [
|
||||
{ file: roboto, weight: 400 as const },
|
||||
{ file: robotoBold, weight: 700 as const },
|
||||
{ file: robotoBlack, weight: 900 as const },
|
||||
].map(async ({ file, weight }) => {
|
||||
const data = await readFile(`${__dirname}/${file}`);
|
||||
return { name: "Roboto", weight, data };
|
||||
});
|
||||
|
||||
export async function jsxToSvg(
|
||||
jsx: React.JSX.Element,
|
||||
renderConfiguration = new RenderConfiguration()
|
||||
) {
|
||||
const fonts = await Promise.all(fontsPromise);
|
||||
return await satori(jsx, { width: renderConfiguration.width, fonts });
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
fetchAverageDefinitions,
|
||||
fetchDiscoveredIdsDefinitions,
|
||||
fetchGameDefinitions,
|
||||
fetchMapDefinitions,
|
||||
fetchTankDefinitions,
|
||||
fetchTankNames,
|
||||
} from "@blitzkit/core";
|
||||
|
||||
export const averageDefinitions = fetchAverageDefinitions();
|
||||
export const tankDefinitions = fetchTankDefinitions();
|
||||
export const gameDefinitions = fetchGameDefinitions();
|
||||
export const tankNames = fetchTankNames();
|
||||
export const mapDefinitions = fetchMapDefinitions();
|
||||
export const discoveredIdsDefinitions = fetchDiscoveredIdsDefinitions();
|
||||
@@ -1,37 +0,0 @@
|
||||
import { usersDatabase } from "../db/users";
|
||||
import { client } from "../discord/client";
|
||||
|
||||
const EXIT_EVENTS = ["SIGINT", "SIGTERM", "SIGUSR1", "SIGUSR2"];
|
||||
|
||||
let cleaningUp = false;
|
||||
|
||||
async function cleanup(signal: string) {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
|
||||
console.log(`Cleaning up... (${signal})`);
|
||||
|
||||
try {
|
||||
await Promise.all([usersDatabase.$disconnect(), client.destroy()]);
|
||||
} catch (err) {
|
||||
console.error("Cleanup error:", err);
|
||||
}
|
||||
|
||||
console.log("Gracefully exiting...");
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export function registerProcesses() {
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("uncaughtException:", err);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err) => {
|
||||
console.error("unhandledRejection:", err);
|
||||
});
|
||||
|
||||
client.on("error", console.error);
|
||||
|
||||
EXIT_EVENTS.forEach((event) => process.on(event, () => cleanup(event)));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface RenderConfiguration {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class RenderConfiguration implements RenderConfiguration {
|
||||
width = 480;
|
||||
|
||||
constructor(configuration?: Partial<RenderConfiguration>) {
|
||||
Object.assign(this, configuration);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { fetchTankDefinitions } from '@blitzkit/core';
|
||||
|
||||
export async function resolveAncestry(id: number) {
|
||||
const tankDefinitions = await fetchTankDefinitions();
|
||||
const tank = tankDefinitions.tanks[id];
|
||||
const ancestors: number[] = [];
|
||||
|
||||
if (tank.ancestors) {
|
||||
for (const ancestor of tank.ancestors) {
|
||||
if (!ancestors.includes(ancestor)) {
|
||||
ancestors.push(ancestor);
|
||||
|
||||
for (const ancestorLayer2 of await resolveAncestry(ancestor)) {
|
||||
if (!ancestors.includes(ancestorLayer2)) {
|
||||
ancestors.push(ancestorLayer2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ancestors.sort(
|
||||
(a, b) => tankDefinitions.tanks[b].tier - tankDefinitions.tanks[a].tier,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
|
||||
export function svgToPng(svg: string) {
|
||||
return new Resvg(svg).render().asPng();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
|
||||
const RENDER_WORKERS = 1;
|
||||
|
||||
const workers = [...Array(RENDER_WORKERS)].map(() => {
|
||||
const worker = new Worker('dist/bot/workers/render.js');
|
||||
console.log(`🟡 Launching render worker ${worker.threadId}`);
|
||||
return worker;
|
||||
});
|
||||
const queue: {
|
||||
svg: string;
|
||||
resolve: (png: Buffer) => void;
|
||||
}[] = [];
|
||||
|
||||
function manageQueue() {
|
||||
if (queue.length > 0 && workers.length > 0) {
|
||||
const { svg, resolve } = queue.shift()!;
|
||||
const worker = workers.shift()!;
|
||||
|
||||
function cleanup() {
|
||||
workers.push(worker);
|
||||
worker.off('message', handleMessage);
|
||||
worker.off('error', handleError);
|
||||
manageQueue();
|
||||
}
|
||||
function handleMessage(png: Uint8Array) {
|
||||
cleanup();
|
||||
resolve(Buffer.from(png));
|
||||
}
|
||||
function handleError(error: Error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
|
||||
worker.postMessage(svg);
|
||||
worker.on('message', handleMessage);
|
||||
worker.on('error', handleError);
|
||||
}
|
||||
}
|
||||
|
||||
export function svgToPngThreaded(svg: string) {
|
||||
return new Promise<Buffer>((resolve) => {
|
||||
queue.push({ svg, resolve });
|
||||
manageQueue();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { tankIcon } from '@blitzkit/core';
|
||||
import { iconPng } from './iconPng';
|
||||
|
||||
export function tankIconPng(id: number) {
|
||||
return iconPng(tankIcon(id));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Discriminator for user caused errors to not report in logs
|
||||
*/
|
||||
export class UserError {
|
||||
constructor(public message: string) {}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { TankClass, TankType } from "@blitzkit/core";
|
||||
import type { SupplementaryStats } from "@blitzkit/core/src/blitz/getAccountInfo";
|
||||
import { calculateWN8 } from "@blitzkit/core/src/statistics/calculateWN8";
|
||||
import type { BlitzStats } from "@blitzkit/core/src/statistics/compositeStats";
|
||||
import { sumAllStats } from "@blitzkit/core/src/statistics/sumAllStats";
|
||||
import {
|
||||
averageDefinitions,
|
||||
tankDefinitions,
|
||||
} from "../blitzkit/nonBlockingPromises";
|
||||
|
||||
export interface StatFilters {
|
||||
nation?: string;
|
||||
tier?: number;
|
||||
class?: TankClass;
|
||||
type?: TankType;
|
||||
tank?: number;
|
||||
}
|
||||
|
||||
export async function filterStats(
|
||||
{ diff, order }: DiffedTankStats,
|
||||
filters: StatFilters,
|
||||
) {
|
||||
// tank, if provided, takes priority over all other filters
|
||||
if (filters.tank) filters = { tank: filters.tank };
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedTankAverages = await averageDefinitions;
|
||||
const filteredOrder = order.filter((id) => {
|
||||
const entry = awaitedTankDefinitions.tanks[id];
|
||||
|
||||
return (
|
||||
entry &&
|
||||
(filters.nation === undefined || entry.nation === filters.nation) &&
|
||||
(filters.tier === undefined || entry.tier === filters.tier) &&
|
||||
(filters.class === undefined || entry.class === filters.class) &&
|
||||
(filters.type === undefined ||
|
||||
(filters.type === TankType.TANK_TYPE_COLLECTOR &&
|
||||
entry.type === TankType.TANK_TYPE_COLLECTOR) ||
|
||||
(filters.type === TankType.TANK_TYPE_PREMIUM &&
|
||||
entry.type === TankType.TANK_TYPE_PREMIUM) ||
|
||||
(filters.type === TankType.TANK_TYPE_RESEARCHABLE &&
|
||||
entry.type === TankType.TANK_TYPE_RESEARCHABLE)) &&
|
||||
(filters.tank === undefined || entry.id === filters.tank)
|
||||
);
|
||||
});
|
||||
const stats = sumAllStats(filteredOrder.map((id) => diff[id]));
|
||||
const battlesOfTanksWithAverages = filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankAverages.averages[id]
|
||||
? accumulator + diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
);
|
||||
const battlesOfTanksWithTankDefinition = filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankDefinitions.tanks[id]
|
||||
? accumulator + diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
);
|
||||
const supplementary = {
|
||||
WN8:
|
||||
filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankAverages.averages[id]
|
||||
? accumulator +
|
||||
calculateWN8(awaitedTankAverages.averages[id].mu, diff[id]) *
|
||||
diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
) / battlesOfTanksWithAverages,
|
||||
tier:
|
||||
filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankDefinitions.tanks[id]
|
||||
? accumulator +
|
||||
awaitedTankDefinitions.tanks[id]!.tier * diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
) / battlesOfTanksWithTankDefinition,
|
||||
} satisfies SupplementaryStats;
|
||||
|
||||
return { stats, supplementary, filteredOrder };
|
||||
}
|
||||
|
||||
export interface DiffedTankStats {
|
||||
diff: Record<number, BlitzStats>;
|
||||
order: number[];
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { getAccountInfo, type Region } from "@blitzkit/core";
|
||||
import { buttonLink } from "../discord/buttonLink";
|
||||
|
||||
export async function getBlitzStarsLinkButton(
|
||||
region: Region,
|
||||
id: number,
|
||||
tankId?: number
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
|
||||
return buttonLink(
|
||||
`https://www.blitzstars.com/player/${region}/${nickname}${
|
||||
tankId ? `/tank/${tankId!}` : ""
|
||||
}`,
|
||||
"BlitzStars"
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { type Region, emptyAllStats, getAccountInfo } from '@blitzkit/core';
|
||||
import {
|
||||
type GetHistoriesOptions,
|
||||
type Histories,
|
||||
type History,
|
||||
getHistoriesDefaultOptions,
|
||||
} from '../../types/histories';
|
||||
import type { PlayerStats } from './getPlayerStats';
|
||||
|
||||
export async function getPlayerHistories(
|
||||
server: Region,
|
||||
id: number,
|
||||
options?: Partial<GetHistoriesOptions>,
|
||||
) {
|
||||
const mergedOptions = { ...getHistoriesDefaultOptions, ...options };
|
||||
const response = await fetch(
|
||||
`https://www.blitzstars.com/api/playerstats/${id}`,
|
||||
);
|
||||
const playerHistories = ((await response.json()) as PlayerStats).map(
|
||||
(history) =>
|
||||
({
|
||||
all: history.statistics.all,
|
||||
last_battle_time: history.last_battle_time,
|
||||
}) satisfies History,
|
||||
);
|
||||
let lastNonCompliantIndex = -1;
|
||||
let hasCompliedOnce = false;
|
||||
const filtered = playerHistories.filter(({ last_battle_time }, index) => {
|
||||
const complies =
|
||||
last_battle_time >= mergedOptions.start &&
|
||||
last_battle_time <= mergedOptions.end;
|
||||
|
||||
if (complies && !hasCompliedOnce) hasCompliedOnce = true;
|
||||
if (!hasCompliedOnce) lastNonCompliantIndex = index;
|
||||
|
||||
return complies;
|
||||
});
|
||||
|
||||
const latestNodes: Histories = [];
|
||||
|
||||
if (mergedOptions.includeLatestHistories) {
|
||||
const { last_battle_time, statistics } = await getAccountInfo(server, id);
|
||||
|
||||
latestNodes.push({ all: statistics.all, last_battle_time });
|
||||
}
|
||||
|
||||
const previousNodes: Histories = [];
|
||||
|
||||
if (mergedOptions.includePreviousHistories) {
|
||||
if (lastNonCompliantIndex !== -1) {
|
||||
previousNodes.push(
|
||||
playerHistories[lastNonCompliantIndex] ??
|
||||
({
|
||||
all: emptyAllStats,
|
||||
last_battle_time: -Infinity,
|
||||
} satisfies History),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...latestNodes, ...filtered, ...previousNodes] satisfies Histories;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { BlitzStats, Region } from "@blitzkit/core";
|
||||
|
||||
interface IndividualPlayerStats {
|
||||
clan: { clan_id: number; name: string; tag: string };
|
||||
statistics: {
|
||||
all: BlitzStats;
|
||||
wn8: number;
|
||||
wn7: number;
|
||||
average_tier: number;
|
||||
};
|
||||
last_battle_time: number;
|
||||
nickname: string;
|
||||
account_id: number;
|
||||
achievements: {
|
||||
account_id: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
region: Region;
|
||||
}
|
||||
|
||||
export type PlayerStats = IndividualPlayerStats[];
|
||||
|
||||
export async function getPlayerStats<
|
||||
Ids extends number | number[],
|
||||
ReturnType = Ids extends number ? PlayerStats : PlayerStats[],
|
||||
>(region: Region, ids: Ids) {
|
||||
const stats = await Promise.all(
|
||||
((typeof ids === "number" ? [ids] : ids) as number[]).map((id) =>
|
||||
fetch(`https://www.blitzstars.com/api/playerstats/${id}`).then(
|
||||
(response) => response.json() as Promise<PlayerStats>
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (typeof ids === "number") return stats[0] as ReturnType;
|
||||
return stats as ReturnType;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { type BlitzStats, emptyAllStats, type Region } from "@blitzkit/core";
|
||||
import { Locale } from "discord.js";
|
||||
import type { DiffedTankStats } from "./filterStats";
|
||||
import {
|
||||
getTankHistories,
|
||||
type TankHistories,
|
||||
type TankHistoryRaw,
|
||||
} from "./getTankHistories";
|
||||
|
||||
const emptyTankHistoryNode: TankHistoryRaw = {
|
||||
account_id: 0,
|
||||
battle_life_time: 0,
|
||||
last_battle_time: 0,
|
||||
mark_of_mastery: 0,
|
||||
tank_id: 0,
|
||||
all: emptyAllStats,
|
||||
};
|
||||
|
||||
// BIG TODO: merge this neatly with diffNormalizedTankStats
|
||||
export async function getStatsInPeriod(
|
||||
server: Region,
|
||||
id: number,
|
||||
start: number,
|
||||
end: number,
|
||||
locale: Locale
|
||||
) {
|
||||
const history = await getTankHistories(server, id, locale, {
|
||||
includeLatestHistories: true,
|
||||
});
|
||||
|
||||
// sort them even though most won't be used
|
||||
const tankSortedHistory: Record<number, TankHistories> = {};
|
||||
const tanks: number[] = [];
|
||||
history.forEach((node) => {
|
||||
if (!tankSortedHistory[node.tank_id]) {
|
||||
tankSortedHistory[node.tank_id] = [node];
|
||||
tanks.push(node.tank_id);
|
||||
} else {
|
||||
tankSortedHistory[node.tank_id].push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// find the ones played within the range
|
||||
const inRangeTanks = tanks.filter((tankId) =>
|
||||
tankSortedHistory[tankId].some(
|
||||
(node) => node.last_battle_time > start && node.last_battle_time < end
|
||||
)
|
||||
);
|
||||
const playedTanks: number[] = [];
|
||||
|
||||
// fetch history node right before "start" and right before "end"
|
||||
const diff: Record<number, BlitzStats> = {};
|
||||
inRangeTanks.forEach((tankId) => {
|
||||
const sortedHistory = tankSortedHistory[tankId];
|
||||
const previousIndex =
|
||||
sortedHistory.findIndex((node) => node.last_battle_time > start) - 1;
|
||||
const latestIndex =
|
||||
sortedHistory.findIndex((node) => node.last_battle_time > end) - 1;
|
||||
const previous = sortedHistory[previousIndex] ?? emptyTankHistoryNode;
|
||||
const latest =
|
||||
sortedHistory[latestIndex] ?? sortedHistory[sortedHistory.length - 1];
|
||||
|
||||
function d(get: (allStats: BlitzStats) => number) {
|
||||
return get(latest.all) - get(previous.all);
|
||||
}
|
||||
|
||||
// check if there was a change in battles as games in rating do update last_battle_time
|
||||
if (d((a) => a.battles) > 0) {
|
||||
diff[tankId] = {
|
||||
battles: d((a) => a.battles),
|
||||
capture_points: d((a) => a.capture_points),
|
||||
damage_dealt: d((a) => a.damage_dealt),
|
||||
damage_received: d((a) => a.damage_received),
|
||||
dropped_capture_points: d((a) => a.dropped_capture_points),
|
||||
frags: d((a) => a.frags),
|
||||
frags8p: d((a) => a.frags8p),
|
||||
hits: d((a) => a.hits),
|
||||
losses: d((a) => a.losses),
|
||||
max_frags: latest.all.max_frags,
|
||||
max_xp: latest.all.max_xp,
|
||||
shots: d((a) => a.shots),
|
||||
spotted: d((a) => a.spotted),
|
||||
survived_battles: d((a) => a.survived_battles),
|
||||
win_and_survived: d((a) => a.win_and_survived),
|
||||
wins: d((a) => a.wins),
|
||||
xp: d((a) => a.xp),
|
||||
};
|
||||
|
||||
playedTanks.push(tankId);
|
||||
}
|
||||
});
|
||||
|
||||
const order = playedTanks.sort((a, b) => {
|
||||
const aHistories = tankSortedHistory[a];
|
||||
const bHistories = tankSortedHistory[b];
|
||||
const aLatest = aHistories[aHistories.length - 1];
|
||||
const bLatest = bHistories[bHistories.length - 1];
|
||||
|
||||
return bLatest.last_battle_time - aLatest.last_battle_time;
|
||||
});
|
||||
|
||||
return { diff, order } satisfies DiffedTankStats;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { type Region, emptyAllStats, getTankStats } from "@blitzkit/core";
|
||||
import { Locale } from "discord.js";
|
||||
import {
|
||||
type GetHistoriesOptions,
|
||||
type Histories,
|
||||
type History,
|
||||
getHistoriesDefaultOptions,
|
||||
} from "../../types/histories";
|
||||
import { UserError } from "../blitzkit/userError";
|
||||
import { translator } from "../localization/translator";
|
||||
|
||||
export interface TankHistory extends History {
|
||||
tank_id: number;
|
||||
}
|
||||
|
||||
export type TankHistories = TankHistory[];
|
||||
|
||||
export interface TankHistoryRaw extends TankHistory {
|
||||
mark_of_mastery: number;
|
||||
battle_life_time: number;
|
||||
account_id: number;
|
||||
}
|
||||
|
||||
export type TankHistoriesRaw = TankHistoryRaw[];
|
||||
|
||||
export interface GetTankHistoriesOptions extends GetHistoriesOptions {
|
||||
tankId?: number;
|
||||
}
|
||||
|
||||
const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
|
||||
...getHistoriesDefaultOptions,
|
||||
tankId: undefined,
|
||||
};
|
||||
|
||||
export async function getTankHistories(
|
||||
server: Region,
|
||||
id: number,
|
||||
locale: Locale,
|
||||
options?: Partial<GetTankHistoriesOptions>
|
||||
) {
|
||||
const { strings } = translator(locale);
|
||||
const mergedOptions = { ...getTankHistoriesDefaultOptions, ...options };
|
||||
const tankHistoriesResponse = await fetch(
|
||||
`https://www.blitzstars.com/api/tankhistories/for/${id}/`
|
||||
);
|
||||
const tankHistories = (
|
||||
(await tankHistoriesResponse.json()) as TankHistoriesRaw
|
||||
).map(
|
||||
(history) =>
|
||||
({
|
||||
all: history.all,
|
||||
last_battle_time: history.last_battle_time,
|
||||
tank_id: history.tank_id,
|
||||
}) satisfies TankHistory
|
||||
);
|
||||
const compliantTanks: number[] = [];
|
||||
const lastBattles: Record<number, number> = {};
|
||||
const lastNonCompliantIndex: Record<number, number> = {};
|
||||
const hasCompliedOnce: Record<number, boolean> = {};
|
||||
const filtered = tankHistories.filter(
|
||||
({ last_battle_time, tank_id, all: { battles } }, index) => {
|
||||
const complies =
|
||||
last_battle_time >= mergedOptions.start &&
|
||||
last_battle_time <= mergedOptions.end &&
|
||||
(mergedOptions.tankId === undefined ||
|
||||
tank_id === mergedOptions.tankId);
|
||||
|
||||
if (complies && !hasCompliedOnce[tank_id]) {
|
||||
hasCompliedOnce[tank_id] = true;
|
||||
compliantTanks.push(tank_id);
|
||||
}
|
||||
if (!hasCompliedOnce[tank_id]) lastNonCompliantIndex[tank_id] = index;
|
||||
lastBattles[tank_id] = battles;
|
||||
|
||||
return complies;
|
||||
}
|
||||
);
|
||||
|
||||
const latestNodes: TankHistories = [];
|
||||
|
||||
if (mergedOptions.includeLatestHistories) {
|
||||
const tankStats = await getTankStats(server, id);
|
||||
|
||||
if (tankStats === null) {
|
||||
throw new UserError(strings.bot.common.errors.no_tank_stats);
|
||||
}
|
||||
|
||||
tankStats.forEach((tankHistory) => {
|
||||
if (
|
||||
tankHistory.all.battles > (lastBattles[tankHistory.tank_id] ?? 0) &&
|
||||
(mergedOptions.tankId === undefined ||
|
||||
tankHistory.tank_id === mergedOptions.tankId)
|
||||
) {
|
||||
if (!hasCompliedOnce[tankHistory.tank_id]) {
|
||||
// was played today for the first time... I think???
|
||||
hasCompliedOnce[tankHistory.tank_id] = true;
|
||||
compliantTanks.push(tankHistory.tank_id);
|
||||
}
|
||||
latestNodes.push(tankHistory);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const previousNodes: TankHistories = [];
|
||||
|
||||
if (mergedOptions.includePreviousHistories) {
|
||||
compliantTanks.forEach((tankId) => {
|
||||
if (tankId in lastNonCompliantIndex) {
|
||||
previousNodes.push(
|
||||
tankHistories[lastNonCompliantIndex[tankId]] ??
|
||||
({
|
||||
all: emptyAllStats,
|
||||
last_battle_time: -Infinity,
|
||||
tank_id: tankId,
|
||||
} satisfies TankHistory)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...previousNodes, ...filtered, ...latestNodes] satisfies Histories;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { usersDatabase } from './users';
|
||||
|
||||
export async function flagUserActivity(blitzId: number) {
|
||||
await usersDatabase.user.upsert({
|
||||
where: { blitz_id: blitzId },
|
||||
update: {},
|
||||
create: { blitz_id: blitzId },
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { flagUserActivity } from './flagUserActivity';
|
||||
import { usersDatabase } from './users';
|
||||
|
||||
export async function linkBlitzAndDiscord(discordId: bigint, blitzId: number) {
|
||||
await usersDatabase.discordUser.upsert({
|
||||
where: { discord_id: discordId },
|
||||
update: { blitz_id: blitzId },
|
||||
create: { discord_id: discordId, blitz_id: blitzId },
|
||||
});
|
||||
await flagUserActivity(blitzId);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { PrismaClient } from '../../prisma';
|
||||
|
||||
export const usersDatabase = new PrismaClient();
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user