mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 03:59:50 +02:00
feat: migration 2
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user