mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: mostly complete rating command
This commit is contained in:
1 parent
983b96fd99
commit
1f30d1d2c3
11 files changed
+384
-32
No files matched your search
@@ -11,7 +11,7 @@ import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
|
||||
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import getArchivedRatingMidnightLeaderboard from '../core/blitzkit/getArchivedRatingMidnightLeaderboard';
|
||||
import { webpToPng } from '../core/blitzkit/iconPng';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||
@@ -295,7 +295,7 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
height: 64,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={await webpToPng(
|
||||
src={await iconPng(
|
||||
reward.type === 'vehicle'
|
||||
? reward.vehicle.preview_image_url
|
||||
: reward.stuff.image_url,
|
||||
|
||||
+340
-9
@@ -1,7 +1,17 @@
|
||||
import { clamp } from 'lodash';
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import TitleBar from '../components/TitleBar';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import { RenderConfiguration } from '../core/blitzkit/renderConfiguration';
|
||||
import { tankDefinitions } from '../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../core/blitzkit/tankDefinitions/constants';
|
||||
import { tankIcon } from '../core/blitzkit/tankIcon';
|
||||
import buttonLink from '../core/discord/buttonLink';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
import { CommandRegistry } from '../events/interactionCreate';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
type WotInspectorReplayResponse =
|
||||
type WotInspectorReplaySubmission =
|
||||
| { status: 'error'; data: unknown; error: { message: string } }
|
||||
| {
|
||||
status: 'ok';
|
||||
@@ -83,6 +93,259 @@ type WotInspectorReplayResponse =
|
||||
error: {};
|
||||
};
|
||||
|
||||
interface WotInspectorReplay {
|
||||
id: string;
|
||||
map_id: number;
|
||||
battle_duration: number;
|
||||
title: 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: WotInspectorReplayGameVersion;
|
||||
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 WotInspectorReplayGameVersion {
|
||||
name: string;
|
||||
package: string;
|
||||
}
|
||||
|
||||
export interface WotInspectorReplayPlayerData {
|
||||
team: number;
|
||||
name: string;
|
||||
entity_id: number;
|
||||
dbid: number;
|
||||
clanid: number | null;
|
||||
clan_tag: null | string;
|
||||
hitpoints_left: number;
|
||||
credits: number;
|
||||
exp: number;
|
||||
shots_made: number;
|
||||
shots_hit: number;
|
||||
shots_splash: number;
|
||||
shots_pen: number;
|
||||
damage_made: number;
|
||||
damage_received: number;
|
||||
damage_assisted: number;
|
||||
damage_assisted_track: number;
|
||||
hits_received: number;
|
||||
hits_bounced: number;
|
||||
hits_splash: number;
|
||||
hits_pen: number;
|
||||
enemies_spotted: number;
|
||||
enemies_damaged: number;
|
||||
enemies_destroyed: number;
|
||||
time_alive: number;
|
||||
distance_travelled: number;
|
||||
killed_by: number;
|
||||
base_capture_points: number;
|
||||
base_defend_points: number;
|
||||
exp_for_damage: number;
|
||||
exp_for_assist: number;
|
||||
exp_team_bonus: number;
|
||||
wp_points_earned: number;
|
||||
wp_points_stolen: number;
|
||||
hero_bonus_credits: number;
|
||||
hero_bonus_exp: number;
|
||||
death_reason: number;
|
||||
achievements: any[];
|
||||
vehicle_descr: number;
|
||||
turret_id: number;
|
||||
gun_id: number;
|
||||
chassis_id: number;
|
||||
squad_index: number;
|
||||
damage_blocked: number;
|
||||
}
|
||||
|
||||
function stat(value: string | number, flex = 2) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function playerListing(
|
||||
player: WotInspectorReplayPlayerData,
|
||||
protagonistTeam: boolean,
|
||||
recordingPlayer = false,
|
||||
) {
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const tank = awaitedTankDefinitions[player.vehicle_descr];
|
||||
const blockAccent = recordingPlayer ? '_amber' : '';
|
||||
const healthAccent = protagonistTeam
|
||||
? recordingPlayer
|
||||
? '_amber'
|
||||
: '_green'
|
||||
: '_red';
|
||||
|
||||
// our turret ids are always off by 1 or 2 lol so imma just grab the closest
|
||||
// TODO: 💀
|
||||
const turret = tank.turrets.sort(
|
||||
(a, b) =>
|
||||
Math.abs(a.id - player.turret_id) - Math.abs(b.id - player.turret_id),
|
||||
)[0];
|
||||
const totalHealth = turret.health + tank.health;
|
||||
const healthLeft = clamp(player.hitpoints_left / totalHealth, 0, 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.entity_id}
|
||||
style={{
|
||||
backgroundColor: theme.colors[`appBackground2${blockAccent}`],
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await iconPng(tankIcon(tank.id))}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// align
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textLowContrast${blockAccent}`],
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors[`textHighContrast${blockAccent}`],
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.colors[`textLowContrast${blockAccent}`],
|
||||
}}
|
||||
>
|
||||
{player.name}
|
||||
{player.clan_tag ? ` [${player.clan_tag}]` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{stat(player.damage_made.toLocaleString())}
|
||||
{stat((2830).toLocaleString())}
|
||||
{stat(player.exp.toLocaleString())}
|
||||
{stat(player.enemies_destroyed, 1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors[`componentInteractive${healthAccent}`],
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${healthLeft * 100}%`,
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors[`solidBackground${healthAccent}`],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const replayCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
resolve({
|
||||
inProduction: true,
|
||||
@@ -93,17 +356,85 @@ export const replayCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
|
||||
async handler(interaction) {
|
||||
const file = interaction.options.getAttachment('file')!;
|
||||
const url = `https://wotinspector.com/api/replay/upload/?url=${encodeURIComponent(
|
||||
file.url,
|
||||
)}`;
|
||||
const response = await fetch(url);
|
||||
const replay = (await response.json()) as WotInspectorReplayResponse;
|
||||
// const file = {
|
||||
// url: 'https://cdn.discordapp.com/ephemeral-attachments/1232434891652861972/1232781496016834581/20240328_1530__TresAbhi_M60_1157057448155406708.wotbreplay?ex=662ab4bc&is=6629633c&hm=c784cd10e80232350ddaf291fa987b879c4d0a8083b78298c9eee4fa0d507f6c&',
|
||||
// };
|
||||
const submissionData = (await fetch(
|
||||
`https://wotinspector.com/api/replay/upload/?url=${encodeURIComponent(
|
||||
file.url,
|
||||
)}`,
|
||||
).then((response) => response.json())) as WotInspectorReplaySubmission;
|
||||
|
||||
if (replay.status === 'error') {
|
||||
return `# Uh oh! WoTInspoec`
|
||||
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).`;
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
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 protagonistTeamId = replayData.players_data.find(
|
||||
(player) => player.dbid === replayData.protagonist,
|
||||
)!.team;
|
||||
const antagonistTeamId = 3 - protagonistTeamId;
|
||||
const players = replayData.players_data.sort(
|
||||
(a, b) => b.damage_made - a.damage_made,
|
||||
);
|
||||
|
||||
return [
|
||||
new RenderConfiguration({ width: 960 }),
|
||||
<CommandWrapper>
|
||||
<TitleBar
|
||||
title={replayData.player_name}
|
||||
description={`uwu <3 xoxo hugs and kicks`}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{await Promise.all(
|
||||
players
|
||||
.filter((player) => player.team === protagonistTeamId)
|
||||
.map((player) =>
|
||||
playerListing(
|
||||
player,
|
||||
true,
|
||||
player.dbid === replayData.protagonist,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{await Promise.all(
|
||||
players
|
||||
.filter((player) => player.team === antagonistTeamId)
|
||||
.map((player) => playerListing(player, false)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandWrapper>,
|
||||
buttonLink(submissionData.data.view_url, 'WoT Inspector'),
|
||||
buttonLink(submissionData.data.download_url, 'Download'),
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import resolveTankId from '../core/blitz/resolveTankId';
|
||||
import { asset } from '../core/blitzkit/asset';
|
||||
import { buildTechTreeLine } from '../core/blitzkit/buildTechTreeLine';
|
||||
import { emblemURL } from '../core/blitzkit/emblemURL';
|
||||
import { webpToPng } from '../core/blitzkit/iconPng';
|
||||
import { iconPng } from '../core/blitzkit/iconPng';
|
||||
import { resolveAncestry } from '../core/blitzkit/resolveAncestry';
|
||||
import { tankDefinitions } from '../core/blitzkit/tankDefinitions';
|
||||
import {
|
||||
@@ -280,7 +280,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(asset('icons/currencies/xp.webp'))}
|
||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
@@ -305,7 +305,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(asset('icons/currencies/silver.webp'))}
|
||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
@@ -330,7 +330,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(asset('icons/currencies/silver.webp'))}
|
||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
@@ -447,9 +447,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(
|
||||
asset('icons/currencies/xp.webp'),
|
||||
)}
|
||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
@@ -476,7 +474,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/free-xp.webp'),
|
||||
)}
|
||||
width={16}
|
||||
@@ -503,7 +501,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/silver.webp'),
|
||||
)}
|
||||
width={16}
|
||||
@@ -531,7 +529,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await webpToPng(
|
||||
src={await iconPng(
|
||||
asset('icons/currencies/silver.webp'),
|
||||
)}
|
||||
width={16}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
|
||||
flexDirection: 'column',
|
||||
padding: `16px ${fat ? 0 : 16}px`,
|
||||
gap: 16,
|
||||
width: 480,
|
||||
width: '100%',
|
||||
color: theme.colors.textHighContrast,
|
||||
position: 'relative',
|
||||
background: theme.colors.appBackground1,
|
||||
|
||||
@@ -2,7 +2,7 @@ import sharp from 'sharp';
|
||||
|
||||
const cache: Record<string, Buffer> = {};
|
||||
|
||||
export async function webpToPng(url: string) {
|
||||
export async function iconPng(url: string) {
|
||||
if (!url.endsWith('.webp')) return url;
|
||||
|
||||
if (!cache[url]) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import jsxToSvg from './jsxToSvg';
|
||||
import { RenderConfiguration } from './renderConfiguration';
|
||||
import { svgToPngThreaded } from './svgToPngThreaded';
|
||||
|
||||
export default async function jsxToPngThreaded(jsx: JSX.Element) {
|
||||
const svg = await jsxToSvg(jsx);
|
||||
export default async function jsxToPngThreaded(
|
||||
jsx: JSX.Element,
|
||||
renderConfiguration: RenderConfiguration,
|
||||
) {
|
||||
const svg = await jsxToSvg(jsx, renderConfiguration);
|
||||
const png = await svgToPngThreaded(svg);
|
||||
|
||||
return png;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'fs';
|
||||
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';
|
||||
@@ -10,9 +11,12 @@ const fonts = [
|
||||
{ file: robotoBlack, weight: 900 as const },
|
||||
];
|
||||
|
||||
export default async function jsxToSvg(jsx: JSX.Element) {
|
||||
export default async function jsxToSvg(
|
||||
jsx: JSX.Element,
|
||||
renderConfiguration: RenderConfiguration,
|
||||
) {
|
||||
return await satori(jsx, {
|
||||
width: 480,
|
||||
width: renderConfiguration.width,
|
||||
fonts: fonts.map(({ file, weight }) => ({
|
||||
weight,
|
||||
name: 'Roboto',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface RenderConfiguration {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class RenderConfiguration implements RenderConfiguration {
|
||||
width = 480;
|
||||
|
||||
constructor(configuration?: Partial<RenderConfiguration>) {
|
||||
Object.assign(this, configuration);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { webpToPng } from './iconPng';
|
||||
import { iconPng } from './iconPng';
|
||||
import { tankIcon } from './tankIcon';
|
||||
|
||||
export function tankIconPng(id: number) {
|
||||
return webpToPng(tankIcon(id));
|
||||
return iconPng(tankIcon(id));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'discord.js';
|
||||
import { InteractionReturnable } from '../../events/interactionCreate';
|
||||
import jsxToPngThreaded from '../blitzkit/jsxToPngThreaded';
|
||||
import { RenderConfiguration } from '../blitzkit/renderConfiguration';
|
||||
|
||||
export default async function normalizeInteractionReturnable(
|
||||
returnable: InteractionReturnable,
|
||||
@@ -17,11 +18,11 @@ export default async function normalizeInteractionReturnable(
|
||||
const reply: InteractionEditReplyOptions &
|
||||
InteractionReplyOptions &
|
||||
MessageEditOptions = {};
|
||||
|
||||
const awaitedReturnable = await returnable;
|
||||
const normalizedReturnable = Array.isArray(awaitedReturnable)
|
||||
? awaitedReturnable
|
||||
: [awaitedReturnable];
|
||||
let renderConfiguration = new RenderConfiguration();
|
||||
|
||||
await Promise.all(
|
||||
normalizedReturnable.map(async (item, index) => {
|
||||
@@ -39,10 +40,12 @@ export default async function normalizeInteractionReturnable(
|
||||
} else if (item instanceof AttachmentBuilder) {
|
||||
if (!reply.files) reply.files = [];
|
||||
reply.files.push(item);
|
||||
} else if (item instanceof RenderConfiguration) {
|
||||
renderConfiguration = item;
|
||||
} else if (item === null) {
|
||||
return;
|
||||
} else {
|
||||
const image = await jsxToPngThreaded(item);
|
||||
const image = await jsxToPngThreaded(item, renderConfiguration);
|
||||
images.push([index, image]);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -35,6 +35,7 @@ import { statsCommand } from '../../commands/stats';
|
||||
import { todayCommand } from '../../commands/today';
|
||||
import getClientId from '../../core/blitzkit/getClientId';
|
||||
import isDev from '../../core/blitzkit/isDev';
|
||||
import { RenderConfiguration } from '../../core/blitzkit/renderConfiguration';
|
||||
import { secrets } from '../../core/blitzkit/secrets';
|
||||
import handleAutocomplete from './handlers/autocomplete';
|
||||
import handleButton from './handlers/button';
|
||||
@@ -45,6 +46,7 @@ export type InteractionRawReturnable =
|
||||
| EmbedBuilder
|
||||
| ButtonBuilder
|
||||
| AttachmentBuilder
|
||||
| RenderConfiguration
|
||||
| JSX.Element
|
||||
| null;
|
||||
export type InteractionIterableReturnable =
|
||||
|
||||
Reference in new issue
Block a user