diff --git a/.vscode/settings.json b/.vscode/settings.json index 04866d79e..f83e0197c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,8 @@ "BINORMAL", "Binormals", "BKRL", + "BKRTS", + "BKRTSMANIFEST", "bkwd", "blitzstars", "Camo", @@ -108,6 +110,8 @@ "researchable", "Resvg", "roboto", + "rtsc", + "RTSM", "SCPG", "scroller", "searchclans", diff --git a/docs/guide/rts.md b/docs/guide/rts.md new file mode 100644 index 000000000..9b071b46e --- /dev/null +++ b/docs/guide/rts.md @@ -0,0 +1,71 @@ +# Blitzkrieg Regular Tank Stats + +A straightforward way of storing the entire World of Tanks Blitz tank statistics for players to disk. + +## Manifest `manifest.rtsm` + +```cpp +primary RegularTankStatisticsManifest { + utf8"RTSM" magic; + Header header; + primary Body body; +} + +Header { + uint16 version; // always 1 +} + +Body { + uint32 time; + uint8 base; +} +``` + +## Chunk `*.rtsc` + +```cpp +primary RegularTankStatisticsChunk { + utf8"RTSC" magic; + Header header; + primary Body body; +} + +Header { + uint16 version; // always 1 + uint32 playerCount; +} + +Body { + Player[playerCount] players; +} + +Player { + uint32 id; + uint16 tankCount; + Tank[tankCount] tanks; +} + +Tank { + uint32 id; + uint32 battleLifeTime; + uint32 lastBattleTime; + uint8 markOfMastery; + uint32 battles; + uint32 capturePoints; + uint32 damageDealt; + uint32 damageReceived; + uint32 droppedCapturePoints; + uint32 frags; + uint32 frags8p; + uint32 hits; + uint32 losses; + uint8 maxFrags; + uint16 maxXp; + uint32 shots; + uint32 spotted; + uint32 survivedBattles; + uint32 winAndSurvived; + uint32 wins; + uint32 xp; +} +``` diff --git a/package.json b/package.json index 31dd53e8d..3dcd5e572 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:docs": "vitepress build docs", "build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard", "build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index", - "build:test": "vite-node --script scripts/buildTest", + "test": "vite-node --script scripts/test", "dev:bot": "vite-node --script scripts/build && yarn start:bot", "dev:docs": "vitepress dev docs", "dev:website": "next dev", diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 000000000..3ce2c3e8b --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,96 @@ +import { chunk, times } from 'lodash'; +import { compress } from 'lz4js'; +import { argv } from 'process'; +import { Region, REGIONS } from '../src/constants/regions'; +import getTankStats from '../src/core/blitz/getTankStats'; +import { idToRegion } from '../src/core/blitz/idToRegion'; +import { commitAssets } from '../src/core/blitzkrieg/commitAssets'; +import { FileChange } from '../src/core/blitzkrieg/commitMultipleFiles'; +import { + RtscWritePlayer, + RtscWriteStream, + RtsmWriteStream, +} from '../src/core/streams/rts'; +import usersRaw from '../test.users.json'; + +const PLAYERS_PER_CHUNK = 256; +const CHUNKS_PER_BATCH = 128; + +const production = argv.includes('--production'); +const region = argv + .find((argument) => argument.startsWith('--region')) + ?.split('=')[1] as Region | undefined; + +if (!region || !REGIONS.includes(region)) { + throw new Error('No valid region specified'); +} + +const today = new Date(); +const todayPath = `${today.getUTCFullYear()}/${today.getUTCMonth() + 1}/${today.getUTCDate()}`; +const users = usersRaw.filter(({ blitz }) => idToRegion(blitz) === region); +const base = Math.ceil(users.length / PLAYERS_PER_CHUNK); +const chunks = times(base, (offset) => ({ + offset, + users: users.filter((user) => user.blitz % base === offset), +})); +const batches = chunk(chunks, CHUNKS_PER_BATCH); +const manifestWriteStream = new RtsmWriteStream(); + +console.log(`Found ${users.length} players in ${region}`); + +await commitAssets( + `regular tank stats manifests ${todayPath}`, + [ + { + encoding: 'base64', + path: `regions/${region}/regular/tanks/${todayPath}/manifest.rtsm`, + content: Buffer.from( + compress(manifestWriteStream.rtsm(base).uint8Array), + ).toString('base64'), + }, + ], + production, +); + +let batchesDone = 0; +for (const batch of batches) { + console.log(`Batch ${++batchesDone} of ${batches.length}`); + + const changes: FileChange[] = []; + + let chunksDone = 0; + for (const chunk of batch) { + console.log(`Chunk ${++chunksDone} of ${chunks.length}`); + + let playersDone = 0; + const players = await Promise.all( + chunk.users.map(async (user) => { + const tanks = await getTankStats(region, user.blitz); + + console.log(`Player ${++playersDone} of ${chunk.users.length}`); + + if (tanks === null) return null; + + return { + id: user.blitz, + tanks, + } satisfies RtscWritePlayer; + }), + ).then((players) => players.filter((player) => player !== null)); + const writeStream = new RtscWriteStream(); + + writeStream.rtsc(players); + changes.push({ + encoding: 'base64', + content: Buffer.from(compress(writeStream.uint8Array)).toString('base64'), + path: `regions/${region}/regular/tanks/${todayPath}/${chunk.offset}.rtsc.lz4`, + }); + } + + // do not await; commit in background + commitAssets( + `regular tank stats ${todayPath} batch ${batches.indexOf(batch) + 1}`, + changes, + production, + ); +} diff --git a/src/app/embeds/session/page.tsx b/src/app/embeds/session/page.tsx index 0d724f360..e3dc9cacb 100644 --- a/src/app/embeds/session/page.tsx +++ b/src/app/embeds/session/page.tsx @@ -3,7 +3,6 @@ import { ContextMenu } from '@radix-ui/themes'; import { useEffect, useState } from 'react'; import * as Breakdown from '../../../components/Breakdown'; -import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import fetchBlitz from '../../../core/blitz/fetchBlitz'; import { resetSession } from '../../../core/blitzkrieg/resetSession'; import { @@ -88,10 +87,10 @@ export default function SessionPage({ naked = true }: SessionPageProps) { async function recalculateDiff() { if (session.isTracking) { const { id, region } = session; - const careerRaw = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - const career = careerRaw[id].reduce< + const careerRaw = await fetchBlitz(region, 'tanks/stats', { + account_id: id, + }); + const career = careerRaw[id]!.reduce< Record >( (accumulator, curr) => ({ @@ -103,7 +102,7 @@ export default function SessionPage({ naked = true }: SessionPageProps) { const awaitedTankDefinitions = await tankDefinitions; const awaitedTankAverages = await tankAverages; const careerWN8 = - careerRaw[id].reduce( + careerRaw[id]!.reduce( (accumulator, { tank_id, all }) => accumulator + (awaitedTankAverages[tank_id] && all.battles > 0 @@ -112,7 +111,7 @@ export default function SessionPage({ naked = true }: SessionPageProps) { : 0), 0, ) / - careerRaw[id].reduce( + careerRaw[id]!.reduce( (accumulator, { tank_id, all }) => accumulator + (awaitedTankAverages[tank_id] && all.battles > 0 diff --git a/src/app/tools/rating/page.tsx b/src/app/tools/rating/page.tsx index 14d47255d..2dd70856f 100644 --- a/src/app/tools/rating/page.tsx +++ b/src/app/tools/rating/page.tsx @@ -25,7 +25,6 @@ import { Region, UNLOCALIZED_REGION_NAMES, } from '../../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import fetchBlitz from '../../../core/blitz/fetchBlitz'; import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo'; import getRatingInfo from '../../../core/blitz/getRatingInfo'; @@ -818,7 +817,9 @@ export default function Page() { } else { setSearchLoading(true); const accountList = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`, + region, + 'account/list', + { search: encodedSearch }, ); const playerKeys = Object.keys( leaderboard[region][season], diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx index 05e98cf02..8b135a6b2 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx @@ -177,16 +177,18 @@ export function TankSandbox() { - - - + {wideFormat && ( + + + + )}
{ const tankAveragesEntry = awaitedTankAverages[id]; const current = statsInPeriod.diff[id]; diff --git a/src/commands/ownedTanks.tsx b/src/commands/ownedTanks.tsx index 29f081c67..8a51ffde2 100644 --- a/src/commands/ownedTanks.tsx +++ b/src/commands/ownedTanks.tsx @@ -31,11 +31,16 @@ export const ownedTanksCommand = new Promise((resolve) => { .addStringOption(addUsernameChoices), async handler(interaction) { - const { translate } = translator(interaction.locale); + const { t, translate } = translator(interaction.locale); const tier = Number(interaction.options.getString('tier')); const { id, region } = await resolvePlayerFromCommand(interaction); const accountInfo = await getAccountInfo(region, id); - const tankStats = await getTankStats(region, id, interaction.locale); + const tankStats = await getTankStats(region, id); + + if (tankStats === null) { + return t`bot.common.errors.no_tank_stats`; + } + const filteredTanks = ( await Promise.all( tankStats.map(async (tankData) => ({ diff --git a/src/commands/research.tsx b/src/commands/research.tsx index ab50379c6..d2bfd9e21 100644 --- a/src/commands/research.tsx +++ b/src/commands/research.tsx @@ -126,7 +126,11 @@ export const researchCommand = new Promise((resolve) => { ]); } - const tankStats = await getTankStats(region, id, interaction.locale); + const tankStats = await getTankStats(region, id); + + if (tankStats === null) { + return t`bot.common.errors.no_tank_stats`; + } if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) { return translate('bot.commands.research.errors.already_researched', [ diff --git a/src/commands/searchClans.ts b/src/commands/searchClans.ts index 3fe09224a..2307ad249 100644 --- a/src/commands/searchClans.ts +++ b/src/commands/searchClans.ts @@ -1,7 +1,6 @@ import { Locale } from 'discord.js'; import markdownEscape from 'markdown-escape'; import { Region } from '../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; import fetchBlitz from '../core/blitz/fetchBlitz'; import addClanChoices from '../core/discord/addClanChoices'; import addRegionChoices from '../core/discord/addRegionChoices'; @@ -49,17 +48,18 @@ export const searchClansCommand = new Promise((resolve) => { async handler(interaction) { const { translate } = translator(interaction.locale); - const server = interaction.options.getString('region') as Region; + const region = interaction.options.getString('region') as Region; const clan = interaction.options.getString('clan')!; const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT; - const clanList = await fetchBlitz( - `https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`, - ); + const clanList = await fetchBlitz(region, 'clans/list', { + search: clan, + limit, + }); return embedInfo( translate('bot.commands.search_clans.body.title', [ markdownEscape(clan), - translate(`common.regions.normal.${server}`), + translate(`common.regions.normal.${region}`), ]), clanList.length === 0 ? translate('bot.commands.search_clans.body.no_results') diff --git a/src/commands/searchPlayers.ts b/src/commands/searchPlayers.ts index ed816c1f2..5be8c4f5b 100644 --- a/src/commands/searchPlayers.ts +++ b/src/commands/searchPlayers.ts @@ -1,7 +1,6 @@ import { Locale } from 'discord.js'; import markdownEscape from 'markdown-escape'; import { Region } from '../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID'; import fetchBlitz from '../core/blitz/fetchBlitz'; import { AccountList } from '../core/blitz/searchPlayersAcrossRegions'; import { usernamePattern } from '../core/blitz/searchPlayersAcrossRegions/constants'; @@ -50,22 +49,21 @@ export const searchPlayersCommand = new Promise((resolve) => { async handler(interaction) { const { translate } = translator(interaction.locale); - const server = interaction.options.getString('region') as Region; + const region = interaction.options.getString('region') as Region; const name = interaction.options.getString('username')!; const limit = interaction.options.getInteger('limit') ?? 25; const trimmedSearch = name.trim(); const players = usernamePattern.test(trimmedSearch) - ? await fetchBlitz( - `https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent( - trimmedSearch, - )}&limit=${limit}`, - ) + ? await fetchBlitz(region, 'account/list', { + search: trimmedSearch, + limit, + }) : []; return embedInfo( translate('bot.commands.search_players.body.title', [ markdownEscape(trimmedSearch), - translate(`common.regions.normal.${server}`), + translate(`common.regions.normal.${region}`), ]), `\`\`\`${ players.length === 0 diff --git a/src/core/blitz/fetchBlitz.ts b/src/core/blitz/fetchBlitz.ts index 48bd1f211..cc2df8816 100644 --- a/src/core/blitz/fetchBlitz.ts +++ b/src/core/blitz/fetchBlitz.ts @@ -1,3 +1,5 @@ +import { Region } from '../../constants/regions'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { patientFetch } from '../blitzkrieg/patientFetch'; type BlitzResponse = @@ -24,31 +26,54 @@ async function manageQueue() { manageQueue(); }, 1000); - const { url, resolve } = queue.shift()!; - const data = (await patientFetch(url) + const request = queue.shift()!; + const data = (await patientFetch(request.url) .then((response) => response.json()) .catch((error) => ({ status: 'error', error }))) as BlitzResponse< - typeof resolve + typeof request.resolve >; if (data.status === 'ok') { - resolve(data.data); + request.resolve(data.data); } else { - clearTimeout(timeout); - inProgress--; - manageQueue(); + if (data.error.message === 'REQUEST_LIMIT_EXCEEDED') { + console.log( + `Rate limit exceeded, putting "${request.url}" back in queue...`, + ); - throw new Error(`Wargaming response error status: "${data.status}"`, { - cause: `Message: "${data.error.message}"\nURL: "${url}"`, - }); + clearTimeout(timeout); + setTimeout(() => { + queue.push(request); + inProgress--; + manageQueue(); + }, 1000 / MAX_CALLS_PER_SECOND); + } else { + clearTimeout(timeout); + inProgress--; + manageQueue(); + + throw new Error(`Wargaming response error status: "${data.status}"`, { + cause: `Message: "${data.error.message}"\nURL: "${request.url}"`, + }); + } } } } -export default function fetchBlitz(url: string) { +export default function fetchBlitz( + region: Region, + path: string, + params: Record = {}, +) { return new Promise((resolve) => { queue.push({ - url, + url: `https://api.wotblitz.${region}/wotb/${path}/?${Object.entries({ + application_id: WARGAMING_APPLICATION_ID, + ...params, + }) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => `${key}=${encodeURIComponent(value!)}`) + .join('&')}`, resolve(data) { resolve(data as Data); }, diff --git a/src/core/blitz/getAccountAchievements.ts b/src/core/blitz/getAccountAchievements.ts index 98b7c9cfd..c87e34118 100644 --- a/src/core/blitz/getAccountAchievements.ts +++ b/src/core/blitz/getAccountAchievements.ts @@ -1,5 +1,4 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import fetchBlitz from './fetchBlitz'; import { normalizeIds } from './normalizeIds'; @@ -23,9 +22,9 @@ export async function getAccountAchievements< : IndividualAccountAchievements[], >(region: Region, ids: Ids) { const object = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( - ids, - )}`, + region, + 'account/achievements', + { account_id: normalizeIds(ids) }, ); if (typeof ids === 'number') { diff --git a/src/core/blitz/getAccountInfo.ts b/src/core/blitz/getAccountInfo.ts index e9cab4285..51f602f0c 100644 --- a/src/core/blitz/getAccountInfo.ts +++ b/src/core/blitz/getAccountInfo.ts @@ -1,5 +1,4 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import fetchBlitz from './fetchBlitz'; import { normalizeIds } from './normalizeIds'; @@ -97,11 +96,10 @@ export async function getAccountInfo< ? IndividualAccountInfo : IndividualAccountInfo[], >(region: Region, ids: Ids, extra: string[] = []) { - const object = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( - ids, - )}${extra.length === 0 ? '' : `&extra=${extra.join(',')}`}`, - ); + const object = await fetchBlitz(region, 'account/info', { + account_id: normalizeIds(ids), + extra: extra.length === 0 ? undefined : extra.join(','), + }); if (typeof ids === 'number') { return object[ids as number] as ReturnType; diff --git a/src/core/blitz/getClanAccountInfo.ts b/src/core/blitz/getClanAccountInfo.ts index de5a73cf4..0a986eac7 100644 --- a/src/core/blitz/getClanAccountInfo.ts +++ b/src/core/blitz/getClanAccountInfo.ts @@ -1,7 +1,5 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import fetchBlitz from './fetchBlitz'; -import { normalizeExtra } from './normalizeExtra'; import { normalizeIds } from './normalizeIds'; type IndividualClanAccountInfo = { @@ -30,11 +28,14 @@ export async function getClanAccountInfo< ReturnType = Ids extends number ? IndividualClanAccountInfo : IndividualClanAccountInfo[], ->(region: Region, ids: Ids, extra?: string[]) { +>(region: Region, ids: Ids, extra: string[] = []) { const object = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( - ids, - )}${normalizeExtra(extra)}`, + region, + 'clans/accountinfo', + { + account_id: normalizeIds(ids), + extra: extra.length === 0 ? undefined : extra.join(','), + }, ); if (typeof ids === 'number') { diff --git a/src/core/blitz/getClanInfo.ts b/src/core/blitz/getClanInfo.ts index e61a828c2..9ac36d92d 100644 --- a/src/core/blitz/getClanInfo.ts +++ b/src/core/blitz/getClanInfo.ts @@ -1,5 +1,4 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import fetchBlitz from './fetchBlitz'; import { normalizeIds } from './normalizeIds'; @@ -40,11 +39,9 @@ export async function getClanInfo< Ids extends number | number[], ReturnType = Ids extends number ? IndividualClanInfo : IndividualClanInfo[], >(region: Region, ids: Ids) { - const object = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${normalizeIds( - ids, - )}`, - ); + const object = await fetchBlitz(region, 'clans/info', { + clan_id: normalizeIds(ids), + }); if (typeof ids === 'number') { return object[ids as number] as ReturnType; diff --git a/src/core/blitz/getTankStats.ts b/src/core/blitz/getTankStats.ts index 918afc7ee..060efc064 100644 --- a/src/core/blitz/getTankStats.ts +++ b/src/core/blitz/getTankStats.ts @@ -1,24 +1,11 @@ -import { Locale } from 'discord.js'; import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; -import { UserError } from '../../hooks/userError'; import { TanksStats } from '../../types/tanksStats'; -import { translator } from '../localization/translator'; import fetchBlitz from './fetchBlitz'; -export default async function getTankStats( - region: Region, - id: number, - locale: Locale, -) { - const { t } = translator(locale); - const tankStats = await fetchBlitz( - `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ); - - if (tankStats[id] === null) { - throw new UserError(t`bot.common.errors.no_tank_stats`); - } +export default async function getTankStats(region: Region, id: number) { + const tankStats = await fetchBlitz(region, 'tanks/stats', { + account_id: id, + }); return tankStats[id]; } diff --git a/src/core/blitz/idToRegion.ts b/src/core/blitz/idToRegion.ts index 2c95af2a1..81b6e5b4f 100644 --- a/src/core/blitz/idToRegion.ts +++ b/src/core/blitz/idToRegion.ts @@ -1,6 +1,6 @@ import { Region } from '../../constants/regions'; -export function idToRange(id: number): Region { +export function idToRegion(id: number): Region { if (id >= 42e8) { throw new Error('bot id'); } else if (id >= 31e8) { diff --git a/src/core/blitz/searchClansAcrossRegions.ts b/src/core/blitz/searchClansAcrossRegions.ts index de5e7a224..6d14f7e8d 100644 --- a/src/core/blitz/searchClansAcrossRegions.ts +++ b/src/core/blitz/searchClansAcrossRegions.ts @@ -1,5 +1,4 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { ClanList } from '../../types/clanList'; import fetchBlitz from './fetchBlitz'; @@ -15,9 +14,10 @@ export default async function searchClansAcrossRegions( return ( await Promise.all([ - fetchBlitz( - `https://api.wotblitz.com/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, - ).then((value) => + fetchBlitz('com', 'clans/list', { + search, + limit: normalizedLimit, + }).then((value) => value .map((account) => ({ ...account, @@ -25,9 +25,10 @@ export default async function searchClansAcrossRegions( })) .splice(0, normalizedLimit), ), - fetchBlitz( - `https://api.wotblitz.eu/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, - ).then((value) => + fetchBlitz('eu', 'clans/list', { + search, + limit: normalizedLimit, + }).then((value) => value .map((account) => ({ ...account, @@ -35,9 +36,10 @@ export default async function searchClansAcrossRegions( })) .splice(0, normalizedLimit), ), - fetchBlitz( - `https://api.wotblitz.asia/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`, - ).then((value) => + fetchBlitz('asia', 'clans/list', { + search, + limit: normalizedLimit, + }).then((value) => value .map((account) => ({ ...account, diff --git a/src/core/blitz/searchPlayersAcrossRegions/index.ts b/src/core/blitz/searchPlayersAcrossRegions/index.ts index 92c7d7ae9..8ca9130a7 100644 --- a/src/core/blitz/searchPlayersAcrossRegions/index.ts +++ b/src/core/blitz/searchPlayersAcrossRegions/index.ts @@ -1,5 +1,4 @@ import { Region } from '../../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import fetchBlitz from '../fetchBlitz'; import { usernamePattern } from './constants'; @@ -18,14 +17,14 @@ export default async function searchPlayersAcrossRegions( ) { const trimmedSearch = search.trim(); const normalizedLimit = Math.floor(limit / 3); - const encodedSearch = encodeURIComponent(trimmedSearch); if (usernamePattern.test(trimmedSearch)) { return ( await Promise.all([ - fetchBlitz( - `https://api.wotblitz.com/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, - ).then( + fetchBlitz('com', 'account/list', { + search, + limit: normalizedLimit, + }).then( (value) => value && value.map((account) => ({ @@ -33,9 +32,10 @@ export default async function searchPlayersAcrossRegions( region: 'com' as Region, })), ), - fetchBlitz( - `https://api.wotblitz.eu/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, - ).then( + fetchBlitz('eu', 'account/list', { + search, + limit: normalizedLimit, + }).then( (value) => value && value.map((account) => ({ @@ -43,9 +43,10 @@ export default async function searchPlayersAcrossRegions( region: 'eu' as Region, })), ), - fetchBlitz( - `https://api.wotblitz.asia/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`, - ).then( + fetchBlitz('asia', 'account/list', { + search, + limit: normalizedLimit, + }).then( (value) => value && value.map((account) => ({ diff --git a/src/core/blitzkrieg/commitAssets.ts b/src/core/blitzkrieg/commitAssets.ts index 2c53e8814..9b5b93be8 100644 --- a/src/core/blitzkrieg/commitAssets.ts +++ b/src/core/blitzkrieg/commitAssets.ts @@ -4,6 +4,7 @@ export async function commitAssets( message: string, changes: FileChange[], production: boolean, + verbose = true, ) { console.log(`Committing ${message}...`); @@ -15,6 +16,6 @@ export async function commitAssets( production ? 'main' : 'dev', `${message} - ${new Date().toDateString()}`, changes, - true, + verbose, ); } diff --git a/src/core/blitzkrieg/discordBlitzLink.ts b/src/core/blitzkrieg/discordBlitzLink.ts index 987c95d14..4fdaf5e75 100644 --- a/src/core/blitzkrieg/discordBlitzLink.ts +++ b/src/core/blitzkrieg/discordBlitzLink.ts @@ -1,5 +1,5 @@ import { usersDatabase } from '../../databases/users'; -import { idToRange } from '../blitz/idToRegion'; +import { idToRegion } from '../blitz/idToRegion'; export async function flagUserActivity(blitz: number) { await usersDatabase.users.upsert({ @@ -26,5 +26,5 @@ export async function getBlitzFromDiscord(discord: bigint) { if (unique === null) return null; await flagUserActivity(unique.blitz); - return { id: unique.blitz, region: idToRange(unique.blitz) }; + return { id: unique.blitz, region: idToRegion(unique.blitz) }; } diff --git a/src/core/blitzkrieg/getTankHistories.ts b/src/core/blitzkrieg/getTankHistories.ts index bba997d01..1a281beec 100644 --- a/src/core/blitzkrieg/getTankHistories.ts +++ b/src/core/blitzkrieg/getTankHistories.ts @@ -1,5 +1,6 @@ import { Locale } from 'discord.js'; import { Region } from '../../constants/regions'; +import { UserError } from '../../hooks/userError'; import { GetHistoriesOptions, Histories, @@ -8,6 +9,7 @@ import { } from '../../types/histories'; import getTankStats from '../blitz/getTankStats'; import { emptyAllStats } from '../blitzstars/getStatsInPeriod/constants'; +import { translator } from '../localization/translator'; export interface TankHistory extends History { tank_id: number; @@ -38,6 +40,7 @@ export default async function getTankHistories( locale: Locale, options?: Partial, ) { + const { t } = translator(locale); const mergedOptions = { ...getTankHistoriesDefaultOptions, ...options }; const tankHistoriesResponse = await fetch( `https://www.blitzstars.com/api/tankhistories/for/${id}/`, @@ -78,7 +81,13 @@ export default async function getTankHistories( const latestNodes: TankHistories = []; if (mergedOptions.includeLatestHistories) { - (await getTankStats(server, id, locale)).forEach((tankHistory) => { + const tankStats = await getTankStats(server, id); + + if (tankStats === null) { + throw new UserError(t`bot.common.errors.no_tank_stats`); + } + + tankStats.forEach((tankHistory) => { if ( tankHistory.all.battles > (lastBattles[tankHistory.tank_id] ?? 0) && (mergedOptions.tankId === undefined || diff --git a/src/core/blitzkrieg/setSession.ts b/src/core/blitzkrieg/setSession.ts index fea967a3d..17a971fb3 100644 --- a/src/core/blitzkrieg/setSession.ts +++ b/src/core/blitzkrieg/setSession.ts @@ -1,5 +1,4 @@ import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { useSession } from '../../stores/session'; import { NormalizedTankStats, TanksStats } from '../../types/tanksStats'; import fetchBlitz from '../blitz/fetchBlitz'; @@ -7,11 +6,9 @@ import fetchBlitz from '../blitz/fetchBlitz'; export async function setSession(region: Region, id: number, nickname: string) { // TODO: generalize this function and also check for more of these kind const rawTankStats = ( - await fetchBlitz( - `https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, - ) + await fetchBlitz(region, 'tanks/stats', { account_id: id }) )[id]; - const tankStats = rawTankStats.reduce( + const tankStats = rawTankStats!.reduce( (accumulator, tank) => ({ ...accumulator, [tank.tank_id]: tank, diff --git a/src/core/blitzkrieg/tankCharacteristics.ts b/src/core/blitzkrieg/tankCharacteristics.ts index a925f8aad..63681ddab 100644 --- a/src/core/blitzkrieg/tankCharacteristics.ts +++ b/src/core/blitzkrieg/tankCharacteristics.ts @@ -291,7 +291,11 @@ export function tankCharacteristics( ); const dpmMaximum = gun.type === 'autoReloader' - ? gun.reload.at(-1)! < gun.reload.at(-2)! + ? ( + gun.count > 2 + ? gun.reload.at(-1)! < gun.reload.at(-2)! + : gun.reload.at(-1)! + gun.intraClip < gun.reload.at(-2)! + ) ? ((damageCoefficient * shell.damage.armor) / (gun.reload.at(-1)! * reloadCoefficient + gun.intraClip * intraClipCoefficient)) * diff --git a/src/core/streams/rts.ts b/src/core/streams/rts.ts new file mode 100644 index 000000000..2d9b0d0e9 --- /dev/null +++ b/src/core/streams/rts.ts @@ -0,0 +1,183 @@ +import { times } from 'lodash'; +import { IndividualTankStats } from '../../types/tanksStats'; +import { AllStats } from '../blitz/getAccountInfo'; +import { ReadStream, WriteStream } from './buffer'; + +export interface BlitzkriegTankStats extends AllStats { + id: number; + battle_life_time: number; + last_battle_time: number; + mark_of_mastery: number; +} + +export class RtsmReadStream extends ReadStream { + rtsm() { + this.magic(); + this.header(); + return this.body(); + } + + magic() { + const magic = this.utf8(4); + + if (magic !== 'RTSM') { + throw new Error(`Invalid RTSM magic number: "${magic}"`); + } + } + + header() { + return { + version: this.uint16(), + }; + } + + body() { + return { + time: this.uint32(), + base: this.uint8(), + }; + } +} + +export class RtsmWriteStream extends WriteStream { + rtsm(base: number) { + this.magic(); + this.header(); + this.body(base); + + return this; + } + + magic() { + this.utf8('RTSM'); + } + + header() { + this.uint16(1); + } + + body(base: number) { + this.uint32(Math.round(Date.now() / 1000)); + this.uint8(base); + } +} + +export class RtscReadStream extends ReadStream { + rtsc() { + this.magic(); + const header = this.header(); + return this.body(header.playerCount); + } + + magic() { + const magic = this.utf8(4); + + if (magic !== 'RTSC') { + throw new Error(`Invalid RTSC magic number: "${magic}"`); + } + } + + header() { + return { + version: this.uint16(), + playerCount: this.uint32(), + }; + } + + body(playerCount: number) { + return times(playerCount, () => this.player()); + } + + player() { + const id = this.uint32(); + const tankCount = this.uint16(); + const tanks = times(tankCount, () => this.tank()); + + return { + id, + tankCount, + tanks, + }; + } + + tank() { + return { + id: this.uint32(), + battle_life_time: this.uint32(), + last_battle_time: this.uint32(), + mark_of_mastery: this.uint8(), + battles: this.uint32(), + capture_points: this.uint32(), + damage_dealt: this.uint32(), + damage_received: this.uint32(), + dropped_capture_points: this.uint32(), + frags: this.uint32(), + frags8p: this.uint32(), + hits: this.uint32(), + losses: this.uint32(), + max_frags: this.uint8(), + max_xp: this.uint16(), + shots: this.uint32(), + spotted: this.uint32(), + survived_battles: this.uint32(), + win_and_survived: this.uint32(), + wins: this.uint32(), + xp: this.uint32(), + } satisfies BlitzkriegTankStats; + } +} + +export interface RtscWritePlayer { + id: number; + tanks: IndividualTankStats[]; +} + +export class RtscWriteStream extends WriteStream { + rtsc(players: RtscWritePlayer[]) { + this.magic(); + this.header(players.length); + this.body(players); + + return this; + } + + magic() { + this.utf8('RTSC'); + } + + header(playerCount: number) { + this.uint16(1); + this.uint32(playerCount); + } + + body(players: RtscWritePlayer[]) { + players.forEach((player) => { + this.uint32(player.id); + this.uint16(player.tanks.length); + + player.tanks.forEach((tank) => { + this.uint32(tank.tank_id); + this.uint32(tank.battle_life_time); + this.uint32(tank.last_battle_time); + this.uint8(tank.mark_of_mastery); + this.uint32(tank.all.battles); + this.uint32(tank.all.capture_points); + this.uint32(tank.all.damage_dealt); + this.uint32(tank.all.damage_received); + this.uint32(tank.all.dropped_capture_points); + this.uint32(tank.all.frags); + this.uint32(tank.all.frags8p); + this.uint32(tank.all.hits); + this.uint32(tank.all.losses); + this.uint8(tank.all.max_frags); + this.uint16(tank.all.max_xp); + this.uint32(tank.all.shots); + this.uint32(tank.all.spotted); + this.uint32(tank.all.survived_battles); + this.uint32(tank.all.win_and_survived); + this.uint32(tank.all.wins); + this.uint32(tank.all.xp); + }); + }); + } +} diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 3c1f6ab93..ae0712ae0 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -62,6 +62,18 @@ "bot": { "common": { + "errors": { + "clan_not_found": "# Could not find clan\nCouldn't find \"%s\". Try selecting a clan from the search result.", + "player_not_found": "# Could not find user\nI couldn't find user \"%s\". Try picking an user from the search result, typing in a valid username, or using the `/link` command.", + "player_not_linked": "# I don't know who you are\nUse the `/link` command to get started.", + "tank_name_not_found": "# Tank not found\nCould not find tank by the name \"%s\".", + "tank_id_not_found": "# Tank not found\nCould not find tank by the ID \"%s\". Try typing the name of the tank instead of a number.", + "no_tank_stats": "# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.", + "get_help": "Help Discord server", + "uncaught_error": "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.", + "preview": "# This command is in preview\n\nYou can only access this command in [the official Discord server](https://discord.gg/nDt7AjGJQH). The command will be generally available soon." + }, + "wn8": { "$": "WN8", "very_bad": "Very bad", @@ -183,18 +195,6 @@ "refresh": "Refresh" }, - "errors": { - "clan_not_found": "# Could not find clan\nCouldn't find \"%s\". Try selecting a clan from the search result.", - "player_not_found": "# Could not find user\nI couldn't find user \"%s\". Try picking an user from the search result, typing in a valid username, or using the `/link` command.", - "player_not_linked": "# I don't know who you are\nUse the `/link` command to get started.", - "tank_name_not_found": "# Tank not found\nCould not find tank by the name \"%s\".", - "tank_id_not_found": "# Tank not found\nCould not find tank by the ID \"%s\". Try typing the name of the tank instead of a number.", - "no_tank_stats": "# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.", - "get_help": "Help Discord server", - "uncaught_error": "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.", - "preview": "# This command is in preview\n\nYou can only access this command in [the official Discord server](https://discord.gg/nDt7AjGJQH). The command will be generally available soon." - }, - "hero_stats": { "battles": "Battles", "winrate": "Winrate", diff --git a/src/types/tanksStats.ts b/src/types/tanksStats.ts index 70aa4986d..73c73fda9 100644 --- a/src/types/tanksStats.ts +++ b/src/types/tanksStats.ts @@ -15,7 +15,7 @@ export interface IndividualTankStats { } export interface TanksStats { - [accountId: number]: IndividualTankStats[]; + [accountId: number]: null | IndividualTankStats[]; } export interface NormalizedTankStats {