feat: migration 2

This commit is contained in:
TrèsAbhi committed 2024-09-06 23:17:53 -05:00
1 parent e1c8194beb
commit 6285f33989
305 files changed
+414 -414

No files matched your search

@@ -0,0 +1,84 @@
import {
PitchLimits,
YawLimits,
} from '@blitzkit/core/src/blitzkit/modelDefinitions';
import { normalizeAngleRad } from '@blitzkit/core/src/math/normalizeAngleRad';
import { clamp } from 'lodash';
import { degToRad } from 'three/src/math/MathUtils';
export function applyPitchYawLimits(
pitch: number, // rad
yaw: number, // rad
pitchLimits: PitchLimits, // deg
yawLimits?: YawLimits, // deg
verticalStabilizer = false,
) {
let newPitch = pitch;
let newYaw = yaw;
if (yawLimits) {
newYaw = normalizeAngleRad(
clamp(newYaw, -degToRad(yawLimits.max), -degToRad(yawLimits.min)),
);
}
const depressionBuff = verticalStabilizer ? degToRad(3) : 0;
const elevationBuff = verticalStabilizer ? degToRad(4) : 0;
let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff;
let upperPitch = -degToRad(pitchLimits.min) + elevationBuff;
const transition = degToRad(pitchLimits.transition ?? 10);
if (pitchLimits.back) {
const range = degToRad(pitchLimits.back.range);
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
if (yawRotatedAbs <= range + transition) {
if (yawRotatedAbs <= range) {
// inside range
lowerPitch = -degToRad(pitchLimits.back.max);
upperPitch = -degToRad(pitchLimits.back.min);
} else {
// inside transition
const transitionProgress = (yawRotatedAbs - range) / transition;
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.max +
transitionProgress * pitchLimits.max;
const minInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.min +
transitionProgress * pitchLimits.min;
lowerPitch = -degToRad(maxInterpolatedPitch);
upperPitch = -degToRad(minInterpolatedPitch);
}
}
}
if (pitchLimits.front) {
const range = degToRad(pitchLimits.front.range);
const yawAbs = Math.abs(normalizeAngleRad(newYaw));
if (yawAbs <= range + transition) {
if (yawAbs <= range) {
// inside range
lowerPitch = -degToRad(pitchLimits.front.max);
upperPitch = -degToRad(pitchLimits.front.min);
} else {
// inside transition
const transitionProgress = (yawAbs - range) / transition;
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.max +
transitionProgress * pitchLimits.max;
const minInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.min +
transitionProgress * pitchLimits.min;
lowerPitch = -degToRad(maxInterpolatedPitch);
upperPitch = -degToRad(minInterpolatedPitch);
}
}
}
newPitch = clamp(newPitch, lowerPitch, upperPitch);
return [newPitch, newYaw];
}
@@ -0,0 +1,3 @@
export function canSplash(type: ShellDefinition['type']): type is ShellType.HE {
return type === ShellType.HE;
}
@@ -0,0 +1,6 @@
import { Vector3, Vector3Tuple } from 'three';
import { rotateDavaVector } from './rotateDavaVector';
export function correctZYTuple(tuple: Vector3Tuple) {
return rotateDavaVector(new Vector3(tuple[0], tuple[1], -tuple[2]));
}
@@ -0,0 +1,141 @@
import { Accessor, Document, Node, Scene } from '@gltf-transform/core';
import { times } from 'lodash';
import { Hierarchy, Sc2ReadStream } from '../streams/sc2';
import { ScgReadStream } from '../streams/scg';
import { VertexAttribute } from '../streams/scpg';
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from './extractModel/constants';
import { readDVPLFile } from './readDVPLFile';
export async function extractArmor(data: string, fileName: string) {
const sc2Path = `${data}/3d/Tanks/CollisionMeshes/${fileName}.sc2.dvpl`;
const scgPath = `${data}/3d/Tanks/CollisionMeshes/${fileName}.scg.dvpl`;
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) {
hierarchies.forEach((hierarchy) => {
const components = times(
hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')],
);
components.forEach((component) => {
switch (component['comp.typename']) {
case 'TransformComponent':
break;
case 'RenderComponent': {
const batch = component['rc.renderObj']['ro.batches']['0000'];
const polygonGroup = scg.get(batch['rb.datasource']);
if (!polygonGroup) {
console.warn(
`Missing polygon group ${batch['rb.datasource']} (${hierarchy.name}); skipping...`,
);
break;
}
const hardJointIndices = new Set<number>();
const vertexHardJointIndices = new Map<number, number>();
const attributes = new Map<VertexAttribute, number[][]>();
polygonGroup.vertices.forEach((vertex, index) => {
vertex.forEach(({ attribute, value }) => {
if (!attributes.has(attribute)) {
attributes.set(attribute, []);
}
attributes.get(attribute)!.push(value);
if (attribute === VertexAttribute.HARD_JOINTINDEX) {
hardJointIndices.add(value[0]);
vertexHardJointIndices.set(index, value[0]);
}
});
});
if (attributes.has(VertexAttribute.TANGENT)) {
attributes.set(
VertexAttribute.TANGENT,
attributes
.get(VertexAttribute.TANGENT)!
.map((tangent) => [...tangent, 1]),
);
}
const accessors = new Map<string, Accessor>();
attributes.forEach((value, attribute) => {
const name = vertexAttributeGLTFName[attribute];
if (!name || accessors.has(name)) return;
const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const accessor = document
.createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat()))
.setBuffer(buffer);
accessors.set(name, accessor);
});
hardJointIndices.forEach((hardJointIndex) => {
const node = document.createNode(
`${hierarchy.name}_armor_${hardJointIndex}`,
);
const mesh = document.createMesh(batch['##name']);
const indicesAccessor = document
.createAccessor()
.setType('SCALAR')
.setArray(
new Uint16Array(
polygonGroup.indices.filter(
(index) =>
vertexHardJointIndices.get(index) === hardJointIndex,
),
),
)
.setBuffer(buffer);
const primitive = document
.createPrimitive()
.setIndices(indicesAccessor);
accessors.forEach((accessor, name) => {
primitive.setAttribute(name, accessor);
});
mesh.addPrimitive(primitive);
node.setMesh(mesh);
parent.addChild(node);
});
break;
}
default:
throw new TypeError(
`Unhandled component type: ${component['comp.typename']}`,
);
}
});
if (hierarchy['#hierarchy']) {
parseHierarchies(hierarchy['#hierarchy'], parent);
}
});
}
parseHierarchies(sc2['#hierarchy'], scene);
return document;
}
@@ -0,0 +1,19 @@
import { vertexAttributeVectorSizes } from '../../streams/scg';
import { VertexAttribute } from '../../streams/scpg';
export const vertexAttributeGLTFName: Partial<Record<VertexAttribute, string>> =
{
[VertexAttribute.VERTEX]: 'POSITION',
[VertexAttribute.NORMAL]: 'NORMAL',
[VertexAttribute.TEXCOORD0]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD1]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD2]: 'TEXCOORD_0',
[VertexAttribute.TEXCOORD3]: 'TEXCOORD_0',
[VertexAttribute.TANGENT]: 'TANGENT',
[VertexAttribute.JOINTINDEX]: 'JOINT_0',
[VertexAttribute.JOINTWEIGHT]: 'WEIGHT_0',
};
export const vertexAttributeGltfVectorSizes = {
...vertexAttributeVectorSizes,
[VertexAttribute.TANGENT]: 4,
} as const;
@@ -0,0 +1,288 @@
import { Document, Material, Node, Scene } from '@gltf-transform/core';
import { times } from 'lodash';
import { dirname } from 'path';
import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three';
import { readTexture } from '../../blitzkit/readTexture';
import { TextureMutation } from '../../blitzkit/readTexture/constants';
import { Hierarchy, Sc2ReadStream, Textures } from '../../streams/sc2';
import { ScgReadStream } from '../../streams/scg';
import { VertexAttribute } from '../../streams/scpg';
import { readDVPLFile } from '../readDVPLFile';
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from './constants';
const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = {
start: ['chassis_chassis_', 'chassis_track_crash_', 'HP_'],
end: ['_POINT'],
};
export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2.dvpl`;
const scgPath = `${data}/3d/${path}.scg.dvpl`;
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
const materials = new Map<bigint, Material | bigint | undefined>();
// create materials
await Promise.all(
sc2['#dataNodes'].map(async (node) => {
const id = new DataView(node['#id']).getBigUint64(0, true);
if (node.parentMaterialKey !== undefined) {
/**
* material depends on a parent and doesn't seem to have any
* properties so we just point it to the parent
*/
materials.set(id, node.parentMaterialKey);
return;
}
const material = document.createMaterial(node.materialName);
let textures: Textures | undefined = undefined;
if (node.textures) {
textures = node.textures;
} else if (typeof node.configCount === 'number') {
textures = node.configArchive_0.textures;
}
if (textures) {
material.setBaseColorTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.albedo}`,
TextureMutation.Albedo,
),
),
);
if (textures.baseRMMap) {
material.setMetallicRoughnessTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.baseRMMap}`,
TextureMutation.RoughnessMetallicness,
),
),
);
}
if (textures.baseNormalMap ?? textures.normalmap) {
const isBase = textures.baseNormalMap !== undefined;
material.setNormalTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${
textures.baseNormalMap ?? textures.normalmap
}`,
isBase ? TextureMutation.Normal : undefined,
),
),
);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
.createTexture(node.materialName)
.setMimeType('image/jpeg')
.setImage(
await readTexture(
`${data}/3d/${dirname(path)}/${textures.miscMap}`,
TextureMutation.Miscellaneous,
),
),
);
}
materials.set(
new DataView(node['#id']).getBigUint64(0, true),
material,
);
}
}),
);
// replace children materials with parents
materials.forEach((material, id) => {
if (typeof material !== 'bigint') return;
let resolvedMaterial: Material | bigint | undefined = material;
while (typeof resolvedMaterial === 'bigint') {
const linkedParentMaterial = materials.get(resolvedMaterial);
resolvedMaterial = linkedParentMaterial;
}
materials.set(id, resolvedMaterial);
});
function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) {
hierarchies.forEach((hierarchy) => {
if (
omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) ||
omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit))
)
return;
const node = document.createNode(hierarchy.name);
const components = times(
hierarchy.components.count,
(index) => hierarchy.components[index.toString().padStart(4, '0')],
);
components.forEach((component) => {
switch (component['comp.typename']) {
case 'LodComponent':
// found and used later by transform component
// TODO: lod component always shows up before transform component; cache it before parsing transform
break;
case 'TransformComponent': {
const translation = new Vector3();
const rotation = new Quaternion();
const scale = new Vector3();
new Matrix4()
.multiplyMatrices(
new Matrix4().compose(
new Vector3().fromArray(component['tc.worldTranslation']),
new Quaternion().fromArray(component['tc.worldRotation']),
new Vector3().fromArray(component['tc.worldScale']),
),
new Matrix4().compose(
new Vector3().fromArray(component['tc.localTranslation']),
new Quaternion().fromArray(component['tc.localRotation']),
new Vector3().fromArray(component['tc.localScale']),
),
)
.decompose(translation, rotation, scale);
node.setTranslation(translation.toArray());
node.setRotation(rotation.toArray() as Vector4Tuple);
node.setScale(scale.toArray());
break;
}
case 'RenderComponent': {
const renderObject = component['rc.renderObj'];
times(renderObject['ro.batchCount'], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return;
const batchKey = batchIndex.toString().padStart(4, '0');
const batch = renderObject['ro.batches'][batchKey];
const material = materials.get(batch['rb.nmatname']);
const polygonGroup = scg.get(batch['rb.datasource']);
if (!(material instanceof Material)) {
// probably shadow material
return;
}
if (polygonGroup === undefined) {
throw new Error(
`Missing polygon group ${batch['rb.datasource']}`,
);
}
const lodNode = document.createNode(batchKey);
const indicesAccessor = document
.createAccessor()
.setType('SCALAR')
.setArray(new Uint16Array(polygonGroup.indices))
.setBuffer(buffer);
const primitive = document
.createPrimitive()
.setIndices(indicesAccessor)
.setMaterial(material);
const attributes = new Map<VertexAttribute, number[][]>();
polygonGroup.vertices.forEach((vertex) => {
vertex.forEach(({ attribute, value }) => {
if (!attributes.has(attribute)) {
attributes.set(attribute, []);
}
attributes.get(attribute)!.push(value);
});
});
if (attributes.has(VertexAttribute.TANGENT)) {
attributes.set(
VertexAttribute.TANGENT,
attributes
.get(VertexAttribute.TANGENT)!
.map((tangent) => [...tangent, 1]),
);
}
attributes.forEach((value, attribute) => {
const name = vertexAttributeGLTFName[attribute];
if (!name || primitive.getAttribute(name)) return;
const vertexSize = vertexAttributeGltfVectorSizes[attribute];
const attributeAccessor = document
.createAccessor(name)
.setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`)
.setArray(new Float32Array(value.flat()))
.setBuffer(buffer);
primitive.setAttribute(name, attributeAccessor);
});
const mesh = document
.createMesh(batch['##name'])
.addPrimitive(primitive);
lodNode.setMesh(mesh);
node.addChild(lodNode);
});
break;
}
default: {
if (ERROR_ON_UNKNOWN_COMPONENT) {
throw new Error(
`Unhandled component type: ${component['comp.typename']}`,
);
}
}
}
});
if (hierarchy['#hierarchy']) {
parseHierarchies(hierarchy['#hierarchy'], node);
}
parent.addChild(node);
});
}
parseHierarchies(sc2['#hierarchy'], scene);
return document;
}
@@ -0,0 +1,88 @@
import { assertSecret } from '@blitzkit/core';
import { EventManager } from '@blitzkit/core/src/blitzkit/eventManager';
import { Region } from '../../constants/regions';
import { patientFetch } from '../blitzkit/patientFetch';
const RETRY_ERRORS = ['REQUEST_LIMIT_EXCEEDED', 'SOURCE_NOT_AVAILABLE'];
export const retryAbleBlitzFetchEvent = new EventManager();
export const blitzFetchQueueAvailableEvent = new EventManager();
type BlitzResponse<Data extends object> =
| {
status: 'error';
error: { field: string; message: string; code: 402; value: null };
}
| {
status: 'ok';
data: Data;
};
const MAX_CALLS_PER_SECOND = 10;
const queue: { url: string; resolve: (data: object) => void }[] = [];
let inProgress = 0;
function decrement() {
inProgress--;
blitzFetchQueueAvailableEvent.emit(undefined);
manageQueue();
}
async function manageQueue() {
if (queue.length > 0 && inProgress < MAX_CALLS_PER_SECOND) {
inProgress++;
const timeout = setTimeout(decrement, 1000);
const request = queue.shift()!;
const data = (await patientFetch(request.url)
.then((response) => response.json())
.catch((error) => ({ status: 'error', error }))) as BlitzResponse<
typeof request.resolve
>;
if (data.status === 'ok') {
request.resolve(data.data);
} else {
if (RETRY_ERRORS.includes(data.error.message)) {
retryAbleBlitzFetchEvent.emit(undefined);
clearTimeout(timeout);
setTimeout(() => {
queue.push(request);
decrement();
}, 1000 / MAX_CALLS_PER_SECOND);
} else {
clearTimeout(timeout);
decrement();
throw new Error(`Wargaming response error status: "${data.status}"`, {
cause: `Message: "${data.error.message}"\nURL: "${request.url}"`,
});
}
}
}
}
export type FetchBlitzParams = Record<string, string | number | undefined>;
export default function fetchBlitz<Data extends object>(
region: Region,
path: string,
params: FetchBlitzParams = {},
) {
return new Promise<Data>((resolve) => {
queue.push({
url: `https://api.wotblitz.${region}/wotb/${path}/?${Object.entries({
application_id: assertSecret(process.env.WARGAMING_APPLICATION_ID),
...params,
})
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value!)}`)
.join('&')}`,
resolve(data) {
resolve(data as Data);
},
});
manageQueue();
});
}
@@ -0,0 +1,58 @@
import calculateWN8 from '../statistics/calculateWN8';
import { BlitzStats } from '../statistics/compositeStats/constants';
export type Stat = keyof ReturnType<typeof generateStats>;
export const STAT_NAMES: Record<Stat, string> = {
battles: 'Battles',
winrate: 'Winrate',
wn8: 'WN8',
averageDamage: 'Damage',
damage: 'Accumulated damage',
wins: 'Wins',
};
export const STAT_KEYS = Object.keys(STAT_NAMES) as Stat[];
export function generateStats(s: BlitzStats, e?: BlitzStats) {
return {
battles: s.battles,
wins: s.wins,
winrate: s.wins / s.battles,
wn8: e ? calculateWN8(e, s) : null,
damage: s.damage_dealt,
averageDamage: s.damage_dealt / s.battles,
};
}
export function prettifyStats(s: ReturnType<typeof generateStats>) {
return {
battles: `${s.battles}`,
wins: `${s.wins}`,
winrate: `${(s.winrate * 100).toFixed(0)}%`,
wn8: s.wn8 ? s.wn8.toFixed(0) : '--',
damage: `${s.damage}`,
averageDamage: s.averageDamage.toFixed(0),
} satisfies Record<Stat, string>;
}
export function sumStats(s: ReturnType<typeof generateStats>[]) {
function sum(slice: (stat: ReturnType<typeof generateStats>) => number) {
return s.reduce((a, b) => a + slice(b), 0);
}
const battles = sum((s) => s.battles);
const wins = sum((s) => s.wins);
const damage = sum((s) => s.damage);
return {
battles,
wins,
winrate: wins / battles,
damage,
averageDamage: damage / battles,
wn8:
sum((s) => (s.wn8 ? s.wn8 * s.battles : 0)) /
sum((s) => (s.wn8 ? s.battles : 0)),
} satisfies ReturnType<typeof generateStats>;
}
@@ -0,0 +1,35 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
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>(
region,
'account/achievements',
{ 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,96 @@
import { Region } from '../../constants/regions';
import { BlitzStats } from '../statistics/compositeStats/constants';
import fetchBlitz, { FetchBlitzParams } from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
export interface SupplementaryStats {
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;
}
export interface RatingStats {
spotted: number;
calibration_battles_left: number;
hits: number;
frags: number;
recalibration_start_time: number;
mm_rating: number;
wins: number;
losses: number;
is_recalibration: boolean;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
export interface IndividualAccountInfo {
account_id: number;
created_at: number;
updated_at: number;
private: null;
last_battle_time: number;
nickname: string;
statistics: {
all: BlitzStats;
clan: ClanStats;
rating?: RatingStats;
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,
extra: string[] = [],
params: FetchBlitzParams = {},
) {
const object = await fetchBlitz<AccountInfo>(region, 'account/info', {
account_id: normalizeIds(ids),
extra: extra.length === 0 ? undefined : extra.join(','),
...params,
});
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
@@ -0,0 +1,46 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
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;
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>(
region,
'clans/accountinfo',
{
account_id: normalizeIds(ids),
extra: extra.length === 0 ? undefined : extra.join(','),
},
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
@@ -0,0 +1,51 @@
import { Region } from '../../constants/regions';
import fetchBlitz from './fetchBlitz';
import { normalizeIds } from './normalizeIds';
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;
}
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>(region, 'clans/info', {
clan_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,5 @@
import { LEAGUES } from '../../constants/leagues';
export function getLeagueFromScore(score: number) {
return LEAGUES.find(({ minScore }) => score >= minScore)!;
}
@@ -0,0 +1,25 @@
import { RatingInfo } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import { patientFetch } from '../blitzkit/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain';
const cache: Partial<Record<Region, RatingInfo>> = {};
export default async function getRatingInfo(region: Region) {
if (cache[region]) return cache[region]!;
const response = await patientFetch(
context === 'website'
? `/api/${region}/rating/current/info`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/season/`,
undefined,
);
const data = (await response.json()) as RatingInfo;
cache[region] = data;
return data;
}
@@ -0,0 +1,28 @@
import { LeagueTop, RatingPlayer } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import { patientFetch } from '../blitzkit/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingLeague(
region: Region,
league: number,
usePatientFetcher = false,
) {
const fetcher = (url: string) =>
usePatientFetcher
? patientFetch(url, undefined, { cache: 'no-store' })
: fetch(url, { cache: 'no-store' });
const response = await fetcher(
context === 'website'
? `/api/${region}/rating/current/league/${league}`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/league/${league}/top/`,
);
const json = await response.json();
return context === 'website'
? (json as RatingPlayer[])
: (json as LeagueTop).result;
}
@@ -0,0 +1,27 @@
import { RatingNeighbors } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { patientFetch } from '../blitzkit/patientFetch';
import { withCORSProxy } from '../blitzkit/withCORSProxy';
import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingNeighbors(
region: Region,
id: number,
count: number,
usePatientFetcher = false,
) {
const fetcher = (url: string) =>
usePatientFetcher
? patientFetch(url, undefined, { cache: 'no-store' })
: fetch(url, { cache: 'no-store' });
const response = await fetcher(
withCORSProxy(
`https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${count}`,
),
);
const json = (await response.json()) as RatingNeighbors;
return json;
}
@@ -0,0 +1,16 @@
import { Region } from '../../constants/regions';
import { TanksStats } from '../../types/tanksStats';
import fetchBlitz, { FetchBlitzParams } from './fetchBlitz';
export default async function getTankStats(
region: Region,
id: number,
params?: FetchBlitzParams,
) {
const tankStats = await fetchBlitz<TanksStats>(region, 'tanks/stats', {
account_id: id,
...params,
});
return tankStats[id];
}
@@ -0,0 +1,6 @@
import getRatingInfo from './getRatingInfo';
export async function isOnGoingRatingSeason() {
const ratingInfo = await getRatingInfo('com');
return ratingInfo.detail === undefined;
}
@@ -0,0 +1,32 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { idToRegion } from './idToRegion';
export async function isValidBlitzId(id: number, token?: string) {
let region: Region;
try {
region = idToRegion(id);
} catch (error) {
return false;
}
const json = await fetch(
`https://api.wotblitz.${region}/wotb/account/info/?account_id=${
id
}&application_id=${WARGAMING_APPLICATION_ID}${
token === undefined ? '' : `&access_token=${token}`
}&fields=account_id%2C-account_id`,
).then(
(response) =>
response.json() as Promise<
| {
status: 'ok';
data: Record<number, {} | null>;
}
| { status: 'error' }
>,
);
return json.status === 'ok' && json.data[id] !== null;
}
@@ -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(',');
}
@@ -0,0 +1,3 @@
export function normalizeLeagueIcon(url: string) {
return url.startsWith('http') ? url : `https:${url}`;
}
@@ -0,0 +1,6 @@
import { readDVPLFile } from './readDVPLFile';
export async function readBase64DVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString('base64');
}
@@ -0,0 +1,60 @@
import { crc32 } from 'crc';
import { decompressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modified heavily to be modern.
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function readDVPL(buffer: Buffer) {
const footerBuffer = buffer.subarray(buffer.length - 20, buffer.length);
if (
footerBuffer.toString('utf8', 16, 20) !== 'DVPL' ||
footerBuffer.length !== 20
) {
throw new SyntaxError('Invalid DVPL footer');
}
const footerData = {
oSize: footerBuffer.readUInt32LE(0),
cSize: footerBuffer.readUInt32LE(4),
crc32: footerBuffer.readUInt32LE(8),
type: footerBuffer.readUInt32LE(12),
};
const targetBlock = buffer.subarray(0, buffer.length - 20);
if (targetBlock.length !== footerData.cSize) {
throw new RangeError('DVPL size mismatch');
}
if (crc32(targetBlock) !== footerData.crc32) {
throw new TypeError('DVPL CRC32 mismatch');
}
if (footerData.type === 0) {
if (!(footerData.oSize === footerData.cSize && footerData.type === 0)) {
throw new RangeError('DVPL type and size mismatch');
}
return targetBlock;
} else if (footerData.type === 1 || footerData.type === 2) {
const source = new Uint8Array(targetBlock);
const destination = new Uint8Array(footerData.oSize);
const decompressedSize = decompressBlock(
source,
destination,
0,
source.length,
0,
);
if (decompressedSize !== footerData.oSize) {
throw new RangeError(
`Decompressed DVPL size mismatch (${decompressedSize} vs ${footerData.oSize})`,
);
}
return Buffer.from(destination);
} else {
throw new SyntaxError('Unknown DVPL format');
}
}
@@ -0,0 +1,6 @@
import { readFile } from 'fs/promises';
import { readDVPL } from './readDVPL';
export async function readDVPLFile(file: string) {
return readDVPL(await readFile(file));
}
@@ -0,0 +1,6 @@
import { readDVPLFile } from './readDVPLFile';
export async function readStringDVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString();
}
@@ -0,0 +1,9 @@
import { XMLParser } from 'fast-xml-parser';
import { readStringDVPL } from './readStringDVPL';
export async function readXMLDVPL<Type>(file: string) {
const parser = new XMLParser();
const xml = await readStringDVPL(file);
return parser.parse(xml) as Type;
}
@@ -0,0 +1,7 @@
import { readFile } from 'fs/promises';
import { parse } from 'yaml';
export async function readYAML<Type>(file: string) {
const yaml = (await readFile(file)).toString();
return parse(yaml) as Type;
}
@@ -0,0 +1,8 @@
import { parse } from 'yaml';
import { readStringDVPL } from './readStringDVPL';
export async function readYAMLDVPL<Type>(file: string) {
const yaml = await readStringDVPL(file);
return parse(yaml) as Type;
}
@@ -0,0 +1,7 @@
import { Region, RegionSubdomain } from '../../constants/regions';
export default function regionSubdomainToRegion(
regionDomain: RegionSubdomain,
): Region {
return regionDomain === 'na' ? 'com' : regionDomain;
}
@@ -0,0 +1,7 @@
import { Region, RegionSubdomain } from '../../constants/regions';
export default function regionToRegionSubdomain(
regionDomain: Region,
): RegionSubdomain {
return regionDomain === 'com' ? 'na' : regionDomain;
}
@@ -0,0 +1,46 @@
import { Locale } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { UserError } from '../../hooks/userError';
import { tankDefinitions } from '../blitzkit/tankDefinitions';
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
import { translator } from '../localization/translator';
export default async function resolveTankId(
tank: string | number,
locale: Locale,
techTreeOnly = false,
) {
const { translate } = translator(locale);
const number = typeof tank === 'string' ? Number(tank) : tank;
if (Number.isNaN(number)) {
const searchResult = go(
`${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 1,
},
);
if (searchResult.length === 0) {
throw new UserError(
translate('bot.common.errors.tank_name_not_found', [
markdownEscape(`${tank}`),
]),
);
} else {
return searchResult[0].obj.id;
}
} else {
if ((await tankDefinitions)[number]) {
return number;
} else {
throw new UserError(
translate('bot.common.errors.tank_id_not_found', [`${number}`]),
);
}
}
}
@@ -0,0 +1,6 @@
import { I_HAT } from '@blitzkit/core/src/math/axis';
import { Vector3 } from 'three';
export function rotateDavaVector(vector: Vector3) {
return vector.applyAxisAngle(I_HAT, Math.PI / 2);
}
@@ -0,0 +1,54 @@
import { Region } from '../../constants/regions';
import { ClanList } from '../../types/clanList';
import fetchBlitz from './fetchBlitz';
export default async function searchClansAcrossRegions(
search: string,
limit = 25,
) {
const trimmed = search.trim();
if (trimmed.length < 3 && trimmed.length > 100) return [];
const normalizedLimit = Math.floor(limit / 3);
// TODO: remove splice once wargaming fixes the functionality of the limit felid
return (
await Promise.all([
fetchBlitz<ClanList>('com', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'com' as Region,
}))
.splice(0, normalizedLimit),
),
fetchBlitz<ClanList>('eu', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'eu' as Region,
}))
.splice(0, normalizedLimit),
),
fetchBlitz<ClanList>('asia', 'clans/list', {
search,
limit: normalizedLimit,
}).then((value) =>
value
.map((account) => ({
...account,
region: 'asia' as Region,
}))
.splice(0, normalizedLimit),
),
])
)
.filter(Boolean)
.flat();
}
@@ -0,0 +1,40 @@
import { Region } from '../../constants/regions';
import { context } from '../blitzkit/context';
import regionToRegionSubdomain from './regionToRegionSubdomain';
type CurrentRatingPlayerSearch = Record<
number,
{
spa_id: number;
nickname: string;
clan_tag: string;
} & (
| {
skip: true;
}
| {
skip: false;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
updated_at: string;
neighbors: [];
}
)
>;
export async function searchCurrentRatingPlayers(region: Region, name: string) {
const encodedSearch = encodeURIComponent(name);
const response = await fetch(
context === 'website'
? `/api/${region}/rating/current/search/${encodedSearch}`
: `https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/search/?prefix=${encodedSearch}`,
{ cache: 'no-store' },
);
const accountList = (await response.json()) as CurrentRatingPlayerSearch;
return accountList;
}
@@ -0,0 +1,2 @@
export const usernamePattern = /^[a-zA-Z0-9_]{3,24}$/;
export const usernamePatternWithoutPosition = /[a-zA-Z0-9_]{3,24}/;
@@ -0,0 +1,64 @@
import { Region } from '../../../constants/regions';
import fetchBlitz from '../fetchBlitz';
import { usernamePattern } from './constants';
export interface AccountListItem {
nickname: string;
account_id: number;
}
export type AccountList = AccountListItem[];
export type AccountListWithServer = (AccountListItem & {
region: Region;
})[];
export default async function searchPlayersAcrossRegions(
search: string,
limit = 25,
) {
const trimmedSearch = search.trim();
const normalizedLimit = Math.floor(limit / 3);
if (usernamePattern.test(trimmedSearch)) {
return (
await Promise.all([
fetchBlitz<AccountList>('com', 'account/list', {
search,
limit: normalizedLimit,
}).then(
(value) =>
value &&
value.map((account) => ({
...account,
region: 'com' as Region,
})),
),
fetchBlitz<AccountList>('eu', 'account/list', {
search,
limit: normalizedLimit,
}).then(
(value) =>
value &&
value.map((account) => ({
...account,
region: 'eu' as Region,
})),
),
fetchBlitz<AccountList>('asia', 'account/list', {
search,
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,5 @@
import { NATION_IDS } from '../../constants/nations';
export function toUniqueId(nation: string, id: number) {
return (id << 8) + (NATION_IDS[nation] << 4) + 1;
}
@@ -0,0 +1,6 @@
import { I_HAT } from '@blitzkit/core/src/math/axis';
import { Vector3 } from 'three';
export function unrotateDavaVector(vector: Vector3) {
return vector.applyAxisAngle(I_HAT, -Math.PI / 2);
}
@@ -0,0 +1,64 @@
import { crc32 } from 'crc';
import { compressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modernized heavily.
*
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function writeDVPL(buffer: Buffer) {
const source = new Uint8Array(buffer);
const destination = new Uint8Array(buffer.length);
const compressedBlockSize = compressBlock(
source,
destination,
0,
source.length,
0,
);
let output = Buffer.from(destination);
if (compressedBlockSize === 0 || compressedBlockSize >= buffer.length) {
const footerBuffer = toDVPLFooter(
buffer.length,
buffer.length,
crc32(buffer),
0,
);
return Buffer.concat(
[new Uint8Array(buffer), new Uint8Array(footerBuffer)],
buffer.length + 20,
);
} else {
output = output.subarray(0, compressedBlockSize);
const footerBuffer = toDVPLFooter(
buffer.length,
compressedBlockSize,
crc32(output),
2,
);
return Buffer.concat(
[new Uint8Array(output), new Uint8Array(footerBuffer)],
compressedBlockSize + 20,
);
}
}
function toDVPLFooter(
inputSize: number,
compressedSize: number,
crc32: number,
type: number,
) {
const result = Buffer.alloc(20);
result.writeUInt32LE(inputSize, 0);
result.writeUInt32LE(compressedSize, 4);
result.writeUInt32LE(crc32, 8);
result.writeUInt32LE(type, 12);
result.write('DVPL', 16, 4, 'utf8');
return result;
}
@@ -0,0 +1,20 @@
import { resolveAncestry } from './resolveAncestry';
import { tankDefinitions } from './tankDefinitions';
export async function buildTechTreeLine(start: number, end: number) {
const awaitedTankDefinitions = await tankDefinitions;
const endTank = awaitedTankDefinitions[end];
if (!endTank.ancestors) throw new Error('End tank has no ancestors');
const line = [end];
if (start === end) return line;
for (const ancestorId of endTank.ancestors) {
if ((await resolveAncestry(ancestorId)).includes(start)) {
const ancestorTree = await buildTechTreeLine(start, ancestorId);
line.push(...ancestorTree);
break;
}
}
return line;
}
@@ -0,0 +1,17 @@
import { assertSecret } from '../../../packages/core/src/blitzkit/assertSecret';
import { ASSETS_REPO } from '../../constants/assets';
import commitMultipleFiles, { FileChange } from './commitMultipleFiles';
export async function commitAssets(message: string, changes: FileChange[]) {
console.log(`Committing ${message}...`);
if (changes.length === 0) return;
await commitMultipleFiles(
'tresabhi',
ASSETS_REPO,
assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
`${message} - ${new Date().toDateString()}`,
changes,
);
}
@@ -0,0 +1,63 @@
import ProgressBar from 'progress';
import { GithubChangeBlob, createBlob } from './createBlob';
import { octokit } from './octokit';
export interface FileChange {
path: string;
content: string;
encoding: 'utf-8' | 'base64';
}
export default async function commitMultipleFiles(
owner: string,
repo: string,
branch: string,
message: string,
changes: FileChange[],
) {
const latestCommitSha = (
await octokit.git.getRef({
owner,
repo,
ref: `heads/${branch}`,
})
).data.object.sha;
const treeSha = (
await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
).data.tree.sha;
const blobs: GithubChangeBlob[] = [];
const bar = new ProgressBar('Blobs :bar', changes.length);
for (const changeIndexString in changes) {
const changeIndex = parseInt(changeIndexString);
const change = changes[changeIndex];
const createdBlob = await createBlob(owner, repo, change);
blobs.push(createdBlob!);
bar.tick();
}
const { data: treeData } = await octokit.git.createTree({
owner,
repo,
base_tree: treeSha,
tree: blobs,
});
const { data: newCommitData } = await octokit.git.createCommit({
owner,
repo,
message,
tree: treeData.sha,
parents: [latestCommitSha],
});
await octokit.git.updateRef({
owner,
repo,
ref: `heads/${branch}`,
sha: newCommitData.sha,
force: true,
});
}
@@ -0,0 +1,4 @@
type Context = 'website' | 'server';
export const context: Context =
typeof window === 'undefined' ? 'server' : 'website';
@@ -0,0 +1,46 @@
import { throttle } from 'lodash';
import { FileChange } from './commitMultipleFiles';
import { octokit } from './octokit';
export interface GithubChangeBlob {
sha: string;
path: string;
mode: '100644';
type: 'blob';
}
const TIME_PER_BLOB = 2 ** 4 * 1000;
// github rate limit: 5000 requests per hour
export const createBlob = throttle(
async (owner: string, repo: string, change: FileChange) => {
while (true) {
try {
const { sha } = (
await octokit.git.createBlob({
owner,
repo,
content: change.content,
encoding: change.encoding,
})
).data;
return {
sha,
path: change.path,
mode: '100644',
type: 'blob',
} satisfies GithubChangeBlob;
} catch (error) {
console.warn(
`Failed blob ${change.path}; retrying in ${TIME_PER_BLOB}ms...`,
// error,
);
await new Promise((resolve) => setTimeout(resolve, TIME_PER_BLOB));
}
}
},
(5000 / (60 * 60 * 1000)) * 0.9,
{ trailing: true },
);
@@ -0,0 +1,3 @@
export function degressiveStat(crew: number) {
return 0.875 / (0.00375 * 100 * crew + 0.5) - 1;
}
@@ -0,0 +1,11 @@
import { asset } from '@blitzkit/core/src/blitzkit/asset';
export interface DiscoveredIdsDefinitions {
time: number;
chunks: number;
count: number;
}
export const discoveredIdsDefinitions = fetch(asset('ids/manifest.json')).then(
(response) => response.json() as Promise<DiscoveredIdsDefinitions>,
);
@@ -0,0 +1,3 @@
export function emblemURL(emblemId: number) {
return `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${emblemId}.png`;
}
@@ -0,0 +1,35 @@
import { times } from 'lodash';
import { decompress } from 'lz4js';
import ProgressBar from 'progress';
import { DidsReadStream } from '../streams/dids';
import { asset } from './asset';
import { DiscoveredIdsDefinitions } from './discoveredIdDefinitions';
export async function fetchPreDiscoveredIds() {
const idChunks: number[][] = [];
const manifest = (await fetch(asset('ids/manifest.json')).then((response) =>
response.json(),
)) as DiscoveredIdsDefinitions;
const bar = new ProgressBar(
`Fetching ${manifest.count.toLocaleString()} ids in ${manifest.chunks} chunks :bar`,
manifest.chunks,
);
bar.render();
await Promise.all(
times(manifest.chunks, async (chunkIndex) => {
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();
});
idChunks.push(preDiscovered);
bar.tick();
}),
);
return idChunks.sort((a, b) => a[0] - b[0]).flat();
}
@@ -0,0 +1,29 @@
import { Locale } from 'discord.js';
import { translator } from '../localization/translator';
import { StatFilters } from '../statistics/filterStats';
import { Tier, tankDefinitions } from './tankDefinitions';
import { TIER_ROMAN_NUMERALS } from './tankDefinitions/constants';
export async function filtersToDescription(
{ nation, tier, tankType, treeType, tank }: StatFilters,
locale: Locale,
) {
const { translate } = translator(locale);
const awaitedTankDefinitions = await tankDefinitions;
const info: string[] = [];
// TODO: somehow localize tank names?
if (tank) return awaitedTankDefinitions[tank].name;
if (nation) info.push(translate(`common.nations.${nation}`));
if (tier) {
info.push(
translate('bot.common.filters.tier', [TIER_ROMAN_NUMERALS[tier as Tier]]),
);
}
if (tankType) info.push(translate(`common.tank_class_short.${tankType}`));
if (treeType) info.push(translate(`common.tree_type.${treeType}`));
return info.length === 0
? translate('bot.common.filters.none')
: info.join(', ');
}
@@ -0,0 +1,9 @@
import { usersDatabase } from '../../databases/users';
export async function flagUserActivity(blitzId: number) {
await usersDatabase.user.upsert({
where: { blitz_id: blitzId },
update: {},
create: { blitz_id: blitzId },
});
}
@@ -0,0 +1,47 @@
import { ASSETS_REPO } from '../../constants/assets';
import getRatingInfo from '../blitz/getRatingInfo';
interface GitHubTrees {
tree: { path: string; url: string }[];
}
let cachedLatestArchivedSeasonNumber: number | null = null;
export async function getArchivedLatestSeasonNumber() {
if (cachedLatestArchivedSeasonNumber !== null) {
return cachedLatestArchivedSeasonNumber;
}
const ratingInfo = await getRatingInfo('com');
if (ratingInfo.detail) {
const regionsURL = (
(await fetch(
`https://api.github.com/repos/tresabhi/${ASSETS_REPO}/git/trees/main`,
).then((response) => response.json())) as GitHubTrees
).tree.find(({ path }) => path === 'regions')!.url;
const comURL = (
(await fetch(regionsURL).then((response) =>
response.json(),
)) as GitHubTrees
).tree.find(({ path }) => path === 'com')!.url;
const ratingURL = (
(await fetch(comURL).then((response) => response.json())) as {
tree: [{ url: string }];
}
).tree[0].url;
const latestSeason = Math.max(
...(
(await fetch(ratingURL).then((response) => response.json())) as {
tree: { path: string }[];
}
).tree.map(({ path }) => Number(path)),
);
cachedLatestArchivedSeasonNumber = latestSeason;
} else {
cachedLatestArchivedSeasonNumber = ratingInfo.current_season - 1;
}
return cachedLatestArchivedSeasonNumber;
}
@@ -0,0 +1,36 @@
import { superDecompress } from '@blitzkit/core/src/blitzkit/superDecompress';
import { RatingInfo } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions';
import { asset } from './asset';
const cache: Record<
Region,
Record<
number,
RatingInfo & {
detail: undefined;
}
>
> = {
com: {},
eu: {},
asia: {},
};
export default async function getArchivedRatingInfo(
region: Region,
season: number,
) {
if (!cache[region][season]) {
const info = await fetch(
asset(`regions/${region}/rating/${season}/info.cdon.lz4`),
)
.then((response) => response.arrayBuffer())
.then((buffer) =>
superDecompress<RatingInfo & { detail: undefined }>(buffer),
);
cache[region][season] = info;
}
return cache[region][season];
}
@@ -0,0 +1,25 @@
import { Region } from '../../constants/regions';
import { BkrlDiscriminatedEntries, BkrlReadStream } from '../streams/bkrl';
import { asset } from './asset';
const cache: Record<Region, Record<number, BkrlDiscriminatedEntries>> = {
asia: {},
eu: {},
com: {},
};
export async function getArchivedRatingLeaderboard(
region: Region,
season: number,
) {
if (!cache[region][season]) {
const info = await fetch(
asset(`regions/${region}/rating/${season}/latest.bkrl`),
)
.then((response) => response.arrayBuffer())
.then((buffer) => new BkrlReadStream(buffer).bkrl());
cache[region][season] = info;
}
return cache[region][season];
}
@@ -0,0 +1,29 @@
import { Region } from '../../constants/regions';
import { BkrlDiscriminatedEntries, BkrlReadStream } from '../streams/bkrl';
import { asset } from './asset';
const cache: Record<Region, Record<number, BkrlDiscriminatedEntries | null>> = {
com: {},
eu: {},
asia: {},
};
export default async function getArchivedRatingMidnightLeaderboard(
region: Region,
season: number,
) {
if (!cache[region][season]) {
const info = await fetch(
asset(`regions/${region}/rating/${season}/midnight.bkrl`),
)
.then((response) =>
response.status === 404 ? null : response.arrayBuffer(),
)
.then((buffer) =>
buffer === null ? null : new BkrlReadStream(buffer).bkrl(),
);
cache[region][season] = info;
}
return cache[region][season];
}
@@ -0,0 +1,14 @@
import { usersDatabase } from '../../databases/users';
import { idToRegion } from '../blitz/idToRegion';
import { flagUserActivity } from './flagUserActivity';
export async function getBlitzFromDiscord(discordId: bigint) {
const unique = await usersDatabase.discordUser.findUnique({
where: { discord_id: discordId },
});
if (unique === null) return null;
await flagUserActivity(unique.blitz_id);
return { id: unique.blitz_id, region: idToRegion(unique.blitz_id) };
}
@@ -0,0 +1,3 @@
export default function getPeriodNow() {
return Date.now() / 1000;
}
@@ -0,0 +1,9 @@
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));
}
@@ -0,0 +1,122 @@
import { emptyAllStats } from '@blitzkit/core/src/blitzstars/getStatsInPeriod/constants';
import { Region } from '../../constants/regions';
import {
GetHistoriesOptions,
Histories,
History,
getHistoriesDefaultOptions,
} from '../../types/histories';
import getTankStats from '../blitz/getTankStats';
import { translator } from '../localization/translator';
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;
}
const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
...getHistoriesDefaultOptions,
tankId: undefined,
};
export default async function getTankHistories(
server: Region,
id: number,
locale: Locale,
options?: Partial<GetTankHistoriesOptions>,
) {
const { t } = translator(locale);
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) {
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 ||
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;
}
@@ -0,0 +1,20 @@
import { Region } from '../../constants/regions';
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;
}
@@ -0,0 +1,19 @@
import { equipmentDefinitions } from '@blitzkit/core/src/blitzkit/equipmentDefinitions';
import { EquipmentMatrix } from '../../../packages/website/src/stores/duel';
export async function hasEquipment(
id: number,
equipmentPreset: string,
equipmentMatrix: EquipmentMatrix,
) {
const awaitedEquipmentDefinitions = await equipmentDefinitions;
const presetRows = awaitedEquipmentDefinitions.presets[equipmentPreset];
return presetRows.some((equipmentRow, rowIndex) => {
return equipmentRow.some((equipment, columnIndex) => {
const choice = equipmentMatrix[rowIndex][columnIndex];
if (choice === 0) return false;
const equipped = equipment[choice === -1 ? 0 : 1];
return equipped === id;
});
});
}
@@ -0,0 +1,16 @@
import sharp from 'sharp';
const cache: Record<string, Buffer> = {};
export async function iconPng(url: string) {
if (!url.endsWith('.webp')) return url;
if (!cache[url]) {
const imageResponse = await fetch(url);
const imageBufferWEBP = await imageResponse.arrayBuffer();
const imageBufferPNG = await sharp(imageBufferWEBP).png().toBuffer();
cache[url] = imageBufferPNG;
}
return `data:image/png;base64,${cache[url].toString('base64')}`;
}
@@ -0,0 +1,5 @@
export function isLocalhost() {
return (
typeof window !== 'undefined' && window.location.hostname === 'localhost'
);
}
@@ -0,0 +1,9 @@
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;
}
@@ -0,0 +1,12 @@
import jsxToSvg from './jsxToSvg';
import { RenderConfiguration } from './renderConfiguration';
import { svgToPngThreaded } from './svgToPngThreaded';
export default async function jsxToPngThreaded(
jsx: JSX.Element,
renderConfiguration: RenderConfiguration,
) {
const svg = await jsxToSvg(jsx, renderConfiguration);
const png = await svgToPngThreaded(svg);
return png;
}
@@ -0,0 +1,23 @@
import { readFile } from 'fs/promises';
import satori from 'satori';
import { RenderConfiguration } from '../renderConfiguration';
import robotoBlack from './Roboto-Black.ttf';
import robotoBold from './Roboto-Bold.ttf';
import roboto from './Roboto.ttf';
const fontsPromise = [
{ file: roboto, weight: 400 as const },
{ file: robotoBold, weight: 700 as const },
{ file: robotoBlack, weight: 900 as const },
].map(async ({ file, weight }) => {
const data = await readFile(`dist/bot/workers/${file}`);
return { name: 'Roboto', weight, data };
});
export default async function jsxToSvg(
jsx: JSX.Element,
renderConfiguration = new RenderConfiguration(),
) {
const fonts = await Promise.all(fontsPromise);
return await satori(jsx, { width: renderConfiguration.width, fonts });
}
@@ -0,0 +1,45 @@
import { GroupProps, MeshProps } from '@react-three/fiber';
import { Mesh, Object3D } from 'three';
export function jsxTree(
node: Object3D,
mergers?: {
mesh?: MeshProps | ((mesh: Mesh) => MeshProps);
group?: GroupProps | ((object3d: Object3D) => GroupProps);
},
key?: string,
) {
if (node instanceof Mesh) {
return (
<mesh
key={key}
geometry={node.geometry}
material={node.material}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(mergers?.mesh &&
(typeof mergers.mesh === 'function'
? mergers.mesh(node)
: mergers.mesh))}
/>
);
} else if (node instanceof Object3D) {
return (
<group
key={key}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(mergers?.group &&
(typeof mergers.group === 'function'
? mergers.group(node)
: mergers.group))}
>
{node.children.map((child) => jsxTree(child, mergers, child.uuid))}
</group>
);
}
return null;
}
@@ -0,0 +1,11 @@
import { usersDatabase } from '../../databases/users';
import { flagUserActivity } from './flagUserActivity';
export async function linkBlitzAndDiscord(discordId: bigint, blitzId: number) {
await usersDatabase.discordUser.upsert({
where: { discord_id: discordId },
update: { blitz_id: blitzId },
create: { discord_id: discordId, blitz_id: blitzId },
});
await flagUserActivity(blitzId);
}
@@ -0,0 +1,13 @@
import { asset } from '@blitzkit/core/src/blitzkit/asset';
import { fetchCdonLz4 } from '@blitzkit/core/src/blitzkit/fetchCdonLz4';
export interface MapDefinitions {
[id: number]: {
id: number;
name: string;
};
}
export const mapDefinitions = fetchCdonLz4<MapDefinitions>(
asset('definitions/maps.cdon.lz4'),
);
@@ -0,0 +1,10 @@
import { EventManager } from '@blitzkit/core/src/blitzkit/eventManager';
import { invalidate } from '@react-three/fiber';
export interface ModelTransformEventData {
yaw?: number;
pitch: number;
}
export const modelTransformEvent = new EventManager<ModelTransformEventData>();
modelTransformEvent.on(() => invalidate());
@@ -0,0 +1,6 @@
export function nameToArmorId(name: string) {
const match = name.match(/.+_armor_(-?\d+)/);
const group1 = match![1];
return parseInt(group1);
}
@@ -0,0 +1,8 @@
import { BoundingBox } from '@blitzkit/core/src/blitzkit/modelDefinitions';
import { Vector3Tuple } from 'three';
export function normalizeBoundingBox(boundingBox: BoundingBox) {
return boundingBox.max.map(
(max, index) => max - boundingBox.min[index],
) as Vector3Tuple;
}
@@ -0,0 +1,9 @@
import { throttling } from '@octokit/plugin-throttling';
import { Octokit } from '@octokit/rest';
import { assertSecret } from '../../../packages/core/src/blitzkit/assertSecret';
Octokit.plugin(throttling);
export const octokit = new Octokit({
auth: assertSecret(process.env.GH_TOKEN),
});
@@ -0,0 +1,23 @@
import getBkniPercentile, {
BKNI_COLORS,
BkniPercentile,
} from '../statistics/getBkniPercentile';
export function parseBkni(bkni: number) {
const bkniFraction = (bkni + 1) / 2;
const bkniMetric = Math.round(bkniFraction * 200);
const bkniPercentile = getBkniPercentile(bkniMetric);
const lastBkniPercentile = Math.max(0, bkniPercentile - 1) as BkniPercentile;
const bkniColor = BKNI_COLORS[bkniPercentile];
const lastBkniColor = BKNI_COLORS[lastBkniPercentile];
return {
bkni,
bkniFraction,
bkniMetric,
bkniPercentile,
lastBkniPercentile,
bkniColor,
lastBkniColor,
};
}
@@ -0,0 +1,21 @@
export async function patientFetch(
url: string,
timeout = 1000,
options?: Partial<{ cache: 'no-store' }>,
) {
let patientResponse: Response;
while (patientResponse! === undefined) {
try {
patientResponse = await fetch(url, options);
} catch (error) {
console.warn(
`Failed to fetch ${url}; retrying in ${timeout}ms...`,
error,
);
await new Promise((resolve) => setTimeout(resolve, timeout));
}
}
return patientResponse;
}
@@ -0,0 +1,23 @@
export async function patientFetchJSON<Type extends object>(
url: string,
timeout = 1000,
options?: Partial<{ cache: 'no-store' }>,
) {
let patientJSON: Type;
while (patientJSON! === undefined) {
try {
patientJSON = (await fetch(url, options).then((response) =>
response.json(),
)) as Type;
} catch (error) {
console.warn(
`Failed to fetch ${url} as JSON; retrying in ${timeout}ms...`,
error,
);
await new Promise((resolve) => setTimeout(resolve, timeout));
}
}
return patientJSON;
}
@@ -0,0 +1,29 @@
import { usersDatabase } from '../../databases/users';
import { client } from '../discord/client';
const EXIT_EVENTS = [
'beforeExit',
'exit',
'SIGINT',
'SIGTERM',
'SIGUSR1',
'SIGUSR2',
];
async function cleanup() {
console.log('Cleaning up...');
await Promise.all([usersDatabase.$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));
}
@@ -0,0 +1,3 @@
export function progressiveStat(crew: number) {
return (0.00375 * 100 * crew + 0.5) / 0.875 - 1;
}
@@ -0,0 +1,3 @@
export function pushTankopediaPath(id: number) {
window.history.pushState(null, '', `/tools/tankopedia/${id}`);
}
@@ -0,0 +1,6 @@
export enum TextureMutation {
Normal,
RoughnessMetallicness,
Albedo,
Miscellaneous,
}
@@ -0,0 +1,79 @@
import { existsSync } from 'fs';
import sharp from 'sharp';
import { readDVPLFile } from '../../blitz/readDVPLFile';
import { DdsReadStream } from '../../streams/dds';
import { PvrReadStream } from '../../streams/pvr';
import { TextureMutation } from './constants';
export async function readTexture(path: string, mutation?: TextureMutation) {
const ddsTexturePath = path.replace('.tex', '.dx11.dds.dvpl');
const isDds = existsSync(ddsTexturePath);
const resolvedTexturePath = isDds
? ddsTexturePath
: ddsTexturePath.replace('.dds', '.pvr');
const decompressedDvpl = await readDVPLFile(resolvedTexturePath);
const raw = isDds
? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds()
: new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr();
switch (mutation) {
case TextureMutation.Normal: {
const bytes = 4 * raw.width * raw.height;
for (let index = 0; index < bytes; index += 4) {
/**
* Red is always 255 and blue is always 0. Only alpha and green contain any
* sort of information.
*/
let x = raw.data[index + 3] * (2 / 255) - 1;
let y = raw.data[index + 1] * (2 / 255) - 1;
const underSqrt = 1 - x ** 2 - y ** 2;
let z = underSqrt > 0 ? Math.sqrt(underSqrt) : -Math.sqrt(-underSqrt);
raw.data[index] = Math.round((x + 1) * (255 / 2));
raw.data[index + 1] = Math.round((y + 1) * (255 / 2));
raw.data[index + 2] = Math.round((z + 1) * (255 / 2));
raw.data[index + 3] = 255;
}
return await sharp(raw.data, { raw }).jpeg().toBuffer();
}
case TextureMutation.RoughnessMetallicness: {
const newImage = sharp(Buffer.alloc(raw.width * raw.height), {
raw: { ...raw, channels: 1 },
});
const metallicness = sharp(raw.data, { raw })
.extractChannel('green')
.jpeg()
.toBuffer();
const roughness = sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
return await newImage
.joinChannel(await Promise.all([roughness, metallicness]))
.jpeg()
.toBuffer();
}
case TextureMutation.Miscellaneous: {
/**
* Alpha is ambient occlusion and green is emissive. But very few tanks
* use emissive so I am ignoring it for now.
*/
return await sharp(raw.data, { raw })
.extractChannel('alpha')
.jpeg()
.toBuffer();
}
case TextureMutation.Albedo: {
return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer();
}
default:
return await sharp(raw.data, { raw }).jpeg().toBuffer();
}
}
@@ -0,0 +1,11 @@
export interface RenderConfiguration {
width: number;
}
export class RenderConfiguration implements RenderConfiguration {
width = 480;
constructor(configuration?: Partial<RenderConfiguration>) {
Object.assign(this, configuration);
}
}
@@ -0,0 +1,25 @@
import { tankDefinitions } from './tankDefinitions';
export async function resolveAncestry(id: number) {
const awaitedTankDefinitions = await tankDefinitions;
const tank = awaitedTankDefinitions[id];
const ancestors: number[] = [];
if (tank.ancestors) {
for (const ancestor of tank.ancestors) {
if (!ancestors.includes(ancestor)) {
ancestors.push(ancestor);
for (const ancestorLayer2 of await resolveAncestry(ancestor)) {
if (!ancestors.includes(ancestorLayer2)) {
ancestors.push(ancestorLayer2);
}
}
}
}
}
return ancestors.sort(
(a, b) => awaitedTankDefinitions[b].tier - awaitedTankDefinitions[a].tier,
);
}
@@ -0,0 +1,6 @@
const pattern = /\d+$/;
export function resolveArmorIndex(name: string) {
const match = name.match(pattern)?.[0];
return match ? parseInt(match) : undefined;
}
@@ -0,0 +1,39 @@
import { GunDefinition, ShellDefinition } from './tankDefinitions';
export function resolveDpm(
gun: GunDefinition,
shell: ShellDefinition,
damageCoefficient = 1,
reloadCoefficient = 1,
intraClipCoefficient = 1,
) {
const alpha = shell.damage.armor * damageCoefficient;
let dps: number;
if (gun.type === 'regular') {
dps = alpha / (reloadCoefficient * gun.reload);
} else if (gun.type === 'autoLoader') {
dps =
(alpha * gun.count) /
(gun.reload * reloadCoefficient +
(gun.count - 1) * gun.intraClip * intraClipCoefficient);
} else {
const mostOptimalShell = gun.reload.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0 ? gun.intraClip * intraClipCoefficient : 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
dps = alpha / mostOptimalShell?.reload;
}
return dps * 60;
}
@@ -0,0 +1,13 @@
import { GunDefinition } from './tankDefinitions';
export function resolveReload(gun: GunDefinition) {
if (gun.type === 'regular') {
return gun.reload;
} else if (gun.type === 'autoLoader') {
return gun.reload + (gun.count - 1) * gun.intraClip;
} else {
return (
gun.reload.reduce((a, b) => a + b, 0) + (gun.count - 1) * gun.intraClip
);
}
}
@@ -0,0 +1,8 @@
import { ModelArmor } from '@blitzkit/core/src/blitzkit/modelDefinitions';
export function resolveArmor(armor: ModelArmor, index: number) {
const spaced = armor.spaced?.includes(index) ?? false;
const thickness = index === -1 ? 0 : armor.thickness[index] ?? 0;
return { spaced, thickness };
}
@@ -0,0 +1,5 @@
import { Resvg } from '@resvg/resvg-js';
export default function svgToPng(svg: string) {
return new Resvg(svg).render().asPng();
}
@@ -0,0 +1,46 @@
import { Worker } from 'worker_threads';
const RENDER_WORKERS = 1;
const workers = [...Array(RENDER_WORKERS)].map(() => {
const worker = new Worker('dist/bot/workers/render.js');
console.log(`🟡 Launching render worker ${worker.threadId}`);
return worker;
});
const queue: {
svg: string;
resolve: (png: Buffer) => void;
}[] = [];
function manageQueue() {
if (queue.length > 0 && workers.length > 0) {
const { svg, resolve } = queue.shift()!;
const worker = workers.shift()!;
function cleanup() {
workers.push(worker);
worker.off('message', handleMessage);
worker.off('error', handleError);
manageQueue();
}
function handleMessage(png: Uint8Array) {
cleanup();
resolve(Buffer.from(png));
}
function handleError(error: Error) {
cleanup();
throw error;
}
worker.postMessage(svg);
worker.on('message', handleMessage);
worker.on('error', handleError);
}
}
export function svgToPngThreaded(svg: string) {
return new Promise<Buffer>((resolve) => {
queue.push({ svg, resolve });
manageQueue();
});
}
@@ -0,0 +1,6 @@
import { tankIcon } from '@blitzkit/core/src/blitzkit/tankIcon';
import { iconPng } from './iconPng';
export function tankIconPng(id: number) {
return iconPng(tankIcon(id));
}
@@ -0,0 +1,15 @@
import { ProvisionDefinitions } from '@blitzkit/core/src/blitzkit/provisionDefinitions';
import { uniqueId } from 'lodash';
import { CompareMember } from '../../../packages/website/src/stores/compareEphemeral';
import { TankDefinition } from './tankDefinitions';
import { tankToDuelMember } from './tankToDuelMember';
export function tankToCompareMember(
tank: TankDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
return {
...tankToDuelMember(tank, provisionDefinitions),
key: uniqueId(),
} satisfies CompareMember;
}
@@ -0,0 +1,49 @@
import { TankDefinition } from '@blitzkit/core';
import { availableProvisions } from '@blitzkit/core/src/blitzkit/availableProvisions';
import { ProvisionDefinitions } from '@blitzkit/core/src/blitzkit/provisionDefinitions';
import { DuelMember } from '../../stores/duel';
import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
const PROVISION_PREFERENCES = [
19, // improved fuel
18, // standard fuel
22, // protective kit
];
function infinityFallback(value: number) {
return value === -1 ? Infinity : value;
}
export function tankToDuelMember(
tank: TankDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const provisionsList = availableProvisions(tank, gun, provisionDefinitions);
return {
camouflage: true,
consumables: [],
equipmentMatrix: genericDefaultEquipmentMatrix,
cooldownBooster: 0,
crewMastery: 1,
provisions: provisionsList
.sort(
(a, b) =>
infinityFallback(PROVISION_PREFERENCES.indexOf(a.id)) -
infinityFallback(PROVISION_PREFERENCES.indexOf(b.id)),
)
.sort((a, b) => (b.crew ?? 0) - (a.crew ?? 0))
.slice(0, tank.provisions)
.map(({ id }) => id),
engine: tank.engines.at(-1)!,
gun,
shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0],
tank,
track: tank.tracks.at(-1)!,
yaw: 0,
pitch: 0,
turret,
} satisfies DuelMember;
}
@@ -0,0 +1,9 @@
import { BoundingBox } from '@blitzkit/core/src/blitzkit/modelDefinitions';
import { Vector3Tuple } from 'three';
export function unionBoundingBox(a: BoundingBox, b: BoundingBox) {
return {
min: a.min.map((a, index) => Math.min(a, b.min[index])) as Vector3Tuple,
max: a.max.map((a, index) => Math.max(a, b.max[index])) as Vector3Tuple,
} satisfies BoundingBox;
}
@@ -0,0 +1,9 @@
import { BlitzkitResponse } from '../../hooks/useTankVotes';
export function unwrapBlitzkitResponse<Data>(response: BlitzkitResponse<Data>) {
if (response.status === 'error') {
throw new Error(response.error, { cause: response.message });
}
return response.data;
}
@@ -0,0 +1,7 @@
import { context } from './context';
export function withCORSProxy(url: string) {
return context === 'website'
? `https://corsproxy.io/?${encodeURIComponent(url)}`
: url;
}
@@ -0,0 +1,18 @@
import { Region } from '../../constants/regions';
import { getAccountInfo } from '../blitz/getAccountInfo';
import buttonLink from '../discord/buttonLink';
export async function getBlitzStarsLinkButton(
region: Region,
id: number,
tankId?: number,
) {
const { nickname } = await getAccountInfo(region, id);
return buttonLink(
`https://www.blitzstars.com/player/${region}/${nickname}${
tankId ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
);
}
@@ -0,0 +1,64 @@
import { emptyAllStats } from '@blitzkit/core/src/blitzstars/getStatsInPeriod/constants';
import { Region } from '../../constants/regions';
import {
GetHistoriesOptions,
Histories,
History,
getHistoriesDefaultOptions,
} from '../../types/histories';
import { getAccountInfo } from '../blitz/getAccountInfo';
import { PlayerStats } from './getPlayerStats';
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;
}
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more