mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: swap core and _core
This commit is contained in:
1 parent
07e3660388
commit
b827f7f730
113 files changed
+254
-247
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import throwError from '../../LEGACY_core/node/throwError';
|
||||
|
||||
export type BlitzResponse<Data extends object> =
|
||||
| {
|
||||
status: 'error';
|
||||
error: { field: string; message: string; code: 402; value: null };
|
||||
}
|
||||
| {
|
||||
status: 'ok';
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export default async function fetchBlitz<Data extends object>(url: string) {
|
||||
const data = (await fetch(url).then((response) =>
|
||||
response.json(),
|
||||
)) as BlitzResponse<Data>;
|
||||
|
||||
if (data.status === 'ok') {
|
||||
return data.data;
|
||||
} else {
|
||||
throw throwError(
|
||||
`Wargaming response error status:"${data.status}"`,
|
||||
`Message: "${data.error.message}"\nURL: "${url}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from './fetchWargaming';
|
||||
import { normalizeIds } from './normalizeIds';
|
||||
|
||||
interface IndividualAccountAchievements {
|
||||
achievements: {
|
||||
[achievement: string]: number;
|
||||
};
|
||||
max_series: {
|
||||
[achievement: string]: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountAchievements {
|
||||
[id: number]: IndividualAccountAchievements;
|
||||
}
|
||||
|
||||
export async function getAccountAchievements<
|
||||
Ids extends number | number[],
|
||||
ReturnType = Ids extends number
|
||||
? IndividualAccountAchievements
|
||||
: IndividualAccountAchievements[],
|
||||
>(region: Region, ids: Ids) {
|
||||
const object = await fetchBlitz<AccountAchievements>(
|
||||
`https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
|
||||
ids,
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (typeof ids === 'number') {
|
||||
return object[ids as number] as ReturnType;
|
||||
} else {
|
||||
return ids.map((id) => object[id]) as ReturnType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from './fetchWargaming';
|
||||
import { normalizeIds } from './normalizeIds';
|
||||
|
||||
export interface AllStats {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SupplementaryStats {
|
||||
// if undefined, WN8 is not available for tanks without averages
|
||||
WN8?: number;
|
||||
tier?: number;
|
||||
}
|
||||
|
||||
export interface ClanStats {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
interface IndividualAccountInfo {
|
||||
account_id: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
private: null;
|
||||
last_battle_time: number;
|
||||
nickname: string;
|
||||
statistics: {
|
||||
all: AllStats;
|
||||
clan: ClanStats;
|
||||
frags: null | number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
[accountId: number]: IndividualAccountInfo;
|
||||
}
|
||||
|
||||
export async function getAccountInfo<
|
||||
Ids extends number | number[],
|
||||
ReturnType = Ids extends number
|
||||
? IndividualAccountInfo
|
||||
: IndividualAccountInfo[],
|
||||
>(region: Region, ids: Ids) {
|
||||
const object = await fetchBlitz<AccountInfo>(
|
||||
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
|
||||
ids,
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (typeof ids === 'number') {
|
||||
return object[ids as number] as ReturnType;
|
||||
} else {
|
||||
return ids.map((id) => object[id]) as ReturnType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from './fetchWargaming';
|
||||
import { normalizeExtra } from './normalizeExtra';
|
||||
import { normalizeIds } from './normalizeIds';
|
||||
|
||||
export type IndividualClanAccountInfo = {
|
||||
account_id: number;
|
||||
joined_at: number;
|
||||
clan_id: number;
|
||||
role: string;
|
||||
account_name: string;
|
||||
|
||||
clan?: {
|
||||
members_count: number;
|
||||
name: string;
|
||||
created_at: number;
|
||||
tag: string;
|
||||
clan_id: number;
|
||||
emblem_set_id: number;
|
||||
};
|
||||
} | null;
|
||||
|
||||
export interface ClanAccountInfo {
|
||||
[accountId: number]: IndividualClanAccountInfo;
|
||||
}
|
||||
|
||||
export async function getClanAccountInfo<
|
||||
Ids extends number | number[],
|
||||
ReturnType = Ids extends number
|
||||
? IndividualClanAccountInfo
|
||||
: IndividualClanAccountInfo[],
|
||||
>(region: Region, ids: Ids, extra?: string[]) {
|
||||
const object = await fetchBlitz<ClanAccountInfo>(
|
||||
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
|
||||
ids,
|
||||
)}${normalizeExtra(extra)}`,
|
||||
);
|
||||
|
||||
if (typeof ids === 'number') {
|
||||
return object[ids as number] as ReturnType;
|
||||
} else {
|
||||
return ids.map((id) => object[id]) as ReturnType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import fetchBlitz from './fetchWargaming';
|
||||
import { normalizeIds } from './normalizeIds';
|
||||
|
||||
export interface IndividualClanInfo {
|
||||
recruiting_options: {
|
||||
vehicles_level: number;
|
||||
wins_ratio: number;
|
||||
average_battles_per_day: number;
|
||||
battles: number;
|
||||
average_damage: number;
|
||||
};
|
||||
members_count: number;
|
||||
name: string;
|
||||
creator_name: string;
|
||||
clan_id: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
leader_name: string;
|
||||
members_ids: number[];
|
||||
recruiting_policy: string;
|
||||
tag: string;
|
||||
is_clan_disbanded: boolean;
|
||||
old_name: string;
|
||||
emblem_set_id: number;
|
||||
creator_id: number;
|
||||
motto: string;
|
||||
renamed_at: number;
|
||||
old_tag: null;
|
||||
leader_id: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ClanInfo {
|
||||
[clanId: number]: IndividualClanInfo;
|
||||
}
|
||||
|
||||
export async function getClanInfo<
|
||||
Ids extends number | number[],
|
||||
ReturnType = Ids extends number ? IndividualClanInfo : IndividualClanInfo[],
|
||||
>(region: Region, ids: Ids) {
|
||||
const object = await fetchBlitz<ClanInfo>(
|
||||
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
|
||||
ids,
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (typeof ids === 'number') {
|
||||
return object[ids as number] as ReturnType;
|
||||
} else {
|
||||
return ids.map((id) => object[id]) as ReturnType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RatingsInfo } from '../../commands/ratings';
|
||||
import { Region } from '../../constants/regions';
|
||||
import regionToRegionSubdomain from '../../LEGACY_core/blitz/regionToRegionSubdomain';
|
||||
|
||||
export default async function getRatingsInfo(region: Region) {
|
||||
const response = await fetch(
|
||||
`https://${regionToRegionSubdomain(
|
||||
region,
|
||||
)}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
||||
);
|
||||
|
||||
return (await response.json()) as Promise<RatingsInfo>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import throwError from '../../LEGACY_core/node/throwError';
|
||||
import { TanksStats } from '../../types/tanksStats';
|
||||
import fetchBlitz from './fetchWargaming';
|
||||
|
||||
export default async function getTankStats(region: Region, id: number) {
|
||||
const tankStats = await fetchBlitz<TanksStats>(
|
||||
`https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
|
||||
);
|
||||
|
||||
if (tankStats[id] === null) {
|
||||
throw throwError(
|
||||
'No tank stats available',
|
||||
"This player doesn't have any stats for tanks available. This may not be the player you are looking for.",
|
||||
);
|
||||
}
|
||||
|
||||
return tankStats[id];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TreeTypeEnum } from '../../components/Tanks';
|
||||
import { tankopedia } from '../../LEGACY_core/blitz/tankopedia';
|
||||
|
||||
export default async function getTreeType(id: number) {
|
||||
const entry = (await tankopedia)[id];
|
||||
|
||||
if (entry?.is_collectible) return TreeTypeEnum.Collector;
|
||||
if (entry?.is_premium) return TreeTypeEnum.Premium;
|
||||
|
||||
return TreeTypeEnum.TechTree;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Percentile } from '../../constants/percentiles';
|
||||
|
||||
export const WN8_PERCENTILES: [number, Percentile][] = [
|
||||
[0, Percentile.VeryBad],
|
||||
[300, Percentile.Bad],
|
||||
[450, Percentile.BelowAverage],
|
||||
[650, Percentile.Average],
|
||||
[900, Percentile.AboveAverage],
|
||||
[1200, Percentile.Good],
|
||||
[1600, Percentile.VeryGood],
|
||||
[2000, Percentile.Great],
|
||||
[2450, Percentile.Unicum],
|
||||
[2900, Percentile.SuperUnicum],
|
||||
];
|
||||
|
||||
export default function getWN8Percentile(WN8: number) {
|
||||
const lastIndex = WN8_PERCENTILES.findLastIndex(
|
||||
([WN8Listing]) => WN8Listing <= WN8,
|
||||
);
|
||||
return WN8_PERCENTILES[lastIndex === -1 ? 0 : lastIndex][1];
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import fetchBlitz from '../../_core/blitz/fetchWargaming';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import { Clan, ClanList } from '../../types/clanList';
|
||||
|
||||
export type ClanListWithRegion = (Clan & {
|
||||
region: 'com' | 'eu' | 'asia';
|
||||
})[];
|
||||
|
||||
export default async function listClans(search: string, limit = 9) {
|
||||
const trimmed = search.trim();
|
||||
if (trimmed.length < 3 && trimmed.length > 100) return [];
|
||||
const normalizedLimit = Math.round(limit / 3);
|
||||
|
||||
return (
|
||||
await Promise.all([
|
||||
fetchBlitz<ClanList>(
|
||||
`https://api.wotblitz.com/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'com' as Region,
|
||||
})),
|
||||
),
|
||||
fetchBlitz<ClanList>(
|
||||
`https://api.wotblitz.eu/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'eu' as Region,
|
||||
})),
|
||||
),
|
||||
fetchBlitz<ClanList>(
|
||||
`https://api.wotblitz.asia/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'asia' as Region,
|
||||
})),
|
||||
),
|
||||
])
|
||||
)
|
||||
.filter(Boolean)
|
||||
.flat();
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import fetchBlitz from '../../_core/blitz/fetchWargaming';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
|
||||
interface Account {
|
||||
nickname: string;
|
||||
account_id: number;
|
||||
}
|
||||
export type AccountList = Account[];
|
||||
export type AccountListWithServer = (Account & {
|
||||
region: Region;
|
||||
})[];
|
||||
|
||||
export const usernamePattern = /^[a-zA-Z0-9_]{3,24}$/;
|
||||
export const usernamePatternWithoutPosition = /[a-zA-Z0-9_]{3,24}/;
|
||||
|
||||
export default async function listPlayers(search: string, limit = 9) {
|
||||
const trimmedSearch = search.trim();
|
||||
const normalizedLimit = Math.round(limit / 3);
|
||||
const encodedSearch = encodeURIComponent(trimmedSearch);
|
||||
|
||||
if (usernamePattern.test(trimmedSearch)) {
|
||||
return (
|
||||
await Promise.all([
|
||||
fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.com/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'com' as Region,
|
||||
})),
|
||||
),
|
||||
fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.eu/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'eu' as Region,
|
||||
})),
|
||||
),
|
||||
fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.asia/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
|
||||
).then(
|
||||
(value) =>
|
||||
value &&
|
||||
value.map((account) => ({
|
||||
...account,
|
||||
region: 'asia' as Region,
|
||||
})),
|
||||
),
|
||||
])
|
||||
)
|
||||
.filter(Boolean)
|
||||
.flat() as AccountListWithServer;
|
||||
} else {
|
||||
return [] as AccountListWithServer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeExtra(extra?: string[]) {
|
||||
return extra ? `&extra=${extra.join(',')}` : '';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeIds(ids: number | number[]): string {
|
||||
return typeof ids === 'number' ? ids.toString() : ids.join(',');
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Thanks, August!
|
||||
* https://stackoverflow.com/a/41358305/12294756
|
||||
*/
|
||||
const ROMAN = {
|
||||
M: 1000,
|
||||
CM: 900,
|
||||
D: 500,
|
||||
CD: 400,
|
||||
C: 100,
|
||||
XC: 90,
|
||||
L: 50,
|
||||
XL: 40,
|
||||
X: 10,
|
||||
IX: 9,
|
||||
V: 5,
|
||||
IV: 4,
|
||||
I: 1,
|
||||
};
|
||||
const ROMAN_KEYS = Object.keys(ROMAN) as (keyof typeof ROMAN)[];
|
||||
|
||||
export default function numberToRomanNumeral(value: number) {
|
||||
let string = '';
|
||||
|
||||
ROMAN_KEYS.forEach((index) => {
|
||||
const part = Math.floor(value / ROMAN[index]);
|
||||
value -= part * ROMAN[index];
|
||||
string += index.repeat(part);
|
||||
});
|
||||
|
||||
return string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Region, RegionSubdomain } from '../../constants/regions';
|
||||
|
||||
export default function regionToRegionSubdomain(
|
||||
regionDomain: Region,
|
||||
): RegionSubdomain {
|
||||
return regionDomain === 'com' ? 'na' : regionDomain;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { go } from 'fuzzysort';
|
||||
import throwError from '../node/throwError';
|
||||
import { TANKS, tankopedia } from './tankopedia';
|
||||
|
||||
export default async function resolveTankId(tank: string) {
|
||||
const number = parseInt(tank);
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
const searchResult = go(tank, await TANKS, { keys: ['name'], limit: 1 });
|
||||
|
||||
if (searchResult.length === 0) {
|
||||
throw throwError(
|
||||
'Tank not found',
|
||||
`Could not find tank by the name "${tank}".`,
|
||||
);
|
||||
} else {
|
||||
return searchResult[0].obj.tank_id;
|
||||
}
|
||||
} else {
|
||||
if ((await tankopedia)[number]) {
|
||||
return number;
|
||||
} else {
|
||||
throw throwError(
|
||||
'Tank not found',
|
||||
`Could not find tank by the ID "${number}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { tankopedia } from './tankopedia';
|
||||
|
||||
export default async function resolveTankName(tankId: number) {
|
||||
const tankopediaName = (await tankopedia)[tankId]?.name;
|
||||
return tankopediaName ? tankopediaName : `Unknown Tank ${tankId}`;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AllStats } from '../../_core/blitz/getAccountInfo';
|
||||
import { emptyAllStats } from '../blitzstars/getDiffedTankStats';
|
||||
|
||||
export default function sumStats(allStatsCollection: AllStats[]) {
|
||||
return allStatsCollection.reduce((accumulated, current) => {
|
||||
function sum(value: (allStats: AllStats) => number) {
|
||||
return value(accumulated) + value(current);
|
||||
}
|
||||
function max(value: (allStats: AllStats) => number) {
|
||||
return Math.max(value(accumulated), value(current));
|
||||
}
|
||||
|
||||
return {
|
||||
battles: sum((a) => a.battles),
|
||||
wins: sum((a) => a.wins),
|
||||
capture_points: sum((a) => a.capture_points),
|
||||
damage_dealt: sum((a) => a.damage_dealt),
|
||||
damage_received: sum((a) => a.damage_received),
|
||||
dropped_capture_points: sum((a) => a.dropped_capture_points),
|
||||
frags: sum((a) => a.frags),
|
||||
frags8p: sum((a) => a.frags8p),
|
||||
hits: sum((a) => a.hits),
|
||||
losses: sum((a) => a.losses),
|
||||
max_frags: max((a) => a.max_frags),
|
||||
max_xp: max((a) => a.max_xp),
|
||||
shots: sum((a) => a.shots),
|
||||
spotted: sum((a) => a.spotted),
|
||||
survived_battles: sum((a) => a.survived_battles),
|
||||
win_and_survived: sum((a) => a.win_and_survived),
|
||||
xp: sum((a) => a.xp),
|
||||
} satisfies AllStats;
|
||||
}, emptyAllStats);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { deburr } from 'lodash';
|
||||
import fetchBlitz from '../../_core/blitz/fetchWargaming';
|
||||
import { TreeTypeString } from '../../components/Tanks';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import { context } from '../blitzkrieg/context';
|
||||
|
||||
export interface TankopediaEntry {
|
||||
name: string;
|
||||
nation: string;
|
||||
is_premium: boolean;
|
||||
is_collectible: boolean;
|
||||
tier: Tier;
|
||||
cost: { price_credit: number; price_gold: number };
|
||||
images: { preview: string; normal: string };
|
||||
tank_id: number;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
type asd = TreeTypeString;
|
||||
export interface Tankopedia {
|
||||
[id: number]: TankopediaEntry | undefined;
|
||||
}
|
||||
|
||||
console.log('Caching tankopedia...');
|
||||
export const tankopedia = fetch(
|
||||
context === 'bot'
|
||||
? 'https://www.blitzstars.com/bs-tankopedia.json'
|
||||
: '/api/tankopedia',
|
||||
)
|
||||
.then(async (response) => response.json())
|
||||
.then((wrapperTankopedia) => {
|
||||
console.log('Tankopedia cached');
|
||||
return context === 'bot'
|
||||
? (wrapperTankopedia as { data: Tankopedia }).data
|
||||
: (wrapperTankopedia as Tankopedia);
|
||||
});
|
||||
const entries = new Promise<TankopediaEntry[]>(async (resolve) => {
|
||||
resolve(Object.entries(await tankopedia).map(([, entry]) => entry));
|
||||
});
|
||||
export const TANKS = new Promise<TankopediaEntry[]>(async (resolve) => {
|
||||
resolve((await entries).map((entry) => entry));
|
||||
});
|
||||
export const TANK_NAMES = new Promise<string[]>(async (resolve) => {
|
||||
resolve((await entries).map(({ name }) => name));
|
||||
});
|
||||
export const TANK_NAMES_DIACRITICS = TANK_NAMES.then((tankNames) =>
|
||||
Promise.all(
|
||||
tankNames.map(async (tankName, index) => {
|
||||
const id = (await TANKS)[index].tank_id;
|
||||
const name = tankName ?? `Unknown Tank ${id}`;
|
||||
const diacriticless = deburr(name);
|
||||
|
||||
return {
|
||||
original: name,
|
||||
diacriticless,
|
||||
combined: `${name}${diacriticless}`,
|
||||
id,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export interface TankopediaInfo {
|
||||
achievement_sections: Record<string, { name: string; order: number }>;
|
||||
tanks_updated_at: number;
|
||||
languages: Record<string, string>;
|
||||
vehicle_types: Record<string, string>;
|
||||
vehicle_nations: Record<string, string>;
|
||||
game_version: string;
|
||||
}
|
||||
|
||||
// this is blocking because info is needed for command creation
|
||||
console.log('Caching tankopedia info...');
|
||||
export const tankopediaInfo = fetchBlitz<TankopediaInfo>(
|
||||
`https://api.wotblitz.com/wotb/encyclopedia/info/?application_id=${WARGAMING_APPLICATION_ID}`,
|
||||
);
|
||||
console.log('Cached tankopedia info');
|
||||
|
||||
export const TANK_ICONS: Record<string, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/BIHSEH0.png',
|
||||
lightTank: 'https://i.imgur.com/CSNha5V.png',
|
||||
mediumTank: 'https://i.imgur.com/wvf3ltm.png',
|
||||
heavyTank: 'https://i.imgur.com/ECeqlZa.png',
|
||||
};
|
||||
|
||||
export const TANK_ICONS_PREMIUM: Record<string, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/TCu3EdR.png',
|
||||
lightTank: 'https://i.imgur.com/zdkpTRb.png',
|
||||
mediumTank: 'https://i.imgur.com/3z7eHX6.png',
|
||||
heavyTank: 'https://i.imgur.com/P3vbmyA.png',
|
||||
};
|
||||
|
||||
export const TANK_ICONS_COLLECTOR: Record<string, string> = {
|
||||
'AT-SPG': 'https://i.imgur.com/WTjeirB.png',
|
||||
lightTank: 'https://i.imgur.com/EwhtKkU.png',
|
||||
mediumTank: 'https://i.imgur.com/u8YDMBh.png',
|
||||
heavyTank: 'https://i.imgur.com/8xRf3nc.png',
|
||||
};
|
||||
|
||||
export type Tier = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
|
||||
export const TIER_ROMAN_NUMERALS: Record<Tier, string> = {
|
||||
1: 'I',
|
||||
2: 'II',
|
||||
3: 'III',
|
||||
4: 'IV',
|
||||
5: 'V',
|
||||
6: 'VI',
|
||||
7: 'VII',
|
||||
8: 'VIII',
|
||||
9: 'IX',
|
||||
10: 'X',
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
|
||||
export const STAT_PROPERTIES = [
|
||||
{ name: 'Time', value: 'time' },
|
||||
|
||||
{ name: 'Battles', value: 'battles' },
|
||||
{ name: 'Maximum XP', value: 'max_xp' },
|
||||
{ name: 'Maximum kills', value: 'max_frags' },
|
||||
|
||||
{ name: 'Accumulated spots', value: 'spotted' },
|
||||
{ name: 'Accumulated hits', value: 'hits' },
|
||||
{ name: 'Accumulated kills', value: 'frags' },
|
||||
{ name: 'Accumulated wins', value: 'wins' },
|
||||
{ name: 'Accumulated losses', value: 'losses' },
|
||||
{ name: 'Accumulated capture points', value: 'capture_points' },
|
||||
{ name: 'Accumulated damage dealt', value: 'damage_dealt' },
|
||||
{ name: 'Accumulated damage received', value: 'damage_received' },
|
||||
{ name: 'Accumulated shots', value: 'shots' },
|
||||
{ name: 'Accumulated kills above tier 8', value: 'frags8p' },
|
||||
{ name: 'Accumulated XP', value: 'xp' },
|
||||
{
|
||||
name: 'Accumulated won and survived battles',
|
||||
value: 'win_and_survived',
|
||||
},
|
||||
{ name: 'Accumulated survived battles', value: 'survived_battles' },
|
||||
{
|
||||
name: 'Accumulated dropped capture points',
|
||||
value: 'dropped_capture_points',
|
||||
},
|
||||
|
||||
{ name: 'Average spots', value: 'average_spotted' },
|
||||
{ name: 'Average hits', value: 'average_hits' },
|
||||
{ name: 'Average kills', value: 'average_frags' },
|
||||
{ name: 'Average winrate', value: 'average_wins' },
|
||||
{ name: 'Average loss rate', value: 'average_losses' },
|
||||
{ name: 'Average capture points', value: 'average_capture_points' },
|
||||
{ name: 'Average damage dealt', value: 'average_damage_dealt' },
|
||||
{ name: 'Average damage received', value: 'average_damage_received' },
|
||||
{ name: 'Average shots', value: 'average_shots' },
|
||||
{ name: 'Average kills above tier 8', value: 'average_frags8p' },
|
||||
{ name: 'Average XP', value: 'average_xp' },
|
||||
{
|
||||
name: 'Average win and survival rate',
|
||||
value: 'average_win_and_survived',
|
||||
},
|
||||
{ name: 'Average survival rate', value: 'average_survived_battles' },
|
||||
|
||||
{ name: 'Average WN8', value: 'average_wn8' },
|
||||
{ name: 'Average damage ratio', value: 'average_damage_ratio' },
|
||||
{
|
||||
name: 'Average kills to death ratio',
|
||||
value: 'average_kills_to_death_ratio',
|
||||
},
|
||||
];
|
||||
|
||||
export function addStatPropertyOptions(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('stat-property')
|
||||
.setDescription('A specific statistical property')
|
||||
.setAutocomplete(true);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
type Context = 'website' | 'bot';
|
||||
|
||||
export const context: Context =
|
||||
typeof window === 'undefined' ? 'bot' : 'website';
|
||||
@@ -0,0 +1,3 @@
|
||||
export function emblemIdToURL(emblemId: number) {
|
||||
return `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${emblemId}.png`;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { StatFilters } from '../../_core/statistics/filterStats';
|
||||
import { TREE_TYPE_NAMES } from '../../components/Tanks';
|
||||
import resolveTankName from '../blitz/resolveTankName';
|
||||
import { TIER_ROMAN_NUMERALS, Tier, tankopediaInfo } from '../blitz/tankopedia';
|
||||
|
||||
export async function filtersToDescription({
|
||||
nation,
|
||||
tier,
|
||||
tankType,
|
||||
treeType,
|
||||
tank,
|
||||
}: StatFilters) {
|
||||
const awaitedTankopediaInfo = await tankopediaInfo;
|
||||
const info: string[] = [];
|
||||
|
||||
if (tank) return await resolveTankName(tank);
|
||||
if (nation) info.push(awaitedTankopediaInfo.vehicle_nations[nation]);
|
||||
if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`);
|
||||
if (tankType) info.push(awaitedTankopediaInfo.vehicle_types[tankType]);
|
||||
if (treeType) info.push(TREE_TYPE_NAMES[treeType]);
|
||||
|
||||
return info.length === 0 ? 'No filters' : info.join(', ');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { RatingsInfo } from '../../commands/ratings';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { DATABASE_REPO } from '../../LEGACY_core/database/getMidnightLeaderboard';
|
||||
import { octokit } from '../../LEGACY_core/github/octokit';
|
||||
import throwError from '../../LEGACY_core/node/throwError';
|
||||
|
||||
const ARCHIVED_RATINGS_CACHE: Record<
|
||||
Region,
|
||||
Record<
|
||||
number,
|
||||
RatingsInfo & {
|
||||
detail: undefined;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
com: {},
|
||||
eu: {},
|
||||
asia: {},
|
||||
};
|
||||
|
||||
export default async function getArchivedRatingsInfo(
|
||||
season: number,
|
||||
region: Region,
|
||||
) {
|
||||
if (ARCHIVED_RATINGS_CACHE[region][season]) {
|
||||
return ARCHIVED_RATINGS_CACHE[region][season];
|
||||
}
|
||||
|
||||
const { data } = await octokit.repos.getContent({
|
||||
...DATABASE_REPO,
|
||||
path: `${region}/ratings/${season}/info.json`,
|
||||
});
|
||||
|
||||
if (Array.isArray(data) || data.type !== 'file')
|
||||
throw throwError('Archived ratings info is malformed');
|
||||
|
||||
const content = Buffer.from(data.content, 'base64').toString();
|
||||
const jsonContent = JSON.parse(content) as RatingsInfo & {
|
||||
detail: undefined;
|
||||
};
|
||||
|
||||
ARCHIVED_RATINGS_CACHE[region][season] = jsonContent;
|
||||
|
||||
return jsonContent;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { AllStats } from '../../_core/blitz/getAccountInfo';
|
||||
import { TierWeightsRecord } from '../../components/TierWeights';
|
||||
import { tankopedia } from '../blitz/tankopedia';
|
||||
|
||||
export async function getTierWeights(
|
||||
stats: Record<number, AllStats>,
|
||||
order: number[] = Object.keys(stats).map(parseInt),
|
||||
) {
|
||||
const awaitedTankopedia = await tankopedia;
|
||||
const tierWeights: TierWeightsRecord = {};
|
||||
|
||||
order.map((id) => {
|
||||
const entry = awaitedTankopedia[id];
|
||||
|
||||
if (!entry) return;
|
||||
if (!tierWeights[entry.tier]) tierWeights[entry.tier] = 0;
|
||||
|
||||
tierWeights[entry.tier]! += stats[id].battles;
|
||||
});
|
||||
|
||||
return tierWeights;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getAccountInfo } from '../../_core/blitz/getAccountInfo';
|
||||
import { Region } from '../../constants/regions';
|
||||
import linkButton from '../discord/linkButton';
|
||||
|
||||
export async function getBlitzStarsLinkButton(
|
||||
region: Region,
|
||||
id: number,
|
||||
tankId?: number,
|
||||
) {
|
||||
const { nickname } = await getAccountInfo(region, id);
|
||||
|
||||
return linkButton(
|
||||
`https://www.blitzstars.com/player/${region}/${nickname}${
|
||||
tankId ? `/tank/${tankId!}` : ''
|
||||
}`,
|
||||
'BlitzStars',
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { AllStats } from '../../_core/blitz/getAccountInfo';
|
||||
import { Region } from '../../constants/regions';
|
||||
import getTankHistories, {
|
||||
TankHistories,
|
||||
TankHistoryRaw,
|
||||
} from './getTankHistories';
|
||||
|
||||
export const emptyAllStats: AllStats = {
|
||||
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 const emptyTankHistoryNode: TankHistoryRaw = {
|
||||
account_id: 0,
|
||||
battle_life_time: 0,
|
||||
last_battle_time: 0,
|
||||
mark_of_mastery: 0,
|
||||
tank_id: 0,
|
||||
all: emptyAllStats,
|
||||
};
|
||||
|
||||
export interface DiffedTankStats {
|
||||
diff: Record<number, AllStats>;
|
||||
order: number[];
|
||||
}
|
||||
|
||||
// BIG TODO: merge this neatly with diffNormalizedTankStats
|
||||
export default async function getDiffedTankStats(
|
||||
server: Region,
|
||||
id: number,
|
||||
start: number,
|
||||
end: number,
|
||||
) {
|
||||
const history = await getTankHistories(server, id, {
|
||||
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, AllStats> = {};
|
||||
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: AllStats) => number) {
|
||||
return get(latest.all) - get(previous.all);
|
||||
}
|
||||
|
||||
// check if there was a change in battles as games in ratings 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,3 +0,0 @@
|
||||
export default function getPeriodNow() {
|
||||
return Date.now() / 1000;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { PeriodSize } from '../discord/addPeriodSubCommands';
|
||||
import getTimeDaysAgo from './getTimeDaysAgo';
|
||||
|
||||
export default function getPeriodStart(region: Region, period: PeriodSize) {
|
||||
if (period === 'career') return 0;
|
||||
if (period === 'today') return getTimeDaysAgo(region, 1);
|
||||
return getTimeDaysAgo(region, parseInt(period));
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { getAccountInfo } from '../../_core/blitz/getAccountInfo';
|
||||
import { PlayerStats } from '../../_core/blitzstars/getPlayerStats';
|
||||
import { Region } from '../../constants/regions';
|
||||
import {
|
||||
GetHistoriesOptions,
|
||||
Histories,
|
||||
History,
|
||||
getHistoriesDefaultOptions,
|
||||
} from '../../types/histories';
|
||||
import { emptyAllStats } from './getDiffedTankStats';
|
||||
|
||||
export default 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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { AllStats } from '../blitz/getAccountInfo';
|
||||
|
||||
export interface IndividualPlayerStats {
|
||||
clan: { clan_id: number; name: string; tag: string };
|
||||
statistics: {
|
||||
all: AllStats;
|
||||
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,113 +0,0 @@
|
||||
import getTankStats from '../../_core/blitz/getTankStats';
|
||||
import { Region } from '../../constants/regions';
|
||||
import {
|
||||
GetHistoriesOptions,
|
||||
Histories,
|
||||
History,
|
||||
getHistoriesDefaultOptions,
|
||||
} from '../../types/histories';
|
||||
import { emptyAllStats } from './getDiffedTankStats';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
|
||||
...getHistoriesDefaultOptions,
|
||||
tankId: undefined,
|
||||
};
|
||||
|
||||
export default async function getTankHistories(
|
||||
server: Region,
|
||||
id: number,
|
||||
options?: Partial<GetTankHistoriesOptions>,
|
||||
) {
|
||||
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) {
|
||||
(await getTankStats(server, id)).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,20 +0,0 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
|
||||
export const TIME_ZONE_MAPPINGS: Record<Region, number> = {
|
||||
com: -5, // Central North American Time
|
||||
eu: +1, // Central European Time
|
||||
asia: +7, // Central Asia Standard Time
|
||||
};
|
||||
|
||||
export default function getTimeDaysAgo(region: Region, daysAgo: number) {
|
||||
const now = new Date();
|
||||
if (daysAgo === 0) now.getTime() / 1000;
|
||||
|
||||
const time = new Date();
|
||||
|
||||
time.setUTCHours(-TIME_ZONE_MAPPINGS[region], 0, 0, 0);
|
||||
if (time > now) time.setUTCDate(time.getUTCDate() - 1);
|
||||
time.setUTCDate(time.getUTCDate() - daysAgo + 1);
|
||||
|
||||
return time.getTime() / 1000;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { AllStats } from '../../_core/blitz/getAccountInfo';
|
||||
import { context } from '../blitzkrieg/context';
|
||||
|
||||
export interface SpecialStats {
|
||||
winrate: number;
|
||||
damageRatio: number;
|
||||
kdr: number;
|
||||
damagePerBattle: number;
|
||||
killsPerBattle: number;
|
||||
hitsPerBattle: number;
|
||||
spotsPerBattle: number;
|
||||
wpm: number;
|
||||
dpm: number;
|
||||
kpm: number;
|
||||
hitRate: number;
|
||||
survivalRate: number;
|
||||
}
|
||||
|
||||
export type Percentiles = [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
export interface SpecialPercentiles {
|
||||
winRate: Percentiles;
|
||||
damageRatio: Percentiles;
|
||||
kdr: Percentiles;
|
||||
damagePerBattle: Percentiles;
|
||||
killsPerBattle: Percentiles;
|
||||
hitsPerBattle: Percentiles;
|
||||
spotsPerBattle: Percentiles;
|
||||
wpm: Percentiles;
|
||||
dpm: Percentiles;
|
||||
kpm: Percentiles;
|
||||
hitRate: Percentiles;
|
||||
survivalRate: Percentiles;
|
||||
}
|
||||
|
||||
export interface MeanSd {
|
||||
winrateMean: number;
|
||||
winrateSd: number;
|
||||
hitRateMean: number;
|
||||
hitRateSd: number;
|
||||
survivalRateMean: number;
|
||||
survivalRateSd: number;
|
||||
damageRatioMean: number;
|
||||
damageRatioSd: number;
|
||||
kdrMean: number;
|
||||
kdrSd: number;
|
||||
dpbMean: number;
|
||||
dpbSd: number;
|
||||
kpbMean: number;
|
||||
kpbSd: number;
|
||||
hpbMean: number;
|
||||
hpbSd: number;
|
||||
spbMean: number;
|
||||
spbSd: number;
|
||||
}
|
||||
|
||||
export interface IndividualTankAverage {
|
||||
_id: number;
|
||||
battle_life_time: number;
|
||||
total_battle_life_time: number;
|
||||
number_of_players: number;
|
||||
tank_id: number;
|
||||
all: AllStats;
|
||||
special: SpecialStats;
|
||||
percentiles: SpecialPercentiles;
|
||||
percentiles_player_count: number;
|
||||
meanSd: MeanSd;
|
||||
shotEff: number;
|
||||
last_update: number;
|
||||
}
|
||||
|
||||
export type TankAverages = Record<number, IndividualTankAverage>;
|
||||
|
||||
console.log('Caching tank averages...');
|
||||
export const tankAverages = fetch(
|
||||
context === 'bot'
|
||||
? 'https://www.blitzstars.com/api/tankaverages.json'
|
||||
: '/api/tank-averages',
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
if (context === 'bot') {
|
||||
const partialTankAverages: TankAverages = {};
|
||||
|
||||
(json as IndividualTankAverage[]).forEach((individualTankAverage) => {
|
||||
partialTankAverages[individualTankAverage.tank_id] =
|
||||
individualTankAverage;
|
||||
});
|
||||
|
||||
console.log('Tank averages cached');
|
||||
|
||||
return partialTankAverages;
|
||||
} else {
|
||||
return json as TankAverages;
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Region } from '../../constants/regions';
|
||||
|
||||
export const discordBlitzDB = new PrismaClient();
|
||||
|
||||
export function linkBlitzAndDiscord(
|
||||
discord: number,
|
||||
region: Region,
|
||||
blitz: number,
|
||||
) {
|
||||
return discordBlitzDB.discord_blitz.upsert({
|
||||
where: { discord },
|
||||
update: { region, blitz },
|
||||
create: { discord, region, blitz },
|
||||
});
|
||||
}
|
||||
|
||||
export function getBlitzFromDiscord(discord: number) {
|
||||
return discordBlitzDB.discord_blitz.findUnique({
|
||||
where: { discord },
|
||||
select: { region: true, blitz: true },
|
||||
}) as Promise<null | { region: Region; blitz: number }>;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { BlitzkriegRatingsLeaderboard } from '../../../scripts/buildRatingsLeaderboard';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { octokit } from '../github/octokit';
|
||||
|
||||
export const DATABASE_REPO = { owner: 'tresabhi', repo: 'blitzkrieg-db' };
|
||||
|
||||
export default async function getMidnightLeaderboard(
|
||||
region: Region,
|
||||
season: number,
|
||||
) {
|
||||
try {
|
||||
const { data } = await octokit.repos.getContent({
|
||||
...DATABASE_REPO,
|
||||
path: `${region}/ratings/${season}/midnight.json`,
|
||||
});
|
||||
|
||||
if (!Array.isArray(data) && data.type === 'file') {
|
||||
const response = await fetch(data.download_url!);
|
||||
|
||||
return (await response.json()) as BlitzkriegRatingsLeaderboard;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`No midnight leaderboard found for season ${season}, region ${region}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
|
||||
export default function addClanChoices(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('clan')
|
||||
.setDescription('The clan name or tag you are checking')
|
||||
.setAutocomplete(true)
|
||||
.setMinLength(2)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
|
||||
export type PeriodType = 'today' | '30' | '60' | '90' | 'career' | 'custom';
|
||||
export type PeriodSize = 'today' | `${number}` | 'career';
|
||||
|
||||
export default function addPeriodSubCommands(
|
||||
option: SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
|
||||
extra: (
|
||||
option: SlashCommandSubcommandBuilder,
|
||||
) => SlashCommandSubcommandBuilder = (option) => option,
|
||||
) {
|
||||
option
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('today').setDescription('Today')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('30').setDescription('30 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('60').setDescription('60 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('90').setDescription('90 days')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(option.setName('career').setDescription('Career')),
|
||||
)
|
||||
.addSubcommand((option) =>
|
||||
extra(
|
||||
option
|
||||
.setName('custom')
|
||||
.setDescription('Custom range')
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('start')
|
||||
.setDescription('Start in number of days ago from today')
|
||||
.setRequired(true),
|
||||
)
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('end')
|
||||
.setDescription('End in number of days')
|
||||
.setRequired(true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { REGION_NAMES } from '../../constants/regions';
|
||||
|
||||
export default function addRegionChoices(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('region')
|
||||
.setDescription('The Blitz region you are in')
|
||||
.addChoices(
|
||||
{ name: REGION_NAMES.com, value: 'com' },
|
||||
{ name: REGION_NAMES.eu, value: 'eu' },
|
||||
{ name: REGION_NAMES.asia, value: 'asia' },
|
||||
)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
SlashCommandSubcommandGroupBuilder,
|
||||
} from 'discord.js';
|
||||
import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks';
|
||||
import { tankopediaInfo } from '../blitz/tankopedia';
|
||||
import addPeriodSubCommands from './addPeriodSubCommands';
|
||||
import addTankChoices from './addTankChoices';
|
||||
import addTierChoices from './addTierChoices';
|
||||
|
||||
export default async function addStatTypeSubCommands<
|
||||
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
|
||||
>(
|
||||
option: OptionType,
|
||||
extra: (
|
||||
option: SlashCommandSubcommandBuilder,
|
||||
) => SlashCommandSubcommandBuilder = (option) => option,
|
||||
): Promise<OptionType> {
|
||||
const awaitedTankopediaInfo = await tankopediaInfo;
|
||||
|
||||
addPeriodSubCommands(option, (option) => {
|
||||
extra(option);
|
||||
|
||||
return option
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('nation')
|
||||
.setDescription('Nation')
|
||||
.addChoices(
|
||||
...Object.entries(awaitedTankopediaInfo.vehicle_nations).map(
|
||||
([type, name]) => ({ name, value: type }),
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) => addTierChoices(option).setRequired(false))
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('tank-type')
|
||||
.setDescription('Tank type')
|
||||
.addChoices(
|
||||
...Object.entries(awaitedTankopediaInfo.vehicle_types).map(
|
||||
([type, name]) => ({
|
||||
name: name.replace(' Tank', ''),
|
||||
value: type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('tree-type')
|
||||
.setDescription('Tech tree type')
|
||||
.addChoices(
|
||||
{
|
||||
name: TREE_TYPE_NAMES['tech-tree'],
|
||||
value: 'tech-tree' satisfies TreeTypeString,
|
||||
},
|
||||
{
|
||||
name: TREE_TYPE_NAMES.premium,
|
||||
value: 'premium' satisfies TreeTypeString,
|
||||
},
|
||||
{
|
||||
name: TREE_TYPE_NAMES.collector,
|
||||
value: 'collector' satisfies TreeTypeString,
|
||||
},
|
||||
)
|
||||
.setRequired(false),
|
||||
)
|
||||
.addStringOption((option) => addTankChoices(option).setRequired(false));
|
||||
});
|
||||
|
||||
return option;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
|
||||
export default function addTankChoices(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('tank')
|
||||
.setDescription('The tank to get stats for')
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
import { range } from 'lodash';
|
||||
import numberToRomanNumeral from '../blitz/numberToRomanNumeral';
|
||||
|
||||
export default function addTierChoices(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('tier')
|
||||
.setDescription('The tier you want to see')
|
||||
.setChoices(
|
||||
...range(10, 0).map((tier) => ({
|
||||
name: `Tier ${tier} - ${numberToRomanNumeral(tier)}`,
|
||||
value: `${tier}`,
|
||||
})),
|
||||
)
|
||||
.setRequired(true);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { SlashCommandStringOption } from 'discord.js';
|
||||
|
||||
export default function addUsernameChoices(option: SlashCommandStringOption) {
|
||||
return option
|
||||
.setName('username')
|
||||
.setDescription(
|
||||
'The username you use in Blitz (inferred from Discord nickname by default)',
|
||||
)
|
||||
.setAutocomplete(true);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
ApplicationCommandOptionChoiceData,
|
||||
AutocompleteInteraction,
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { REGION_NAMES } from '../../constants/regions';
|
||||
import listClans from '../blitz/listClans';
|
||||
|
||||
export default async function autocompleteClan(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== 'clan') return;
|
||||
if (focusedOption.value.length < 2) return interaction.respond([]);
|
||||
const players = await listClans(focusedOption.value);
|
||||
|
||||
await interaction.respond(
|
||||
players
|
||||
? players.map(
|
||||
(player) =>
|
||||
({
|
||||
name: `${player.name} (${REGION_NAMES[player.region]})`,
|
||||
value: `${player.region}/${player.clan_id}`,
|
||||
}) satisfies ApplicationCommandOptionChoiceData<string>,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { AutocompleteInteraction, CacheType } from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import { STAT_PROPERTIES } from '../blitzkrieg/addStatPropertyOptions';
|
||||
|
||||
export default async function autocompleteStatProperty(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
name = 'stat-property',
|
||||
) {
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== name) return;
|
||||
|
||||
await interaction.respond(
|
||||
focusedOption.value
|
||||
? go(focusedOption.value, STAT_PROPERTIES, {
|
||||
keys: ['name'],
|
||||
limit: 10,
|
||||
}).map(({ obj }) => obj)
|
||||
: STAT_PROPERTIES.slice(0, 25),
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
ApplicationCommandOptionChoiceData,
|
||||
AutocompleteInteraction,
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import { TANK_NAMES_DIACRITICS } from '../blitz/tankopedia';
|
||||
|
||||
export default async function autocompleteTanks(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== 'tank') return;
|
||||
|
||||
await interaction.respond(
|
||||
focusedOption.value
|
||||
? await Promise.all(
|
||||
go(focusedOption.value, await TANK_NAMES_DIACRITICS, {
|
||||
keys: ['combined'],
|
||||
limit: 10,
|
||||
}).map(
|
||||
async (item) =>
|
||||
({
|
||||
name: item.obj.original,
|
||||
value: `${item.obj.id}`,
|
||||
}) satisfies ApplicationCommandOptionChoiceData<string>,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
ApplicationCommandOptionChoiceData,
|
||||
AutocompleteInteraction,
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { REGION_NAMES } from '../../constants/regions';
|
||||
import listPlayers from '../blitz/listPlayers';
|
||||
|
||||
export default async function autocompleteUsername(
|
||||
interaction: AutocompleteInteraction<CacheType>,
|
||||
) {
|
||||
const focusedOption = interaction.options.getFocused(true);
|
||||
if (focusedOption.name !== 'username') return;
|
||||
const players = await listPlayers(focusedOption.value);
|
||||
|
||||
try {
|
||||
await interaction.respond(
|
||||
players
|
||||
? players.map(
|
||||
(player) =>
|
||||
({
|
||||
name: `${player.nickname} (${REGION_NAMES[player.region]})`,
|
||||
value: `${player.region}/${player.account_id}`,
|
||||
}) satisfies ApplicationCommandOptionChoiceData<string>,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Client, GatewayIntentBits } from 'discord.js';
|
||||
import guildMemberAdd from '../../events/guildMemberAdd';
|
||||
import interactionCreate from '../../events/interactionCreate';
|
||||
import ready from '../../events/ready';
|
||||
|
||||
export const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
|
||||
})
|
||||
.on('ready', ready)
|
||||
.on('guildMemberAdd', guildMemberAdd)
|
||||
.on('interactionCreate', interactionCreate);
|
||||
@@ -1,12 +0,0 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { ACCENT_COLOR } from '../../constants/colors';
|
||||
|
||||
export default function embedInfo(title: string, description: string) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(ACCENT_COLOR)
|
||||
.setDescription(description);
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
|
||||
return embed;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { NEGATIVE_COLOR } from '../../constants/colors';
|
||||
|
||||
export default function embedNegative(title: string, description: string) {
|
||||
return new EmbedBuilder()
|
||||
.setColor(NEGATIVE_COLOR)
|
||||
.setTitle(title)
|
||||
.setDescription(description);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { POSITIVE_COLOR } from '../../constants/colors';
|
||||
|
||||
export default function embedPositive(title: string, description: string) {
|
||||
return new EmbedBuilder()
|
||||
.setColor(POSITIVE_COLOR)
|
||||
.setTitle(title)
|
||||
.setDescription(description);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { WARNING_COLOR } from '../../constants/colors';
|
||||
|
||||
export default function embedWarning(title: string, description: string) {
|
||||
return new EmbedBuilder()
|
||||
.setColor(WARNING_COLOR)
|
||||
.setTitle(title)
|
||||
.setDescription(description);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { PeriodType } from './addPeriodSubCommands';
|
||||
|
||||
export function getCustomPeriodParams(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
) {
|
||||
if ((interaction.options.getSubcommand() as PeriodType) === 'custom')
|
||||
return {
|
||||
start: interaction.options.getInteger('start', true),
|
||||
end: interaction.options.getInteger('end', true),
|
||||
};
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ButtonInteraction } from 'discord.js';
|
||||
import { StatFilters } from '../../_core/statistics/filterStats';
|
||||
import { TreeTypeString } from '../../components/Tanks';
|
||||
|
||||
export function getFiltersFromButton(interaction: ButtonInteraction) {
|
||||
const url = new URL(`https://example.com/${interaction.customId}`);
|
||||
|
||||
return {
|
||||
nation: url.searchParams.get('nation') ?? undefined,
|
||||
tank: parseInt(url.searchParams.get('tank') ?? '0') || undefined,
|
||||
tankType: url.searchParams.get('tank-type') ?? undefined,
|
||||
treeType:
|
||||
(url.searchParams.get('tree-type') as TreeTypeString | undefined) ??
|
||||
undefined,
|
||||
tier: parseInt(url.searchParams.get('tier') ?? '0') || undefined,
|
||||
} satisfies StatFilters;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { StatFilters } from '../../_core/statistics/filterStats';
|
||||
import { TreeTypeString } from '../../components/Tanks';
|
||||
|
||||
export function getFiltersFromCommand(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
) {
|
||||
return {
|
||||
nation: interaction.options.getString('nation') ?? undefined,
|
||||
tier: parseInt(interaction.options.getString('tier') ?? '0') || undefined,
|
||||
tankType: interaction.options.getString('tank-type') ?? undefined,
|
||||
treeType:
|
||||
(interaction.options.getString('tree-type') as
|
||||
| TreeTypeString
|
||||
| undefined) ?? undefined,
|
||||
tank: parseInt(interaction.options.getString('tank') ?? '0') || undefined,
|
||||
} satisfies StatFilters;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
|
||||
|
||||
export interface Parameters {
|
||||
[key: string]: string | number | boolean | undefined | null;
|
||||
}
|
||||
|
||||
export default function interactionToURL(
|
||||
interaction: ChatInputCommandInteraction<CacheType>,
|
||||
parameters?: Parameters,
|
||||
) {
|
||||
const pathname = [
|
||||
interaction.commandName,
|
||||
interaction.options.getSubcommandGroup(false),
|
||||
interaction.options.getSubcommand(false),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
const params = (parameters ? Object.entries(parameters) : [])
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
|
||||
return `${pathname}${params.length > 0 ? `?${params}` : ''}`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ComponentEmojiResolvable,
|
||||
} from 'discord.js';
|
||||
|
||||
export default function linkButton(
|
||||
url: string,
|
||||
label: string,
|
||||
emoji?: ComponentEmojiResolvable,
|
||||
) {
|
||||
const button = new ButtonBuilder()
|
||||
.setStyle(ButtonStyle.Link)
|
||||
.setURL(url)
|
||||
.setLabel(label);
|
||||
|
||||
if (emoji) button.setEmoji(emoji);
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type TableInputEntry = [string, string | number] | [];
|
||||
export type TableInput = TableInputEntry[];
|
||||
|
||||
export default function markdownTable(input: TableInput) {
|
||||
return input
|
||||
.filter((array) => array[1] !== undefined)
|
||||
.map((array) => (array.length === 0 ? '' : `**${array[0]}**: ${array[1]}`))
|
||||
.join('\n');
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
AttachmentBuilder,
|
||||
ButtonBuilder,
|
||||
EmbedBuilder,
|
||||
InteractionEditReplyOptions,
|
||||
} from 'discord.js';
|
||||
import { InteractionReturnable } from '../../events/interactionCreate';
|
||||
import jsxToPng from '../node/jsxToPng';
|
||||
|
||||
export default async function normalizeInteractionReturnable(
|
||||
returnable: InteractionReturnable,
|
||||
) {
|
||||
const reply: InteractionEditReplyOptions = {};
|
||||
|
||||
const awaitedReturnable = await returnable;
|
||||
const normalizedReturnable = Array.isArray(awaitedReturnable)
|
||||
? awaitedReturnable
|
||||
: [awaitedReturnable];
|
||||
|
||||
await Promise.all(
|
||||
normalizedReturnable.map(async (item) => {
|
||||
if (typeof item === 'string') {
|
||||
reply.content = item;
|
||||
} else if (item instanceof EmbedBuilder) {
|
||||
if (!reply.embeds) reply.embeds = [];
|
||||
reply.embeds.push(item);
|
||||
} else if (item instanceof ButtonBuilder) {
|
||||
if (!reply.components)
|
||||
reply.components = [new ActionRowBuilder<ButtonBuilder>()];
|
||||
(reply.components[0] as ActionRowBuilder<ButtonBuilder>).addComponents(
|
||||
item,
|
||||
);
|
||||
} else if (item instanceof AttachmentBuilder) {
|
||||
if (!reply.files) reply.files = [];
|
||||
reply.files.push(item);
|
||||
} else if (item === null) {
|
||||
return;
|
||||
} else {
|
||||
const image = await jsxToPng(item);
|
||||
if (!reply.files) reply.files = [];
|
||||
reply.files.push(image);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return reply;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ComponentEmojiResolvable,
|
||||
} from 'discord.js';
|
||||
|
||||
export default function primaryButton(
|
||||
id: string,
|
||||
label: string,
|
||||
emoji?: ComponentEmojiResolvable,
|
||||
) {
|
||||
const button = new ButtonBuilder()
|
||||
.setCustomId(id)
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setLabel(label);
|
||||
|
||||
if (emoji) button.setEmoji(emoji);
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface PSAData {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PSA {
|
||||
data: PSAData | null;
|
||||
}
|
||||
|
||||
export const psa: PSA = { data: null };
|
||||
|
||||
console.log('Caching PSA...');
|
||||
fetch(
|
||||
'https://gist.githubusercontent.com/tresabhi/ed4b136f08856e615212e573aabe1968/raw',
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
psa.data = data as PSAData;
|
||||
console.log('PSA cached');
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ChatInputCommandInteraction } from 'discord.js';
|
||||
import primaryButton from './primaryButton';
|
||||
|
||||
export function refreshButton(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
path: string,
|
||||
) {
|
||||
if (
|
||||
interaction.appPermissions?.has('AttachFiles') &&
|
||||
interaction.appPermissions?.has('ViewChannel')
|
||||
) {
|
||||
return primaryButton(path, 'Refresh');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import listClans from '../blitz/listClans';
|
||||
import throwError from '../node/throwError';
|
||||
import { serverAndIdPattern } from './resolvePlayerFromCommand';
|
||||
|
||||
export default async function resolveClanFromCommand(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
) {
|
||||
const clan = interaction.options.getString('clan', true);
|
||||
|
||||
if (serverAndIdPattern.test(clan)) {
|
||||
const [server, accountId] = clan.split('/');
|
||||
return { region: server as Region, id: Number(accountId) };
|
||||
} else {
|
||||
const accounts = await listClans(clan);
|
||||
|
||||
if (accounts[0]) {
|
||||
return { region: accounts[0].region, id: accounts[0].clan_id };
|
||||
} else {
|
||||
throw throwError(
|
||||
'Could not find clan',
|
||||
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ButtonInteraction } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import getPeriodNow from '../blitzstars/getPeriodNow';
|
||||
import getPeriodStart from '../blitzstars/getPeriodStart';
|
||||
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
|
||||
import { PeriodType } from './addPeriodSubCommands';
|
||||
import {
|
||||
ResolvedPeriod,
|
||||
getPeriodOptionName,
|
||||
} from './resolvePeriodFromCommand';
|
||||
|
||||
export default function resolvePeriodFromButton(
|
||||
region: Region,
|
||||
interaction: ButtonInteraction,
|
||||
) {
|
||||
let name: string;
|
||||
let start: number;
|
||||
let end: number;
|
||||
const url = new URL(`https://example.com/${interaction.customId}`);
|
||||
const path = url.pathname.split('/');
|
||||
const periodSubcommand = path.at(-1) as PeriodType;
|
||||
|
||||
if (periodSubcommand === 'custom') {
|
||||
const startRaw = parseInt(url!.searchParams.get('start')!);
|
||||
const endRaw = parseInt(url!.searchParams.get('end')!);
|
||||
const startDaysAgoMin = Math.min(startRaw, endRaw);
|
||||
const endDaysAgoMax = Math.max(startRaw, endRaw);
|
||||
|
||||
name = `${startDaysAgoMin} - ${endDaysAgoMax} days`;
|
||||
start = getTimeDaysAgo(region, endDaysAgoMax);
|
||||
end = getTimeDaysAgo(region, startDaysAgoMin);
|
||||
} else {
|
||||
name = getPeriodOptionName(periodSubcommand);
|
||||
start = getPeriodStart(region, periodSubcommand);
|
||||
end = getPeriodNow();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
start,
|
||||
end,
|
||||
} satisfies ResolvedPeriod;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import getPeriodNow from '../blitzstars/getPeriodNow';
|
||||
import getPeriodStart from '../blitzstars/getPeriodStart';
|
||||
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
|
||||
import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands';
|
||||
|
||||
export function getPeriodOptionName(period: PeriodSize) {
|
||||
if (period === 'career') return 'Career';
|
||||
if (period === 'today') return 'Today';
|
||||
return `${period} days`;
|
||||
}
|
||||
|
||||
export interface ResolvedPeriod {
|
||||
name: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export default function resolvePeriodFromCommand(
|
||||
region: Region,
|
||||
interaction: ChatInputCommandInteraction<CacheType>,
|
||||
) {
|
||||
let name: string;
|
||||
let start: number;
|
||||
let end: number;
|
||||
const periodSubcommand = interaction.options.getSubcommand() as PeriodType;
|
||||
|
||||
if (periodSubcommand === 'custom') {
|
||||
const startOption = interaction.options.getInteger('start', true);
|
||||
const endOption = interaction.options.getInteger('end', true);
|
||||
const startDaysAgoMin = Math.min(startOption, endOption);
|
||||
const endDaysAgoMax = Math.max(startOption, endOption);
|
||||
|
||||
name = `${startDaysAgoMin} - ${endDaysAgoMax} days`;
|
||||
start = getTimeDaysAgo(region, endDaysAgoMax);
|
||||
end = getTimeDaysAgo(region, startDaysAgoMin);
|
||||
} else {
|
||||
name = getPeriodOptionName(periodSubcommand);
|
||||
start = getPeriodStart(region, periodSubcommand);
|
||||
end = getPeriodNow();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
start,
|
||||
end,
|
||||
} satisfies ResolvedPeriod;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ButtonInteraction } from 'discord.js';
|
||||
import { Region } from '../../constants/regions';
|
||||
import { ResolvedPlayer } from './resolvePlayerFromCommand';
|
||||
|
||||
export default async function resolvePlayerFromButton(
|
||||
interaction: ButtonInteraction,
|
||||
) {
|
||||
const url = new URL(`https://exmaple.com/${interaction.customId}`);
|
||||
|
||||
return {
|
||||
id: parseInt(url.searchParams.get('id')!),
|
||||
region: url.searchParams.get('region') as Region,
|
||||
} satisfies ResolvedPlayer;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { Region } from '../../constants/regions';
|
||||
import listPlayers from '../blitz/listPlayers';
|
||||
import { getBlitzFromDiscord } from '../cockroackdb/discordBlitz';
|
||||
import throwError from '../node/throwError';
|
||||
|
||||
export const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
|
||||
|
||||
export interface ResolvedPlayer {
|
||||
region: Region;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export default async function resolvePlayerFromCommand(
|
||||
interaction: ChatInputCommandInteraction<CacheType>,
|
||||
) {
|
||||
const commandUsername = interaction.options.getString('username');
|
||||
|
||||
if (commandUsername) {
|
||||
if (serverAndIdPattern.test(commandUsername)) {
|
||||
const [server, accountId] = commandUsername.split('/');
|
||||
|
||||
return {
|
||||
region: server as Region,
|
||||
id: Number(accountId),
|
||||
} satisfies ResolvedPlayer;
|
||||
} else {
|
||||
const accounts = await listPlayers(commandUsername);
|
||||
|
||||
if (accounts[0]) {
|
||||
return {
|
||||
region: accounts[0].region,
|
||||
id: accounts[0].account_id,
|
||||
} satisfies ResolvedPlayer;
|
||||
} else {
|
||||
throw throwError(
|
||||
'Could not find user',
|
||||
`I couldn't find user "${markdownEscape(
|
||||
commandUsername,
|
||||
)}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const account = await getBlitzFromDiscord(parseInt(interaction.user.id));
|
||||
|
||||
if (account) {
|
||||
return { region: account.region, id: account.blitz };
|
||||
} else {
|
||||
throw throwError(
|
||||
"You're not linked",
|
||||
'Use the `/link` command to link your Discord and Blitz accounts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { secrets } from '../node/secrets';
|
||||
|
||||
export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
|
||||
@@ -1,6 +0,0 @@
|
||||
import discord from '../../../discord.json' assert { type: 'json' };
|
||||
import isDev from './isDev';
|
||||
|
||||
export default function getClientId() {
|
||||
return isDev() ? discord.client_id_dev : discord.client_id;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function isDev() {
|
||||
return process.env.NODE_ENV === 'development';
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function isNumber(value: any) {
|
||||
return typeof value === 'number' && isFinite(value);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import jsxToSvg from './jsxToSvg';
|
||||
import svgToPng from './svgToPng';
|
||||
|
||||
export default async function jsxToPng(jsx: JSX.Element) {
|
||||
const svg = await jsxToSvg(jsx);
|
||||
const png = svgToPng(svg);
|
||||
|
||||
return png;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { readFile } from 'fs';
|
||||
import { join } from 'path';
|
||||
import satori from 'satori';
|
||||
import robotoBlackFile from '../../assets/fonts/Roboto-Black.ttf';
|
||||
import robotoBoldFile from '../../assets/fonts/Roboto-Bold.ttf';
|
||||
import robotoFile from '../../assets/fonts/Roboto.ttf';
|
||||
|
||||
const FONT_NAME = 'Roboto';
|
||||
const FONT_FILES = [robotoFile, robotoBoldFile, robotoBlackFile];
|
||||
|
||||
let roboto: Buffer;
|
||||
let robotoBold: Buffer;
|
||||
let robotoBlack: Buffer;
|
||||
|
||||
console.log('Importing fonts...');
|
||||
Promise.all(
|
||||
FONT_FILES.map(
|
||||
(file) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const path = join(__dirname, file);
|
||||
|
||||
readFile(path, (error, data) => {
|
||||
if (error) reject(error);
|
||||
resolve(data);
|
||||
});
|
||||
}),
|
||||
),
|
||||
).then((data) => {
|
||||
[roboto, robotoBold, robotoBlack] = data;
|
||||
console.log('Fonts imported');
|
||||
});
|
||||
|
||||
export default async function jsxToSvg(jsx: JSX.Element) {
|
||||
return await satori(jsx, {
|
||||
width: 480,
|
||||
fonts: [
|
||||
{ data: roboto, name: FONT_NAME, weight: 400 },
|
||||
{ data: robotoBold, name: FONT_NAME, weight: 700 },
|
||||
{ data: robotoBlack, name: FONT_NAME, weight: 900 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { discordBlitzDB } from '../cockroackdb/discordBlitz';
|
||||
import { client } from '../discord/client';
|
||||
|
||||
const EXIT_EVENTS = [
|
||||
'beforeExit',
|
||||
'exit',
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
'SIGUSR1',
|
||||
'SIGUSR2',
|
||||
];
|
||||
|
||||
async function cleanup() {
|
||||
console.log('Cleaning up...');
|
||||
|
||||
await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]);
|
||||
|
||||
console.log('Gracefully exiting...');
|
||||
|
||||
EXIT_EVENTS.forEach((event) => process.off(event, cleanup));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export function registerProcesses() {
|
||||
process.on('uncaughtException', console.error);
|
||||
client.on('error', console.error);
|
||||
|
||||
EXIT_EVENTS.forEach((event) => process.on(event, cleanup));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
|
||||
export const SECRET_KEYS = [
|
||||
'DISCORD_TOKEN',
|
||||
'GH_TOKEN',
|
||||
'DISCORD_BLITZ_DB',
|
||||
] as const;
|
||||
|
||||
export type SECRET = typeof SECRET_KEYS extends readonly (infer T)[]
|
||||
? T
|
||||
: never;
|
||||
|
||||
export const secrets = SECRET_KEYS.reduce<Partial<Record<SECRET, string>>>(
|
||||
(accumulator, key) => ({
|
||||
...accumulator,
|
||||
|
||||
get [key](): string {
|
||||
if (!(key in process.env)) throw new Error(`Secret ${key} not provided`);
|
||||
return process.env[key]!;
|
||||
},
|
||||
}),
|
||||
{},
|
||||
) as Record<SECRET, string>;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
|
||||
export default function svgToPng(svg: string) {
|
||||
return new Resvg(svg).render().asPng();
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function throwError(message: string, cause?: string) {
|
||||
return new Error(message, { cause });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AllStats } from '../blitz/getAccountInfo';
|
||||
|
||||
/**
|
||||
* Formula from https://web.archive.org/web/20190105064817/http://wiki.wnefficiency.net/pages/WN8
|
||||
*
|
||||
* @param expected what all players on average perform like
|
||||
* @param average what the player in question performs like
|
||||
*/
|
||||
export default function calculateWN8(expected: AllStats, average: AllStats) {
|
||||
const avgDmg = average.damage_dealt / average.battles;
|
||||
const avgSpot = average.spotted / average.battles;
|
||||
const avgFrag = average.frags / average.battles;
|
||||
const avgDef = average.dropped_capture_points / average.battles;
|
||||
const avgWinRate = average.wins / average.battles;
|
||||
|
||||
const expDmg = expected.damage_dealt / expected.battles;
|
||||
const expSpot = expected.spotted / expected.battles;
|
||||
const expFrag = expected.frags / expected.battles;
|
||||
const expDef = expected.dropped_capture_points / expected.battles;
|
||||
const expWinRate = expected.wins / expected.battles;
|
||||
|
||||
const rDAMAGE = avgDmg / expDmg;
|
||||
const rSPOT = avgSpot / expSpot;
|
||||
const rFRAG = avgFrag / expFrag;
|
||||
const rDEF = avgDef / expDef;
|
||||
const rWIN = avgWinRate / expWinRate;
|
||||
|
||||
const rWINc = Math.max(0, (rWIN - 0.71) / (1 - 0.71));
|
||||
const rDAMAGEc = Math.max(0, (rDAMAGE - 0.22) / (1 - 0.22));
|
||||
const rFRAGc = Math.max(
|
||||
0,
|
||||
Math.min(rDAMAGEc + 0.2, (rFRAG - 0.12) / (1 - 0.12)),
|
||||
);
|
||||
const rSPOTc = Math.max(
|
||||
0,
|
||||
Math.min(rDAMAGEc + 0.1, (rSPOT - 0.38) / (1 - 0.38)),
|
||||
);
|
||||
const rDEFc = Math.max(0, Math.min(rDAMAGEc + 0.1, (rDEF - 0.1) / (1 - 0.1)));
|
||||
|
||||
const WN8 =
|
||||
980 * rDAMAGEc +
|
||||
210 * rDAMAGEc * rFRAGc +
|
||||
155 * rFRAGc * rSPOTc +
|
||||
75 * rDEFc * rFRAGc +
|
||||
145 * Math.min(1.8, rWINc);
|
||||
|
||||
return WN8;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
IndividualTankStats,
|
||||
NormalizedTankStats,
|
||||
emptyIndividualTankStats,
|
||||
} from '../../types/tanksStats';
|
||||
|
||||
export function deltaTankStats(a: NormalizedTankStats, b: NormalizedTankStats) {
|
||||
const bTanks = Object.keys(b);
|
||||
const changedTanks = bTanks.filter((id) => {
|
||||
return (
|
||||
a[id as unknown as number]?.all.battles !==
|
||||
b[id as unknown as number].all.battles
|
||||
);
|
||||
});
|
||||
const diff = changedTanks.map((id) => {
|
||||
const aTank = a[id as unknown as number] ?? emptyIndividualTankStats;
|
||||
const bTank = b[id as unknown as number];
|
||||
|
||||
const tankDiff = {
|
||||
account_id: bTank.account_id,
|
||||
battle_life_time: bTank.battle_life_time,
|
||||
frags: (bTank.frags ?? aTank.frags ?? 0) - (aTank.frags ?? 0),
|
||||
max_frags: Math.max(aTank.max_frags, bTank.max_frags),
|
||||
in_garage: bTank.in_garage,
|
||||
max_xp: Math.max(aTank.max_xp, bTank.max_xp),
|
||||
in_garage_updated: bTank.in_garage_updated,
|
||||
last_battle_time: bTank.last_battle_time - aTank.last_battle_time,
|
||||
mark_of_mastery: bTank.mark_of_mastery,
|
||||
tank_id: bTank.tank_id,
|
||||
|
||||
all: {
|
||||
battles: bTank.all.battles - aTank.all.battles,
|
||||
wins: bTank.all.wins - aTank.all.wins,
|
||||
capture_points: bTank.all.capture_points - aTank.all.capture_points,
|
||||
damage_dealt: bTank.all.damage_dealt - aTank.all.damage_dealt,
|
||||
damage_received: bTank.all.damage_received - aTank.all.damage_received,
|
||||
dropped_capture_points:
|
||||
bTank.all.dropped_capture_points - aTank.all.dropped_capture_points,
|
||||
frags: bTank.all.frags - aTank.all.frags,
|
||||
frags8p: bTank.all.frags8p - aTank.all.frags8p,
|
||||
hits: bTank.all.hits - aTank.all.hits,
|
||||
losses: bTank.all.losses - aTank.all.losses,
|
||||
max_frags: Math.max(aTank.max_frags, bTank.max_frags),
|
||||
max_xp: Math.max(aTank.max_xp, bTank.max_xp),
|
||||
shots: bTank.all.shots - aTank.all.shots,
|
||||
spotted: bTank.all.spotted - aTank.all.spotted,
|
||||
survived_battles:
|
||||
bTank.all.survived_battles - aTank.all.survived_battles,
|
||||
win_and_survived:
|
||||
bTank.all.win_and_survived - aTank.all.win_and_survived,
|
||||
xp: bTank.all.xp - aTank.all.xp,
|
||||
},
|
||||
} satisfies IndividualTankStats;
|
||||
|
||||
return tankDiff;
|
||||
});
|
||||
const normalized = diff.reduce<NormalizedTankStats>(
|
||||
(accumulator, diff) => ({ ...accumulator, [diff.tank_id]: diff }),
|
||||
{},
|
||||
);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TreeTypeString } from '../../components/Tanks';
|
||||
import sumStats from '../../LEGACY_core/blitz/sumStats';
|
||||
import { tankopedia } from '../../LEGACY_core/blitz/tankopedia';
|
||||
import { DiffedTankStats } from '../../LEGACY_core/blitzstars/getDiffedTankStats';
|
||||
import { tankAverages } from '../../LEGACY_core/blitzstars/tankAverages';
|
||||
import calculateWN8 from './calculateWN8';
|
||||
|
||||
export interface StatFilters {
|
||||
nation?: string;
|
||||
tier?: number;
|
||||
tankType?: string;
|
||||
treeType?: TreeTypeString;
|
||||
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 awaitedTankopedia = await tankopedia;
|
||||
const awaitedTankAverages = await tankAverages;
|
||||
const filteredOrder = order.filter((id) => {
|
||||
const entry = awaitedTankopedia[id];
|
||||
|
||||
return (
|
||||
entry &&
|
||||
(filters.nation === undefined || entry.nation === filters.nation) &&
|
||||
(filters.tier === undefined || entry.tier === filters.tier) &&
|
||||
(filters.tankType === undefined || entry.type === filters.tankType) &&
|
||||
(filters.treeType === undefined ||
|
||||
(filters.treeType === 'collector' && entry.is_collectible) ||
|
||||
(filters.treeType === 'premium' &&
|
||||
entry.is_premium &&
|
||||
!entry.is_collectible) ||
|
||||
(filters.treeType === 'tech-tree' &&
|
||||
!entry.is_collectible &&
|
||||
!entry.is_premium)) &&
|
||||
(filters.tank === undefined || entry.tank_id === filters.tank)
|
||||
);
|
||||
});
|
||||
const stats = sumStats(filteredOrder.map((id) => diff[id]));
|
||||
const battlesOfTanksWithAverages = filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankAverages[id] ? accumulator + diff[id].battles : accumulator,
|
||||
0,
|
||||
);
|
||||
const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankopedia[id] ? accumulator + diff[id].battles : accumulator,
|
||||
0,
|
||||
);
|
||||
const supplementary = {
|
||||
WN8:
|
||||
filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankAverages[id]
|
||||
? accumulator +
|
||||
calculateWN8(awaitedTankAverages[id].all, diff[id]) *
|
||||
diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
) / battlesOfTanksWithAverages,
|
||||
tier:
|
||||
filteredOrder.reduce<number>(
|
||||
(accumulator, id) =>
|
||||
awaitedTankopedia[id]
|
||||
? accumulator + awaitedTankopedia[id]!.tier * diff[id].battles
|
||||
: accumulator,
|
||||
0,
|
||||
) / battlesOfTanksWithTankopediaEntry,
|
||||
};
|
||||
|
||||
return { stats, supplementary, filteredOrder };
|
||||
}
|
||||
Reference in new issue
Block a user