feat: dailystats

This commit is contained in:
TresAbhi committed 2023-05-25 18:20:51 +00:00
1 parent f729aee3de
commit e37a7947b4
18 files changed
+637 -153

No files matched your search

+81 -78
View File
@@ -1,7 +1,8 @@
import { SlashCommandBuilder } from 'discord.js';
import escapeHTML from 'escape-html';
import GenericStats from '../components/GenericStats/index.js';
import Screenshot from '../components/Screenshot.js';
import NoBattlesInPeriod from '../components/NoBattlesInPeriod.js';
import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
@@ -12,8 +13,8 @@ import getBlitzStarsAccount from '../core/blitzstars/getBlitzStarsAccount.js';
import cmdName from '../core/interaction/cmdName.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { args } from '../core/process/args.js';
import screenshotHTML from '../core/ui/screenshotHTML.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { browser } from '../index.js';
import { AccountInfo, AllStats } from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
import { BlitzStartsComputedPeriodicStatistics } from '../types/statistics.js';
@@ -29,7 +30,7 @@ const periodNames: Record<Period, string> = {
export default {
inProduction: true,
inDevelopment: true,
inDevelopment: false,
inPublic: true,
command: new SlashCommandBuilder()
@@ -72,8 +73,8 @@ export default {
const a2 = accountInfo[id].statistics;
const battles = a2.all.battles - a1.all.battles;
function pb(value: (allStats: AllStats) => number) {
return (value(a2.all) - value(a1.all)) / battles;
function max(value: (allStats: AllStats) => number) {
return Math.max(value(a2.all), value(a1.all));
}
function diff(value: (allStats: AllStats) => number) {
return value(a2.all) - value(a1.all);
@@ -82,18 +83,16 @@ export default {
stats = {
all: {
battles,
capture_points: pb((a) => a.capture_points),
damage_dealt: pb((a) => a.damage_dealt),
damage_received: pb((a) => a.damage_received),
capture_points: diff((a) => a.capture_points),
damage_dealt: diff((a) => a.damage_dealt),
damage_received: diff((a) => a.damage_received),
dropped_capture_points: diff((a) => a.dropped_capture_points),
frags: pb((a) => a.frags),
frags8p: pb((a) => a.frags8p),
frags: diff((a) => a.frags),
frags8p: diff((a) => a.frags8p),
hits: diff((a) => a.hits),
losses: diff((a) => a.losses),
max_frags: pb((a) => a.max_frags),
max_frags_tank_id: pb((a) => a.max_frags_tank_id),
max_xp: pb((a) => a.max_xp),
max_xp_tank_id: pb((a) => a.max_xp_tank_id),
max_frags: max((a) => a.max_frags),
max_xp: max((a) => a.max_xp),
shots: diff((a) => a.shots),
spotted: diff((a) => a.spotted),
survived_battles: diff((a) => a.survived_battles),
@@ -113,73 +112,77 @@ export default {
}
if (!stats) return;
const page = await browser.newPage();
await page.setViewportSize({ width: 640, height: 1 });
await page.setContent(
Screenshot(
Wrapper(
TitleBar(
escapeHTML(blitzStarsAccount.nickname),
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: 'https://i.imgur.com/uq6pKbh.png',
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : '',
`${periodNames[period]} • ${new Date().toDateString()} • ${
BLITZ_SERVERS[server]
}`,
),
GenericStats([
[
'Winrate',
`${(100 * (stats.all.wins / stats.all.battles)).toFixed(2)}%`,
],
['WN8', stats.wn8.toFixed(0)],
[
'Survival',
`${(
100 *
(stats.all.survived_battles / stats.all.battles)
).toFixed(2)}%`,
],
[
'Accuracy',
`${((stats.all.hits / stats.all.shots) * 100).toFixed(2)}%`,
],
['Battles', stats.all.battles],
['Wins', stats.all.wins],
['Losses', stats.all.losses],
[
'Average damage',
(stats.all.damage_dealt / stats.all.battles).toFixed(0),
],
['Average XP', (stats.all.xp / stats.all.battles).toFixed(0)],
['Average shots', (stats.all.shots / stats.all.battles).toFixed(2)],
['Average hits', (stats.all.hits / stats.all.battles).toFixed(2)],
['Average kills', (stats.all.frags / stats.all.battles).toFixed(2)],
[
'Average spots',
(stats.all.spotted / stats.all.battles).toFixed(2),
],
['Average tier', stats.avg_tier.toFixed(2)],
[
'Damage ratio',
(stats.all.damage_dealt / stats.all.damage_received).toFixed(2),
],
[
'Kills to death ratio',
(
stats.all.frags /
(stats.all.battles - stats.all.survived_battles)
).toFixed(2),
],
]),
const screenshot = await screenshotHTML(
Wrapper(
TitleBar(
escapeHTML(blitzStarsAccount.nickname),
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : '',
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined,
`${periodNames[period]} • ${new Date().toDateString()} • ${
BLITZ_SERVERS[server]
}`,
),
stats.all.battles === 0
? NoBattlesInPeriod()
: GenericStats([
[
'Winrate',
`${(100 * (stats.all.wins / stats.all.battles)).toFixed(2)}%`,
],
['WN8', stats.wn8.toFixed(0)],
[
'Survival',
`${(
100 *
(stats.all.survived_battles / stats.all.battles)
).toFixed(2)}%`,
],
[
'Accuracy',
`${((stats.all.hits / stats.all.shots) * 100).toFixed(2)}%`,
],
['Battles', stats.all.battles],
['Wins', stats.all.wins],
['Losses', stats.all.losses],
[
'Average damage',
(stats.all.damage_dealt / stats.all.battles).toFixed(0),
],
['Average XP', (stats.all.xp / stats.all.battles).toFixed(0)],
[
'Average shots',
(stats.all.shots / stats.all.battles).toFixed(2),
],
['Average hits', (stats.all.hits / stats.all.battles).toFixed(2)],
[
'Average kills',
(stats.all.frags / stats.all.battles).toFixed(2),
],
[
'Average spots',
(stats.all.spotted / stats.all.battles).toFixed(2),
],
['Average tier', stats.avg_tier.toFixed(2)],
[
'Damage ratio',
(stats.all.damage_dealt / stats.all.damage_received).toFixed(2),
],
[
'Kills to death ratio',
(
stats.all.frags /
(stats.all.battles - stats.all.survived_battles)
).toFixed(2),
],
]),
PoweredByBlitzStars(),
),
);
const screenshot = await page
.locator('#screenshot')
.screenshot({ omitBackground: true, scale: 'css' });
await page.close();
await interaction.editReply({ files: [screenshot] });
console.log(`Showing stats for ${blitzStarsAccount.nickname}`);
+114
View File
@@ -0,0 +1,114 @@
import { SlashCommandBuilder } from 'discord.js';
import escapeHTML from 'escape-html';
import Breakdown from '../components/Breakdown.js';
import NoBattlesInPeriod from '../components/NoBattlesInPeriod.js';
import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js';
import last5AM from '../core/blitzstars/last5AM.js';
import { tankopedia } from '../core/blitzstars/tankopedia.js';
import cmdName from '../core/interaction/cmdName.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { args } from '../core/process/args.js';
import screenshotHTML from '../core/ui/screenshotHTML.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { AccountInfo, AllStats } from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
import { TanksStats } from '../types/tanksStats.js';
import resolveTankName from '../utilities/resolveTankName.js';
export default {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('today'))
.setDescription('A general daily breakdown of your performance')
.addStringOption(addUsernameOption),
async execute(interaction) {
const username = interaction.options.getString('username')!;
const blitzAccount = await getBlitzAccount(interaction, username);
if (!blitzAccount) return;
const { id, server } = blitzAccount;
const tankStatsOverTime = await getTankStatsOverTime(
server,
id,
last5AM().getTime() / 1000,
new Date().getTime() / 1000,
);
if (!tankStatsOverTime) return;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${args['wargaming-application-id']}&account_id=${id}`,
);
if (!accountInfo) return;
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${args['wargaming-application-id']}&account_id=${id}&extra=clan`,
);
if (!clanData) return;
const careerTankStatsRaw = await getWargamingResponse<TanksStats>(
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${args['wargaming-application-id']}&account_id=${id}`,
);
if (!careerTankStatsRaw) return;
const careerStats: Record<number, AllStats> = {};
Object.entries(careerTankStatsRaw[id]).forEach(([, tankStats]) => {
careerStats[tankStats.tank_id] = tankStats.all;
});
const rows = Object.entries(tankStatsOverTime).map(
([tankId, tankStats]) => {
const career = careerStats[tankId as unknown as number];
return Breakdown.Row(
resolveTankName({
tank_id: tankId as unknown as number,
name: tankopedia.data[tankId as unknown as number].name,
}),
tankStats.wins / tankStats.battles,
career.wins / career.battles,
-Infinity,
-Infinity,
tankStats.damage_dealt / tankStats.battles,
career.damage_dealt / career.battles,
(tankStats.battles - tankStats.survived_battles) / tankStats.battles,
(career.battles - career.survived_battles) / career.battles,
tankStats.battles,
career.battles,
tankopedia.data[tankId as unknown as number].images.normal,
);
},
);
const screenshot = await screenshotHTML(
Wrapper(
TitleBar(
escapeHTML(accountInfo[id].nickname),
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : '',
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined,
`Today's breakdown • ${new Date().toDateString()} • ${
BLITZ_SERVERS[server]
}`,
),
rows.length === 0 ? NoBattlesInPeriod() : Breakdown.Root(...rows),
PoweredByBlitzStars(),
),
);
await interaction.editReply({ files: [screenshot] });
console.log(`Showing daily breakdown for ${accountInfo[id].nickname}`);
},
autocomplete: usernameAutocomplete,
} satisfies CommandRegistry;
+183
View File
@@ -0,0 +1,183 @@
import Children from './Children.js';
const Breakdown = {
Root(...children: string[]) {
return `
<style>
.breakdown {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
<div class="breakdown">
${Children(children)}
</div>
`;
},
Row(
name: string,
winrate: number,
careerWinrate: number,
wn8: number,
careerWn8: number,
averageDamage: number,
careerAverageDamage: number,
survival: number,
careerSurvival: number,
battles: number,
careerBattles: number,
icon?: string,
) {
return `
<style>
.breakdown-row {
display: flex;
gap: 16px;
}
</style>
<div class="breakdown-row">
${this.RowDiscriminator(name, icon)}
${this.RowStat(
'Winrate',
`${(winrate * 100).toFixed(2)}%`,
`${(careerWinrate * 100).toFixed(2)}%`,
winrate - careerWinrate,
)}
${this.RowStat(
'Damage',
averageDamage.toFixed(2),
careerAverageDamage.toFixed(2),
averageDamage - careerAverageDamage,
)}
${this.RowStat(
'Survival',
`${(survival * 100).toFixed(2)}%`,
`${(careerSurvival * 100).toFixed(2)}%`,
survival - careerSurvival,
)}
${this.RowStat('Battles', battles.toFixed(0), careerBattles.toFixed(0))}
</div>
`;
/*
${this.RowStat(
'WN8',
wn8.toFixed(0),
careerWn8.toFixed(0),
wn8 - careerWn8,
)}
*/
},
RowDiscriminator(name: string, icon?: string) {
return `
<style>
.breakdown-row-discriminator {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
width: 0px;
align-items: center;
justify-content: center;
}
.breakdown-row-discriminator-icon {
width: 100%;
flex: 1;
object-fit: cover;
}
.breakdown-row-discriminator-name {
color: #A0A0A0;
font-size: 16px;
text-align: center;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
</style>
<div class="breakdown-row-discriminator">
${
icon
? `<img src="${icon}" class="breakdown-row-discriminator-icon" />`
: ''
}
<span class="breakdown-row-discriminator-name">${name}</span>
</div>
`;
},
RowStat(name: string, value: string, career: string, delta?: number) {
return `
<style>
.breakdown-row-stat {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
flex: 1;
}
.breakdown-row-stat-career {
color: #A0A0A0;
font-size: 12px;
}
.breakdown-row-stat-name {
color: #A0A0A0;
font-size: 16px;
}
.breakdown-row-stat-value {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
}
.breakdown-row-stat-value-delta {
width: 12px;
height: 12px;
}
.breakdown-row-stat-value-text {
color: white;
font-size: 16px;
font-weight: bold;
}
</style>
<div class="breakdown-row-stat">
<span class="breakdown-row-stat-career">${career}</span>
<div class="breakdown-row-stat-value">
${
delta
? `<img src="${
(delta ?? 0) > 0
? 'https://i.imgur.com/qbjiXa1.png'
: 'https://i.imgur.com/3uyNhun.png'
}" class="breakdown-row-stat-value-delta" />`
: ''
}
<span class="breakdown-row-stat-value-text">${value}</span>
</div>
<span class="breakdown-row-stat-name">${name}</span>
</div>
`;
},
};
export default Breakdown;
+20
View File
@@ -0,0 +1,20 @@
export default function NoBattlesInPeriod() {
return `
<style>
.no-battles-in-period {
display: flex;
align-items: center;
justify-content: center;
}
.no-battles-in-period-text {
font-size: 16px;
color: #A0A0A0;
}
</style>
<div class="no-battles-in-period">
<span class="no-battles-in-period-text">No battles played in this period</span>
</div>
`;
}
+26
View File
@@ -0,0 +1,26 @@
export default function PoweredByBlitzStars() {
return `
<style>
.powered-by-blitz-stars {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.powered-by-blitz-stars-icon {
height: 16px;
width: 16px;
}
.powered-by-blitz-stars-text {
font-size: 16px;
color: #A0A0A0;
}
</style>
<div class="powered-by-blitz-stars">
<img class="powered-by-blitz-stars-icon" src="https://www.blitzstars.com/assets/images/4b4cb591.TankyMcPewpew.png" />
<span class="powered-by-blitz-stars-text">Powered by BlitzStars</span>
</div>
`;
}
@@ -1,6 +1,6 @@
import Children from './Children.js';
export default function Screenshot(...children: string[]) {
export default function Root(...children: string[]) {
return `
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:[email protected]');
@@ -14,12 +14,12 @@ export default function Screenshot(...children: string[]) {
font-family: 'Roboto Flex', sans-serif;
}
#screenshot {
#root {
display: flex;
}
</style>
<div id="screenshot">
<div id="root">
${Children(children)}
</div>
`;
+4 -4
View File
@@ -1,8 +1,8 @@
export default function TitleBar(
name: string,
image: string,
nameDiscriminator = '',
description = '',
nameDiscriminator: string,
image?: string,
description?: string,
) {
return `
<style>
@@ -51,7 +51,7 @@ export default function TitleBar(
<div class="title-bar">
<div class="title-bar-info">
<img class="title-bar-avatar" src="${image}" />
${image ? `<img class="title-bar-avatar" src="${image}" />` : ''}
<div class="title-bar-text">
<span class="title-bar-name">
+3 -1
View File
@@ -12,7 +12,9 @@ export default async function getWargamingResponse<Data extends object>(
if (parsed.status === 'ok') {
return parsed.data;
} else {
console.error(`Wargaming response status ${parsed.status}`);
console.error(
`Wargaming response status ${parsed.status} ${parsed.error.message}`,
);
return null;
}
}
+122
View File
@@ -0,0 +1,122 @@
import fetch from 'node-fetch';
import { BlitzServer } from '../../constants/servers.js';
import { AllStats } from '../../types/accountInfo.js';
import { TanksStats } from '../../types/tanksStats.js';
import getWargamingResponse from '../blitz/getWargamingResponse.js';
import { args } from '../process/args.js';
export interface TankHistoryNode {
all: AllStats;
last_battle_time: number;
mark_of_mastery: number;
battle_life_time: number;
account_id: number;
tank_id: number;
}
export const emptyTankHistoryNode: TankHistoryNode = {
account_id: 0,
battle_life_time: 0,
last_battle_time: 0,
mark_of_mastery: 0,
tank_id: 0,
all: {
battles: 0,
capture_points: 0,
damage_dealt: 0,
damage_received: 0,
dropped_capture_points: 0,
frags: 0,
frags8p: 0,
hits: 0,
losses: 0,
max_frags: 0,
max_xp: 0,
shots: 0,
spotted: 0,
survived_battles: 0,
xp: 0,
win_and_survived: 0,
wins: 0,
},
};
export type TankHistory = TankHistoryNode[];
export default async function getTankStatsOverTime(
server: BlitzServer,
id: number,
start = -Infinity,
end = Infinity,
) {
const latestTankStatsRaw = await getWargamingResponse<TanksStats>(
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${args['wargaming-application-id']}&account_id=${id}`,
);
if (!latestTankStatsRaw) return;
const tankHistoriesResponse = await fetch(
`https://www.blitzstars.com/api/tankhistories/for/${id}/`,
);
const tankHistories = (await tankHistoriesResponse.json()) as TankHistory;
const history = [...tankHistories, ...latestTankStatsRaw[id]];
// sort them even though most won't be used
const tankSortedHistory: Record<number, TankHistory> = {};
const tankIds: number[] = [];
history.forEach((node) => {
if (!tankSortedHistory[node.tank_id]) {
tankSortedHistory[node.tank_id] = [node];
tankIds.push(node.tank_id);
} else {
tankSortedHistory[node.tank_id].push(node);
}
});
// find the ones played within the range
const tankIdsInRange = tankIds.filter((tankId) =>
tankSortedHistory[tankId].some(
(node) => node.last_battle_time > start && node.last_battle_time < end,
),
);
// fetch history node right before "start" and right before "end"
const tanksBeforeAndAfter: Record<number, AllStats> = {};
tankIdsInRange.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 diff(get: (allStats: AllStats) => number) {
return get(latest.all) - get(previous.all);
}
function max(get: (allStats: AllStats) => number) {
return Math.max(get(latest.all), get(previous.all));
}
tanksBeforeAndAfter[tankId] = {
battles: diff((a) => a.battles),
capture_points: diff((a) => a.capture_points),
damage_dealt: diff((a) => a.damage_dealt),
damage_received: diff((a) => a.damage_received),
dropped_capture_points: diff((a) => a.dropped_capture_points),
frags: diff((a) => a.frags),
frags8p: diff((a) => a.frags8p),
hits: diff((a) => a.hits),
losses: diff((a) => a.losses),
max_frags: max((a) => a.max_frags),
max_xp: max((a) => a.max_xp),
shots: diff((a) => a.shots),
spotted: diff((a) => a.spotted),
survived_battles: diff((a) => a.survived_battles),
win_and_survived: diff((a) => a.win_and_survived),
wins: diff((a) => a.wins),
xp: diff((a) => a.xp),
};
});
return tanksBeforeAndAfter;
}
+11
View File
@@ -0,0 +1,11 @@
export default function last5AM() {
const fiveAM = new Date();
fiveAM.setHours(5, 0, 0, 0); // Set the time to 5:00 AM today
if (fiveAM > new Date()) {
// If it's now before 5:00 AM, go back one day
fiveAM.setDate(fiveAM.getDate() - 1);
}
return fiveAM;
}
+14
View File
@@ -0,0 +1,14 @@
import Root from '../../components/Root.js';
import { browser } from '../../index.js';
export default async function screenshotHTML(children: string) {
const page = await browser.newPage();
await page.setViewportSize({ width: 640, height: 1 });
await page.setContent(Root(children));
const screenshot = await page
.locator('#root')
.screenshot({ omitBackground: true, scale: 'css' });
await page.close();
return screenshot;
}
-2
View File
@@ -1,10 +1,8 @@
export interface AllStats {
spotted: number;
max_frags_tank_id: number;
hits: number;
frags: number;
max_xp: number;
max_xp_tank_id: number;
wins: number;
losses: number;
capture_points: number;
+17 -31
View File
@@ -1,33 +1,19 @@
import { AllStats } from './accountInfo.js';
export interface IndividualTankStats {
all: AllStats;
last_battle_time: number;
account_id: number;
max_xp: number;
in_garage_updated: number;
max_frags: number;
frags: number | null;
mark_of_mastery: number;
battle_life_time: number;
in_garage: number | null;
tank_id: number;
}
export type TanksStats = {
[accountId: number]: {
all: {
spotted: number;
hits: number;
frags: number;
max_xp: number;
wins: number;
losses: number;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
max_frags: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
};
last_battle_time: number;
account_id: number;
max_xp: number;
in_garage_updated: number;
max_frags: number;
frags: number | null;
mark_of_mastery: number;
battle_life_time: number;
in_garage: number | null;
tank_id: number;
}[];
[accountId: number]: IndividualTankStats[];
};
+6
View File
@@ -1,6 +1,12 @@
export type WargamingResponse<Data extends object> =
| {
status: 'error';
error: {
field: string;
message: string;
code: 402;
value: null;
};
}
| {
status: 'ok';