feat: migration 4

This commit is contained in:
TrèsAbhi committed 2024-09-06 23:27:27 -05:00
1 parent 4055988478
commit 135dd85a82
26 files changed
+1 -19

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { mkdir, writeFile } from 'fs/promises';
import { parse as parsePath } from 'path';
import ProgressBar from 'progress';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { readStringDVPL } from '../src/core/blitz/readStringDVPL';
import { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { writeDVPL } from '../src/core/blitz/writeDVPL';
import { assertSecret } from '../src/core/blitzkit/secret';
import { dvp } from '../submodules/blitzkit-closed/src/dvp';
import { DATA } from './buildAssets/constants';
const versionTextFile = await readStringDVPL(`${DATA}/version.txt.dvpl`);
const currentVersion = versionTextFile
.split(' ')[0]
.split('.')
.slice(0, 3)
.join('.');
console.log(`Installing patches for ${currentVersion}...`);
let patchIndex = 1;
while (true) {
const response = await fetch(
`${assertSecret(process.env.WOTB_DLC_CDN)}/dlc/s${currentVersion}_${patchIndex}.yaml`,
);
if (response.status === 200) {
console.log(`Applying patch ${patchIndex}...`);
const data = parseYaml(await response.text());
const dvpm = await fetch(
`${assertSecret(process.env.WOTB_DLC_CDN)}/dlc/${data.dx11}`,
).then((response) => response.arrayBuffer());
const dvpd = await fetch(
`${assertSecret(process.env.WOTB_DLC_CDN)}/dlc/${data.dx11.replace('.dvpm', '.dvpd')}`,
).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd);
const bar = new ProgressBar(
`Patching ${files.length} files :bar`,
files.length,
);
await Promise.all(
files.map(async ({ path, data }) => {
const { dir } = parsePath(path);
await mkdir(`${DATA}/${dir}`, { recursive: true });
await writeFile(
`${DATA}/${path}.dvpl`,
new Uint8Array(writeDVPL(Buffer.from(data))),
);
bar.tick();
}),
);
if ('dynamicContentLocalizationsDir' in data) {
console.log('Found dynamic content localizations; patching...');
const localizationsResponse = await fetch(
`${assertSecret(process.env.WOTB_DLC_CDN)}/dlc/${data.dynamicContentLocalizationsDir}/en.yaml`,
);
const newStrings = parseYaml(await localizationsResponse.text());
const oldStrings = await readYAMLDVPL<Record<string, string>>(
`${DATA}/Strings/en.yaml.dvpl`,
);
const patchedStrings = { ...oldStrings, ...newStrings };
const patchedContent = stringifyYaml(patchedStrings);
await writeFile(
`${DATA}/Strings/en.yaml.dvpl`,
new Uint8Array(writeDVPL(Buffer.from(patchedContent))),
);
}
patchIndex++;
} else break;
}
+240
View File
@@ -0,0 +1,240 @@
import { chunk, times } from 'lodash';
import { Region, REGIONS } from '../src/constants/regions';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import getTankStats from '../src/core/blitz/getTankStats';
import { idToRegion } from '../src/core/blitz/idToRegion';
import {
AverageDefinitions,
AverageDefinitionsEntry,
AverageDefinitionsEntrySubPartial,
AverageDefinitionsManifest,
Samples,
} from '../src/core/blitzkit/averageDefinitions';
import {
averageDefinitionsAllStatsKeys,
emptySamples,
} from '../src/core/blitzkit/averageDefinitions/constants';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { fetchPreDiscoveredIds } from '../src/core/blitzkit/fetchPreDiscoveredIds';
import { encodeToBase64 } from '../src/core/protobuf/encodeToBase64';
import { IndividualTankStats } from '../src/types/tanksStats';
interface DataPoint {
x: number;
y: number;
w: number;
}
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// const MAX_ACTIVITY_TIME = 120 * DAY;
// const MIN_BATTLES = 5000;
const RUN_TIME = 5 * HOUR + 55 * MINUTE;
const THREADS = 10;
const PLAYER_IDS_PER_CALL = 100;
const startTime = Date.now();
const preDiscoveredIds = await fetchPreDiscoveredIds();
const playerIds: Record<Region, number[][]> = {
asia: chunk(
preDiscoveredIds.filter((id) => idToRegion(id) === 'asia'),
PLAYER_IDS_PER_CALL,
),
com: chunk(
preDiscoveredIds.filter((id) => idToRegion(id) === 'com'),
PLAYER_IDS_PER_CALL,
),
eu: chunk(
preDiscoveredIds.filter((id) => idToRegion(id) === 'eu'),
PLAYER_IDS_PER_CALL,
),
};
let regionIndex = 0;
const samples: Samples = { ...emptySamples };
const availableRegions = [...REGIONS];
const tankIds: number[] = [];
const tanksSorted: Record<number, IndividualTankStats[]> = {};
let postWorkRequested = false;
times(THREADS, async () => {
while (availableRegions.length > 0 && startTime + RUN_TIME > Date.now()) {
const region = availableRegions[regionIndex];
const regionIds = playerIds[region];
const [ids] = regionIds.splice(
Math.floor(Math.random() * regionIds.length),
1,
);
const accountInfo = await getAccountInfo(region, ids, undefined, {
fields: 'last_battle_time,statistics.all.battles',
});
const filteredIds = ids.filter((_, index) => {
const info = accountInfo[index];
if (info === null) return false;
const timeSinceLastActivity = Date.now() - info.last_battle_time * 1000;
samples.total++;
if (timeSinceLastActivity <= 120 * DAY) samples.d_120++;
if (timeSinceLastActivity <= 90 * DAY) samples.d_90++;
if (timeSinceLastActivity <= 60 * DAY) samples.d_60++;
if (timeSinceLastActivity <= 30 * DAY) samples.d_30++;
if (timeSinceLastActivity <= 7 * DAY) samples.d_7++;
if (timeSinceLastActivity <= 1 * DAY) samples.d_1++;
return true;
// return (
// timeSinceLastActivity <= MAX_ACTIVITY_TIME &&
// info.statistics.all.battles > MIN_BATTLES
// );
});
const players = await Promise.all(
filteredIds.map((id) =>
getTankStats(region, id, {
fields: 'last_battle_time,battle_life_time,all,tank_id',
}),
),
);
players.forEach((tanks) => {
tanks?.forEach((tank) => {
if (tank.all.battles === 0) return;
if (!tankIds.includes(tank.tank_id)) {
console.log(
`Found new tank: ${tank.tank_id} (${tankIds.length + 1})`,
);
tankIds.push(tank.tank_id);
tanksSorted[tank.tank_id] = [];
}
tanksSorted[tank.tank_id].push(tank);
});
});
if (regionIds.length === 0 && availableRegions.includes(region)) {
availableRegions.splice(availableRegions.indexOf(region), 1);
}
regionIndex = (regionIndex + 1) % availableRegions.length;
}
if (!postWorkRequested) {
postWorkRequested = true;
postWork();
}
});
async function postWork() {
console.log(
`Generating statistics based on ${samples.d_120.toLocaleString()} players (${samples.total.toLocaleString()} checked in total) and ${tankIds.length} tanks...`,
);
const averages: Record<number, AverageDefinitionsEntry> = {};
tankIds.forEach((id) => {
const tanksSortedEntry = tanksSorted[id];
// const tanks = tanksSortedEntry.filter(
// (tank) => Date.now() - tank.last_battle_time * 1000 <= MAX_ACTIVITY_TIME,
// );
const tanks = tanksSortedEntry;
const entry: AverageDefinitionsEntrySubPartial = {
mu: {},
sigma: {},
r: {},
samples: { ...emptySamples },
};
tanks.forEach((tank) => {
const timeSinceLastActivity = Date.now() - tank.last_battle_time * 1000;
entry.samples.total++;
if (timeSinceLastActivity <= 120 * DAY) entry.samples.d_120++;
if (timeSinceLastActivity <= 90 * DAY) entry.samples.d_90++;
if (timeSinceLastActivity <= 60 * DAY) entry.samples.d_60++;
if (timeSinceLastActivity <= 30 * DAY) entry.samples.d_30++;
if (timeSinceLastActivity <= 7 * DAY) entry.samples.d_7++;
if (timeSinceLastActivity <= 1 * DAY) entry.samples.d_1++;
});
const dataWY = tanks.map((tank) => ({
w: tank.all.battles,
y: tank.all.wins / tank.all.battles,
}));
averageDefinitionsAllStatsKeys.forEach((key) => {
const data: DataPoint[] = dataWY.map(({ w, y }, index) => ({
w,
x:
(key === 'battle_life_time'
? tanks[index].battle_life_time
: tanks[index].all[key]) / w,
y,
}));
if (data.length === 0) return;
function sum(slicer: (data: DataPoint) => number) {
return data.reduce(
(accumulator, data) => accumulator + slicer(data),
0,
);
}
const sum_w = sum(({ w }) => w);
const sum_wx = sum(({ w, x }) => w * x);
const sum_wy = sum(({ w, y }) => w * y);
const x_bar = sum_wx / sum_w;
const y_bar = sum_wy / sum_w;
// undo normalization for mu to get a simple average
const mu = sum(({ w, x }) => w * x) / samples.d_120;
const sigma_numerator = sum(({ w, x }) => w * (x - x_bar) ** 2);
const sigma = Math.sqrt(sigma_numerator / sum_w);
const r_numerator = sum(({ w, x, y }) => w * (x - x_bar) * (y - y_bar));
const r_denominator_x = sum(({ w, x }) => w * (x - x_bar) ** 2);
const r_denominator_y = sum(({ w, y }) => w * (y - y_bar) ** 2);
const r = r_numerator / Math.sqrt(r_denominator_x * r_denominator_y);
entry.mu[key] = mu;
entry.sigma[key] = sigma;
entry.r[key] = isNaN(r) ? 0 : r;
});
averages[id] = entry as AverageDefinitionsEntry;
});
const time = Date.now();
const latest = Math.round(time / DAY);
const averageDefinitions = {
averages,
samples,
time,
} satisfies AverageDefinitions;
const manifest: AverageDefinitionsManifest = {
version: 1,
latest,
};
commitAssets('averages', [
{
path: `averages/${latest}.pb`,
content: await encodeToBase64(
'average_definitions',
'blitzkit.AverageDefinitions',
averageDefinitions,
),
encoding: 'base64',
},
{
path: 'averages/manifest.json',
content: JSON.stringify(manifest),
encoding: 'utf-8',
},
]);
}
@@ -0,0 +1,51 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function boosterIcons() {
const boosterFiles = (await readdir(`${DATA}/Gfx/Shared/boosters`)).filter(
(file) => !file.endsWith('@2x.txt.dvpl') && !file.startsWith('texture0'),
);
const image = sharp(
await readDVPLFile(`${DATA}/Gfx/Shared/boosters/texture0.packed.webp.dvpl`),
);
const changes: FileChange[] = [];
await Promise.all(
boosterFiles.map(async (file) => {
const name = file.match(/booster_(.+).txt.dvpl/)?.[1];
if (name === undefined) return;
const sizes = (
await readStringDVPL(`${DATA}/Gfx/Shared/boosters/${file}`)
)
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await image
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/boosters/${name}.webp`,
content,
encoding: 'base64',
});
}),
);
await commitAssets('booster icons', changes);
}
@@ -0,0 +1,20 @@
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function camouflageIcons() {
console.log('Building camouflage icons...');
const content = await readDVPLFile(
`${DATA}/Gfx/UI/Hangar/IconCamouflage.packed.webp.dvpl`,
).then((content) => content.toString('base64'));
const changes: FileChange[] = [
{
content,
encoding: 'base64',
path: 'icons/camo.webp',
},
];
await commitAssets('camo icons', changes);
}
@@ -0,0 +1,11 @@
import { readdir } from 'fs/promises';
import { argv } from 'process';
const isDepot = argv.includes('--depot');
export const DATA = isDepot
? await (async () => {
const [installationVersion] = await readdir(`depots/444202`);
return `depots/444202/${installationVersion}/Data`;
})()
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
@@ -0,0 +1,192 @@
import { existsSync } from 'fs';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { ConsumablesCommon, ProvisionsCommon } from './definitions';
interface Mappings {
Header: { version: number };
StyleSheets: {
selectors: string[];
properties: { 'bg-sprite': string };
}[];
}
const listItemsPattern = /<items path="(.+)\.xml"\/>/g;
export async function consumableProvisionIcons() {
console.log('Building consumable and provision icons...');
const changes: FileChange[] = [];
const styles = [
'UI/Styles/Lobby/Inventory/Event/InventoryBigStyles.yaml',
'UI/Styles/Lobby/Inventory/Event/InventoryNormalStyles.yaml',
'UI/Styles/Lobby/Inventory/InventoryNormalStyles.yaml',
'UI/Screens3/Lobby/Inventory/Equipment/EquipmentItemImage.style.yaml',
'UI/Screens/Battle/Styles/BattleEquipmentStyles.yaml',
];
const styleSheets = await Promise.all(
styles.map(async (path) =>
Object.values(
(await readYAMLDVPL<Mappings>(`${DATA}/${path}.dvpl`)).StyleSheets,
),
),
).then((array) => array.flat());
for (const match of (
await readStringDVPL(
`${DATA}/XML/item_defs/vehicles/common/consumables/list.xml.dvpl`,
)
).matchAll(listItemsPattern)) {
const consumablesCommon = await readXMLDVPL<{
root: ConsumablesCommon;
}>(
`${DATA}/XML/item_defs/vehicles/common/consumables/${match[1]}.xml.dvpl`,
);
await Promise.all(
Object.values(consumablesCommon.root).map(async (consumable) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.some((selector) =>
selector.includes(`${consumable.icon} `),
),
);
if (!styleSheet) {
console.warn(
`No style sheet found for consumable ${consumable.icon}; skipping...`,
);
return;
}
const configPath = styleSheet.properties['bg-sprite']
.replace('~res:/', '')
.replace('.psd', '')
.replace('.txt', '');
if (existsSync(`${DATA}/${configPath}.packed.webp.dvpl`)) {
const image = sharp(
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
);
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
changes.push({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
} else {
const consumablesTexture = sharp(
await readDVPLFile(
`${`${DATA}/${configPath}`.split('/').slice(0, -1).join('/')}/texture0.packed.webp.dvpl`,
),
);
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await consumablesTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/consumables/${consumable.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
}
for (const match of (
await readStringDVPL(
`${DATA}/XML/item_defs/vehicles/common/provisions/list.xml.dvpl`,
)
).matchAll(listItemsPattern)) {
const provisionsCommon = await readXMLDVPL<{
root: ProvisionsCommon;
}>(`${DATA}/XML/item_defs/vehicles/common/provisions/${match[1]}.xml.dvpl`);
await Promise.all(
Object.values(provisionsCommon.root).map(async (provision) => {
const styleSheet = styleSheets.find((styleSheet) =>
styleSheet.selectors.some((selector) =>
selector.includes(`${provision.icon} `),
),
);
if (!styleSheet) {
console.warn(
`No style sheet found for provision ${provision.icon}; skipping...`,
);
return;
}
const configPath = styleSheet.properties['bg-sprite']
.replace('~res:/', '')
.replace('.psd', '')
.replace('.txt', '');
if (existsSync(`${DATA}/${configPath}.packed.webp.dvpl`)) {
const image = sharp(
await readDVPLFile(`${DATA}/${configPath}.packed.webp.dvpl`),
);
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
} else {
const provisionsTexture = sharp(
await readDVPLFile(
`${`${DATA}/${configPath}`.split('/').slice(0, -1).join('/')}/texture0.packed.webp.dvpl`,
),
);
const sizes = (await readStringDVPL(`${DATA}/${configPath}.txt.dvpl`))
.split('\n')[4]
.split(' ')
.map(Number);
const content = (
await provisionsTexture
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/provisions/${provision.id}.webp`,
encoding: 'base64',
content,
});
}
}),
);
}
await commitAssets('consumable and provision icons', changes);
}
@@ -0,0 +1,79 @@
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { assertSecret } from '../../src/core/blitzkit/secret';
import { DATA } from './constants';
const ICONS = [
['currency_silver_m.packed.webp', 'silver'],
['currency_premium_xl.packed.webp', 'premium'],
['currency_gold_m.packed.webp', 'gold'],
['currency_free-xp_xl.packed.webp', 'free-xp'],
['currency_elite-xp_xl.packed.webp', 'elite-xp'],
['currency_crew-xp_xl.packed.webp', 'crew-xp'],
['currency_battle-xp_xl.packed.webp', 'xp'],
];
type BlitzGlossary = Record<
string,
{
image_url: null | string;
}
>;
export async function currencies() {
console.log('Building currency icons...');
const changes: FileChange[] = [];
await Promise.all(
ICONS.map(async ([file, name]) => {
const content = (
await sharp(
await readDVPLFile(`${DATA}/Gfx/Lobby/currency/${file}.dvpl`),
)
.trim()
.toBuffer()
).toString('base64');
changes.push({
content,
encoding: 'base64',
path: `icons/currencies/${name}.webp`,
});
}),
);
const glossary = await fetch(assertSecret(process.env.WOTB_GLOSSARY)).then(
(response) => response.json() as Promise<BlitzGlossary>,
);
await Promise.all(
Object.entries(glossary)
.filter(([key]) => /^prx_season_\d+$/.test(key))
.map(async ([key, item]) => {
if (item.image_url === null) throw new Error(`No image_url for ${key}`);
const imageRaw = await fetch(item.image_url).then((response) =>
response.arrayBuffer(),
);
const content = (
await sharp(imageRaw)
.trim({
threshold: 100,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
).toString('base64');
changes.push({
content,
encoding: 'base64',
path: `icons/currencies/${key}.webp`,
});
}),
);
await commitAssets('currency icons', changes);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { OptionalDevices } from './definitions';
interface Mappings {
Header: { version: number };
StyleSheets: {
selectors: string[];
properties: { 'bg-sprite': string };
}[];
}
export async function equipmentIcons() {
console.log('Building equipment icons...');
const changes: FileChange[] = [];
const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>(
`${DATA}/XML/item_defs/vehicles/common/optional_devices.xml.dvpl`,
);
const mappings = await readYAMLDVPL<Mappings>(
`${DATA}/UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml.dvpl`,
);
const image = sharp(
await readDVPLFile(
`${DATA}/Gfx/UI/InventoryIcons/Big/OptionalDevices/texture0.packed.webp.dvpl`,
),
);
await Promise.all(
Object.entries(optionalDevices.root).map(
async ([optionalDeviceKey, optionalDevice]) => {
if (optionalDeviceKey === 'nextAvailableId') return;
const mapping = mappings.StyleSheets.find((mapping) =>
mapping.selectors.includes(
`.optional_device_item.${optionalDevice.icon} #Img`,
),
);
if (!mapping) {
console.warn(
`No mapping found for ${optionalDevice.icon}; skipping...`,
);
return;
}
const configPathRaw = mapping.properties['bg-sprite'];
const configPath = configPathRaw.replace('~res:/', '');
if (configPath.startsWith('Gfx/Lobby')) {
const configPathWebp = configPath.replace('.txt', '');
const image = sharp(
await readDVPLFile(`${DATA}/${configPathWebp}.packed.webp.dvpl`),
);
const content = (await image.trim().toBuffer()).toString('base64');
changes.push({
path: `icons/equipment/${optionalDevice.id}.webp`,
encoding: 'base64',
content,
});
} else {
const config = await readStringDVPL(`${DATA}/${configPath}.dvpl`);
const sizes = config.split('\n')[4].split(' ').map(Number);
const content = (
await image
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64');
changes.push({
path: `icons/equipment/${optionalDevice.id}.webp`,
encoding: 'base64',
content,
});
}
},
),
);
await commitAssets('equipment icons', changes);
}
+84
View File
@@ -0,0 +1,84 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function flags() {
console.log('Building flags...');
const changes = [
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_profile-stat_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const image = sharp(
await readDVPLFile(`${DATA}/Gfx/Lobby/flags/${flag}`),
);
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/circle/${name}.webp`,
} satisfies FileChange;
}),
)),
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_tutor-tank_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(
`${DATA}/Gfx/Lobby/flags/${flag}`,
);
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/scratched/${name}.webp`,
} satisfies FileChange;
}),
)),
...(await Promise.all(
(await readdir(`${DATA}/Gfx/Lobby/flags`))
.filter(
(flag) =>
flag.startsWith('flag_filter_') &&
flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = (
await sharp(await readDVPLFile(`${DATA}/Gfx/Lobby/flags/${flag}`))
.trim({
threshold: 100,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
).toString('base64');
const name = flag.match(/flag_filter_(.+)@2x\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/fade_small/${name}.webp`,
} satisfies FileChange;
}),
)),
];
await commitAssets('flags', changes);
}
@@ -0,0 +1,54 @@
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { SquadBattleTypeStyles } from './definitions';
export async function gameModeBanners() {
console.log('Building game mode banners...');
const changes: FileChange[] = [];
const gameTypeSelectorStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens/Lobby/Hangar/GameTypeSelector.yaml.dvpl`,
);
const squadBattleTypeStyles = await readYAMLDVPL<SquadBattleTypeStyles>(
`${DATA}/UI/Screens3/Lobby/Hangar/Squad/SquadBattleType.yaml.dvpl`,
);
const bannerMatches: { name: string; path: string }[] = [];
for (const match of gameTypeSelectorStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/eGameMode\.([a-zA-Z]+) -> "~res:([^"]+)"/g,
)) {
bannerMatches.push({ name: match[1], path: match[2] });
}
for (const match of squadBattleTypeStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/"(\d+)" -> "battleType\/([a-zA-Z]+)"/g,
)) {
const id = Number(match[1]);
const name = match[2];
let path = bannerMatches.find(
(bannerMatch) => bannerMatch.name.toLowerCase() === name.toLowerCase(),
)?.path;
if (path === undefined) {
path = `/Gfx/UI/Hangar/GameTypes/battle-type_${name.toLowerCase()}`;
}
const content = (
await sharp(await readDVPLFile(`${DATA}${path}.packed.webp.dvpl`))
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
.toBuffer()
).toString('base64');
changes.push({
path: `icons/game_mode_banners/${id}.webp`,
content,
encoding: 'base64',
});
}
await commitAssets('game mode banners', changes);
}
+54
View File
@@ -0,0 +1,54 @@
import { config } from 'dotenv';
import { argv } from 'process';
config();
const allTargets = argv.includes('--all-targets');
const targets = argv
.find((argument) => argument.startsWith('--target'))
?.split('=')[1]
.split(',');
import { boosterIcons } from './boosterIcons';
import { camouflageIcons } from './camouflageIcons';
import { consumableProvisionIcons } from './consumableProvisionIcons';
import { currencies } from './currencies';
import { definitions } from './definitions';
import { equipmentIcons } from './equipmentIcons';
import { flags } from './flags';
import { gameModeBanners } from './gameModeBanners';
import { moduleIcons } from './moduleIcons';
import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels';
if (!targets && !allTargets) throw new Error('No target(s) specified');
const methods = [
tankModels,
tankArmors,
gameModeBanners,
definitions,
tankIcons,
currencies,
skillIcons,
flags,
shellIcons,
moduleIcons,
equipmentIcons,
consumableProvisionIcons,
camouflageIcons,
boosterIcons,
];
for (const method of methods) {
if (allTargets || targets?.includes(method.name)) {
try {
await method();
} catch (error) {
console.warn(`Failed method ${method.name}; skipping...`, error);
}
}
}
@@ -0,0 +1,37 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function moduleIcons() {
console.log('Building module icons...');
const changes = await Promise.all(
(await readdir(`${DATA}/Gfx/UI/ModulesTechTree`))
.filter(
(file) =>
!file.endsWith('@2x.packed.webp.dvpl') && file.startsWith('vehicle'),
)
.map(async (file) => {
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
const content = (
await sharp(
await readDVPLFile(`${DATA}/Gfx/UI/ModulesTechTree/${file}`),
)
.trim()
.toBuffer()
).toString('base64');
return {
content,
path: `icons/modules/${name}.webp`,
encoding: 'base64',
} satisfies FileChange;
}),
);
await commitAssets('module icons', changes);
}
@@ -0,0 +1,51 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
export async function shellIcons() {
console.log('Building shell icons...');
const image = sharp(
await readDVPLFile(
`${DATA}/Gfx/Shared/tank-supply/ammunition/big/texture0.packed.webp.dvpl`,
),
);
const changes = await Promise.all(
(await readdir(`${DATA}/Gfx/Shared/tank-supply/ammunition/big`))
.filter((file) => file.endsWith('_l.txt.dvpl'))
.map(async (file) => {
const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
const sizes = (
await readStringDVPL(
`${DATA}/Gfx/Shared/tank-supply/ammunition/big/${file}`,
)
)
.split('\n')[4]
.split(' ')
.map(Number);
return {
content: (
await image
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64'),
encoding: 'base64',
path: `icons/shells/${name}.webp`,
} satisfies FileChange;
}),
);
await commitAssets('shell icons', changes);
}
@@ -0,0 +1,51 @@
import sharp from 'sharp';
import { TankClass } from '../../src/components/Tanks';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
interface SkillIcon {
name: string;
state: number;
}
export interface Avatar {
roles: unknown;
skillsByClasses: Record<TankClass, string>;
skills: {
[name: string]: {
userString: string;
effectDescription: string;
tipDescription: string;
icon: SkillIcon | SkillIcon[];
type: 'continuous' | 'trigger';
};
};
}
export async function skillIcons() {
console.log('Building skill icons...');
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const changes = await Promise.all(
Object.values(avatar.root.skills).map(async (skill) => {
const icon = Array.isArray(skill.icon) ? skill.icon[0] : skill.icon;
const name = icon.name.split('/').at(-1)!.replace(/_\d$/, '');
const path = `${DATA}${icon.name.replace('~res:', '')}.packed.webp.dvpl`;
const image = sharp(await readDVPLFile(path)).trim();
const content = (await image.toBuffer()).toString('base64');
return {
content,
encoding: 'base64',
path: `icons/skills/${name}.webp`,
} satisfies FileChange;
}),
);
commitAssets('skill icons', changes);
}
@@ -0,0 +1,46 @@
import { NodeIO } from '@gltf-transform/core';
import { readdir } from 'fs/promises';
import { extractArmor } from '../../src/core/blitz/extractArmor';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
export async function tankArmors() {
console.log('Building tank armors...');
const changes: FileChange[] = [];
const nodeIO = new NodeIO();
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
);
await Promise.all(
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
if (tankKey.includes('tutorial_bot')) return;
const id = toUniqueId(nation, tank.id);
const model = await extractArmor(DATA, `${nation}-${tankKey}`);
changes.push({
path: `3d/tanks/armor/${id}.glb`,
encoding: 'base64',
content: Buffer.from(await nodeIO.writeBinary(model)).toString(
'base64',
),
});
}),
);
}),
);
await commitAssets('tank armor', changes);
}
@@ -0,0 +1,101 @@
import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { Vector3Tuple } from 'three';
import { NATION_IDS } from '../../src/constants/nations';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
export interface TankParameters {
resourcesPath: {
smallIconPath: string;
bigIconPath: string;
blitzModelPath: string;
};
collision: {
[key: string]: {
points: Vector3Tuple;
bbox: {
min: Vector3Tuple;
max: Vector3Tuple;
};
averageThickness?: Record<string, number>;
};
};
maskSlice?: {
[key: string]:
| {
enabled: boolean;
planePosition: Vector3Tuple;
planeNormal: Vector3Tuple;
planeAxis: Vector3Tuple;
frustumConeBaseRadius: number;
frustumConeAngleDegrees: number;
planeSliceMode: number;
frustumConeSliceMode: number;
}
| undefined;
};
}
export async function tankIcons() {
console.log('Building tank icons...');
const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
);
await Promise.all(
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
if (tankKey.includes('tutorial_bot')) return;
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
);
const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath
.replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`;
const bigPath = `${DATA}/${parameters.resourcesPath.bigIconPath
.replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`;
const big = await sharp(await readDVPLFile(bigPath))
.trim()
.toBuffer();
const small = await sharp(await readDVPLFile(smallPath))
.trim()
.toBuffer();
if (big) {
changes.push({
content: big.toString('base64'),
encoding: 'base64',
path: `icons/tanks/big/${id}.webp`,
});
}
if (small) {
changes.push({
content: small.toString('base64'),
encoding: 'base64',
path: `icons/tanks/small/${id}.webp`,
});
}
}),
);
}),
);
await commitAssets('tank icons', changes);
}
@@ -0,0 +1,55 @@
import { NodeIO } from '@gltf-transform/core';
import { readdir } from 'fs/promises';
import { extractModel } from '../../src/core/blitz/extractModel';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA } from './constants';
import { VehicleDefinitionList } from './definitions';
import { TankParameters } from './tankIcons';
export async function tankModels() {
console.log('Building tank models...');
const nodeIO = new NodeIO();
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
for (const nationIndex in nations) {
const nation = nations[nationIndex];
const changes: FileChange[] = [];
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
);
console.log(`Building models for ${nation}`);
await Promise.all(
Object.entries(tanks.root).map(async ([tankKey, tank]) => {
if (tankKey.includes('tutorial_bot')) return;
const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml.dvpl`,
);
const model = await extractModel(
DATA,
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''),
);
changes.push({
path: `3d/tanks/models/${id}.glb`,
content: Buffer.from(await nodeIO.writeBinary(model)).toString(
'base64',
),
encoding: 'base64',
});
}),
);
await commitAssets(`tank models ${nation}`, changes);
}
}
@@ -0,0 +1,195 @@
import { chunk } from 'lodash';
import { argv } from 'process';
import {
RatingInfo,
RatingNeighbors,
RatingPlayer,
} from '../src/commands/ratingLeaderboard';
import { RegionSubdomain } from '../src/constants/regions';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import regionSubdomainToRegion from '../src/core/blitz/regionSubdomainToRegion';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { FileChange } from '../src/core/blitzkit/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkit/patientFetchJSON';
import { superCompress } from '../src/core/blitzkit/superCompress';
import {
BkrlBaseEntry,
BkrlFormat,
BkrlSuperset1Entry,
BkrlWriteStream,
} from '../src/core/streams/bkrl';
import { CdonValue, CdonWriteStream } from '../src/core/streams/cdon';
/*
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*/
const production = argv.includes('--production');
const target = argv
.find((arg) => arg.startsWith('--target='))
?.split('=')[1] as 'midnight' | 'latest' | undefined;
const regionSubdomains = argv
.find((arg) => arg.startsWith('--region='))
?.split('=')[1];
if (!target) throw new Error('Target parameter not specified');
if (!regionSubdomains) throw new Error('Region parameter not specified');
regionSubdomains.split(',').forEach(async (regionSubdomain) => {
const region = regionSubdomainToRegion(regionSubdomain as RegionSubdomain);
console.log(`Building ${target} rating leaderboard for ${region}`);
const NEIGHBORS = 2 ** 8;
const PLAYERS = await patientFetchJSON<RatingInfo>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current rating season running');
}
const players: Record<number, RatingPlayer> = {};
const leaderboard: BkrlBaseEntry[] = [];
let registered = 0;
async function branchFromPlayer(id: number) {
const { neighbors } = await patientFetchJSON<RatingNeighbors>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
branches.push(branchFromPlayer(lastPlayer.spa_id));
neighbors.forEach((neighbor) => {
if (players[neighbor.number]) return;
players[neighbor.number] = neighbor;
registered++;
});
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
await Promise.all(branches);
}
async function branchFromLeague(id: number) {
const { result } = await patientFetchJSON<{ result: RatingPlayer[] }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
);
const firstPlayer = result[0];
const lastPlayer = result.at(-1)!;
await Promise.all([
branchFromPlayer(firstPlayer.spa_id),
branchFromPlayer(lastPlayer.spa_id),
]);
}
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
branchFromLeague(2),
branchFromLeague(3),
branchFromLeague(4),
]);
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
const listing = players[index + 1];
if (listing) {
leaderboard[index] = { id: listing.spa_id, score: listing.score };
} else {
console.warn(`Position ${index + 1} had no player`);
leaderboard[index] = { id: 0, score: 0 };
}
}
console.log('Requesting further stats...');
const comprehensiveLeaderboard: BkrlSuperset1Entry[] = [];
let chunkIndex = 0;
for (const leaderboardChunk of chunk(leaderboard, 100)) {
console.log(
`Chunk ${chunkIndex + 1} of ${Math.ceil(leaderboard.length / 100)}`,
);
const stats = await getAccountInfo(
region,
leaderboardChunk.map(({ id }) => id),
['statistics.rating'],
);
stats.forEach((stat, index) => {
const trueIndex = chunkIndex * 100 + index;
comprehensiveLeaderboard[trueIndex] =
leaderboardChunk[index].id === 0 ||
stat?.statistics.rating === undefined
? {
battles: 0,
damage: 0,
id: 0,
kills: 0,
score: 0,
survived: 0,
wins: 0,
}
: {
id: leaderboardChunk[index].id,
score: leaderboardChunk[index].score,
battles: stat.statistics.rating.battles,
damage: stat.statistics.rating.damage_dealt,
kills: stat.statistics.rating.frags,
survived: stat.statistics.rating.survived_battles,
wins: stat.statistics.rating.wins,
};
});
chunkIndex++;
}
const info = await patientFetchJSON<RatingInfo & { detail: undefined }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
const normalizedServer = regionSubdomain === 'na' ? 'com' : regionSubdomain;
const infoContent = new CdonWriteStream().cdon(
info as unknown as CdonValue,
).uint8Array;
const leaderboardWriteStream = new BkrlWriteStream();
leaderboardWriteStream.bkrl({
format: BkrlFormat.Superset1,
entries: comprehensiveLeaderboard,
});
const changes: FileChange[] = [
{
path: `regions/${normalizedServer}/rating/${info.current_season}/info.cdon.lz4`,
content: superCompress(infoContent),
encoding: 'base64',
},
{
path: `regions/${normalizedServer}/rating/${info.current_season}/${target}.bkrl`,
content: Buffer.from(leaderboardWriteStream.uint8Array).toString(
'base64',
),
encoding: 'base64',
},
];
commitAssets(`rating leaderboard ${regionSubdomain} ${target}`, changes);
});
+158
View File
@@ -0,0 +1,158 @@
import { chunk, times, uniq } from 'lodash';
import { compress, decompress } from 'lz4js';
import { REGIONS, Region } from '../src/constants/regions';
import { retryAbleBlitzFetchEvent } from '../src/core/blitz/fetchBlitz';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import { MIN_IDS, idToRegion } from '../src/core/blitz/idToRegion';
import { asset } from '../src/core/blitzkit/asset';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { DiscoveredIdsDefinitions } from '../src/core/blitzkit/discoveredIdDefinitions';
import { DidsReadStream, DidsWriteStream } from '../src/core/streams/dids';
const CHUNK_SIZE = 2 ** 21;
const RUN_TIME = 1000 * 60 * 60 * 5.5;
const PROGRESS_UPDATE_FREQUENCY = 1000 * 60;
const MAX_REQUESTS = 10;
const ACCOUNTS_PER_CALL = 100;
const TERMINATION_THRESHOLD = 1000;
const startTime = Date.now();
const indexableRegions = [...REGIONS];
let ids: number[] = [];
const preDiscoveredManifest = (await fetch(asset('ids/manifest.json')).then(
(response) => response.json(),
)) as DiscoveredIdsDefinitions;
console.log(
`Fetching ${preDiscoveredManifest.chunks} pre-discovered chunks...`,
);
let chunkIndex = 0;
while (chunkIndex < preDiscoveredManifest.chunks) {
const preDiscovered = await fetch(asset(`ids/${chunkIndex}.dids.lz4`)).then(
async (response) => {
const buffer = await response.arrayBuffer();
const decompressed = decompress(new Uint8Array(buffer)).buffer;
return new DidsReadStream(decompressed as ArrayBuffer).dids();
},
);
// no spread syntax: https://github.com/oven-sh/bun/issues/11734
ids = ids.concat(preDiscovered);
console.log(
`Pre-discovered ${preDiscovered.length} ids (chunk ${chunkIndex})`,
);
chunkIndex++;
}
const regionalIdIndex: Record<Region, number> = {
asia: ids.findLast((id) => idToRegion(id) === 'asia') ?? MIN_IDS.asia,
com: ids.findLast((id) => idToRegion(id) === 'com') ?? MIN_IDS.com,
eu: ids.findLast((id) => idToRegion(id) === 'eu') ?? MIN_IDS.eu,
};
const zeroStreak: Record<Region, number> = { asia: 0, com: 0, eu: 0 };
let regionIndex = 0;
let outOfTimeFlagged = false;
async function verify(region: Region, ids: number[]) {
const infos = await getAccountInfo(region, ids, undefined, {
// cheeky way of requesting nothing
fields: 'account_id,-account_id',
});
const filtered = ids.filter((_, index) => {
const info = infos[index];
return info !== null;
});
return filtered;
}
let discardAttempts = 0;
let lastProgressUpdate = 0;
retryAbleBlitzFetchEvent.on(() => discardAttempts++);
const interval = setInterval(async () => {
if (discardAttempts > 0) return discardAttempts--;
const region = indexableRegions[regionIndex];
const idsToVerify = times(
ACCOUNTS_PER_CALL,
(index) => regionalIdIndex[region] + index,
);
const verified = await verify(region, idsToVerify);
if (Date.now() - lastProgressUpdate > PROGRESS_UPDATE_FREQUENCY) {
lastProgressUpdate = Date.now();
console.log(`Discovered ${ids.length} ids in ${Date.now() - startTime}ms`);
}
if (verified.length === 0) {
zeroStreak[region]++;
if (zeroStreak[region] >= TERMINATION_THRESHOLD) {
const sliceableIndex = indexableRegions.indexOf(region);
if (sliceableIndex !== -1) {
console.log(`Stopped discovery for ${region}`);
indexableRegions.splice(sliceableIndex, 1);
if (indexableRegions.length === 0) {
console.log('All players discovered exhaustively!');
post();
}
}
}
} else {
zeroStreak[region] = 0;
ids.push(...verified);
}
if (Date.now() - startTime > RUN_TIME && !outOfTimeFlagged) {
outOfTimeFlagged = true;
console.log('Out of time');
post();
}
regionIndex = (regionIndex + 1) % indexableRegions.length;
regionalIdIndex[region] += ACCOUNTS_PER_CALL;
}, 1000 / MAX_REQUESTS);
function post() {
clearInterval(interval);
ids = uniq(ids).sort((a, b) => a - b);
console.log(`Uploading ${ids.length} ids...`);
const idsChunked = chunk(ids, CHUNK_SIZE);
const files = idsChunked.map((chunk, index) => {
const didsWriteStream = new DidsWriteStream().dids(chunk);
const buffer = didsWriteStream.uint8Array;
const compressed = compress(buffer);
console.log(
`Chunk ${index} with ${chunk.length} ids (compressed: ${compressed.length}; uncompressed: ${buffer.length})`,
);
const content = Buffer.from(compressed).toString('base64');
return {
content,
encoding: 'base64' as const,
path: `ids/${index}.dids.lz4`,
};
});
commitAssets('discovered ids', [
...files,
{
content: JSON.stringify({
chunks: idsChunked.length,
count: ids.length,
time: Date.now(),
} satisfies DiscoveredIdsDefinitions),
encoding: 'utf-8',
path: 'ids/manifest.json',
},
]);
}
@@ -0,0 +1,3 @@
import { assertSecret } from '../src/core/blitzkit/secret';
assertSecret(process.env.ZIP_URL);
+131
View File
@@ -0,0 +1,131 @@
import { readdir } from 'fs/promises';
import { google } from 'googleapis';
import { cloneDeep, uniqBy } from 'lodash';
import { parse as parseYaml } from 'yaml';
import { youtubers } from '../src/constants/youtubers';
import { readXMLDVPL } from '../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../src/core/blitz/toUniqueId';
import { asset } from '../src/core/blitzkit/asset';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { Reviews, Video } from '../src/core/blitzkit/reviews';
import { decode } from '../src/core/protobuf/decode';
import { encodeToBase64 } from '../src/core/protobuf/encodeToBase64';
import { DATA } from './buildAssets/constants';
import {
BlitzStrings,
botPattern,
VehicleDefinitionList,
} from './buildAssets/definitions';
const MAX_QUERIES = 128;
console.log('Finding reviews...');
const currentReviews = await fetch(asset('definitions/reviews.pb'))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<Reviews>('reviews', 'blitzkit.Reviews', new Uint8Array(buffer)),
);
const auth = await google.auth.getClient({
scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
});
const youtube = google.youtube({ version: 'v3', auth });
const nations = await readdir(`${DATA}/XML/item_defs/vehicles`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
const stringsPreInstalled = await readYAMLDVPL<BlitzStrings>(
`${DATA}/Strings/en.yaml.dvpl`,
);
const stringsCache = await fetch(
'https://stufficons.wgcdn.co/localizations/en.yaml',
)
.then((response) => response.text())
.then((string) => parseYaml(string) as BlitzStrings);
const strings = {
...stringsPreInstalled,
...stringsCache,
};
const tanks: { id: number; name: string; tier: number }[] = [];
await Promise.all(
nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/XML/item_defs/vehicles/${nation}/list.xml.dvpl`,
);
await Promise.all(
Object.entries(tankList.root).map(async ([tankKey, tank]) => {
if (botPattern.test(tankKey)) return null;
const id = toUniqueId(nation, tank.id);
const name =
(tank.shortUserString ? strings[tank.shortUserString] : undefined) ??
strings[tank.userString];
const tier = tank.level;
tanks.push({ id, name, tier });
}),
);
}),
);
const tanksSanitized = tanks
.sort((a, b) => b.tier - a.tier)
.sort((a, b) => {
const lastUpdatedA = currentReviews.reviews[a.id]?.last_updated ?? 0;
const lastUpdatedB = currentReviews.reviews[b.id]?.last_updated ?? 0;
return lastUpdatedA - lastUpdatedB;
});
const reviews: Reviews = cloneDeep(currentReviews);
let done = 0;
for (const tank of tanksSanitized) {
if (done >= MAX_QUERIES) {
console.log(`Limit reached with ${done} queries; stopping...`);
break;
}
try {
const results = await youtube.search.list({
part: ['snippet'],
q: `World of Tanks Blitz ${tank.name}`,
});
if (!results.data.items || results.data.items.length === 0) continue;
const items = uniqBy(
results.data.items.filter(
(item) =>
item.snippet?.channelId &&
item.id?.videoId &&
youtubers.some(({ id }) => id === item.snippet?.channelId),
),
(item) => item.snippet!.channelId,
);
reviews.reviews[tank.id] = {
last_updated: Date.now(),
videos: items.map(
(item) =>
({
id: item.id!.videoId!,
author: item.snippet!.channelId!,
}) satisfies Video,
),
};
console.log(`${++done} done; found ${items.length} for ${tank.name}`);
await new Promise((resolve) => setTimeout(resolve, 1000 / 5));
} catch (error) {
console.warn(`Limit reached with ${done} queries`);
break;
}
}
const content = await encodeToBase64('reviews', 'blitzkit.Reviews', reviews);
await commitAssets('reviews', [
{ content, encoding: 'base64', path: 'definitions/reviews.pb' },
]);
@@ -0,0 +1,33 @@
import decompress from 'decompress';
import { existsSync } from 'fs';
import { mkdir, readdir, writeFile } from 'fs/promises';
if (!existsSync('temp')) {
console.log('Creating temp directory...');
await mkdir('temp');
}
console.log('Downloading depot downloader...');
await fetch(
'https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest',
)
.then(
(response) =>
response.json() as Promise<{
assets: { browser_download_url: string; name: string }[];
}>,
)
.then(({ assets }) => {
const asset = assets.find(({ name }) => name.includes('linux-x64'));
if (!asset) throw new Error('No asset found');
return fetch(asset.browser_download_url);
})
.then((response) => response.arrayBuffer())
.then((arrayBuffer) =>
writeFile('temp/depotDownloader.zip', new Uint8Array(arrayBuffer)),
);
console.log('Unzipping...');
await decompress('temp/depotDownloader.zip', 'temp/depotDownloader');
console.log('Download complete; files:', await readdir('temp/depotDownloader'));
+26
View File
@@ -0,0 +1,26 @@
import { tankDefinitions } from '../src/core/blitzkit/tankDefinitions';
// random vercel bug forces require import
const { writeFileSync } = require('fs');
console.log('Building sitemap...');
const awaitedTankDefinitions = await tankDefinitions;
const values = Object.values(awaitedTankDefinitions);
const txt = values
.map((tank) => `https://blitzkit.app/tools/tankopedia/${tank.id}/`)
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${values
.map(
(tank) =>
`<url><loc>https://blitzkit.app/tools/tankopedia/${tank.id}/</loc></url>`,
)
.join('\n')}
</urlset>`;
writeFileSync('public/sitemap.txt', txt);
writeFileSync('public/sitemap.xml', xml);
console.log(`Built sitemap for ${values.length} tanks`);