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,110 @@
import { Locale } from 'discord.js';
import { Region } from '../../../../../src/constants/regions';
import getTankHistories, {
TankHistories,
TankHistoryRaw,
} from '../../../../../src/core/blitzkit/getTankHistories';
import { BlitzStats } from '../../../../../src/core/statistics/compositeStats/constants';
import { emptyAllStats } from './constants';
const emptyTankHistoryNode: TankHistoryRaw = {
account_id: 0,
battle_life_time: 0,
last_battle_time: 0,
mark_of_mastery: 0,
tank_id: 0,
all: emptyAllStats,
};
export interface DiffedTankStats {
diff: Record<number, BlitzStats>;
order: number[];
}
// BIG TODO: merge this neatly with diffNormalizedTankStats
export default async function getStatsInPeriod(
server: Region,
id: number,
start: number,
end: number,
locale: Locale,
) {
const history = await getTankHistories(server, id, locale, {
includeLatestHistories: true,
});
// sort them even though most won't be used
const tankSortedHistory: Record<number, TankHistories> = {};
const tanks: number[] = [];
history.forEach((node) => {
if (!tankSortedHistory[node.tank_id]) {
tankSortedHistory[node.tank_id] = [node];
tanks.push(node.tank_id);
} else {
tankSortedHistory[node.tank_id].push(node);
}
});
// find the ones played within the range
const inRangeTanks = tanks.filter((tankId) =>
tankSortedHistory[tankId].some(
(node) => node.last_battle_time > start && node.last_battle_time < end,
),
);
const playedTanks: number[] = [];
// fetch history node right before "start" and right before "end"
const diff: Record<number, BlitzStats> = {};
inRangeTanks.forEach((tankId) => {
const sortedHistory = tankSortedHistory[tankId];
const previousIndex =
sortedHistory.findIndex((node) => node.last_battle_time > start) - 1;
const latestIndex =
sortedHistory.findIndex((node) => node.last_battle_time > end) - 1;
const previous = sortedHistory[previousIndex] ?? emptyTankHistoryNode;
const latest =
sortedHistory[latestIndex] ?? sortedHistory[sortedHistory.length - 1];
function d(get: (allStats: BlitzStats) => number) {
return get(latest.all) - get(previous.all);
}
// check if there was a change in battles as games in rating do update last_battle_time
if (d((a) => a.battles) > 0) {
diff[tankId] = {
battles: d((a) => a.battles),
capture_points: d((a) => a.capture_points),
damage_dealt: d((a) => a.damage_dealt),
damage_received: d((a) => a.damage_received),
dropped_capture_points: d((a) => a.dropped_capture_points),
frags: d((a) => a.frags),
frags8p: d((a) => a.frags8p),
hits: d((a) => a.hits),
losses: d((a) => a.losses),
max_frags: latest.all.max_frags,
max_xp: latest.all.max_xp,
shots: d((a) => a.shots),
spotted: d((a) => a.spotted),
survived_battles: d((a) => a.survived_battles),
win_and_survived: d((a) => a.win_and_survived),
wins: d((a) => a.wins),
xp: d((a) => a.xp),
};
playedTanks.push(tankId);
}
});
const order = playedTanks.sort((a, b) => {
const aHistories = tankSortedHistory[a];
const bHistories = tankSortedHistory[b];
const aLatest = aHistories[aHistories.length - 1];
const bLatest = bHistories[bHistories.length - 1];
return bLatest.last_battle_time - aLatest.last_battle_time;
});
return { diff, order } satisfies DiffedTankStats;
}
export * from './constants';
@@ -0,0 +1 @@
export * from './getStatsInPeriod';
@@ -0,0 +1,59 @@
import AdmZip from 'adm-zip';
import { readFile, writeFile } from 'fs/promises';
import { Parser } from 'pickleparser';
import protobuf from 'protobufjs';
import { ReadStream } from '../../../../core/src/streams/buffer';
interface WotbReplayMetaJson {
version: string;
title: string;
dbid: string;
playerName: string;
battleStartTime: string;
playerVehicleName: string;
mapName: string;
arenaUniqueId: string;
battleDuration: number;
vehicleCompDescriptor: number;
camouflageId: number;
mapId: number;
arenaBonusType: number;
}
export class WotbReplayReadStream extends ReadStream {
async wotbReplay() {
const pickleParser = new Parser();
const zip = new AdmZip(Buffer.from(this.buffer));
const metaJsonBuffer = zip.getEntry('meta.json')?.getData();
const battleResultsDat = zip.getEntry('battle_results.dat')?.getData();
if (!metaJsonBuffer || !battleResultsDat)
throw new SyntaxError('meta.json not found');
const metaJson = JSON.parse(
metaJsonBuffer.toString(),
) as WotbReplayMetaJson;
const unpickledBattleResults = pickleParser.parse(battleResultsDat);
const [, battleResultsString] = unpickledBattleResults as [number, string];
const root = await protobuf.load(
`${__dirname}/../../protos/BattleResultsClient.proto`,
);
const BattleResults = root.lookupType(
'BattleResultsGenerated.BattleResultsClient',
);
writeFile('test.dat', Buffer.from(battleResultsString));
const reader = protobuf.Reader.create(Buffer.from(battleResultsString));
// const decodedBattleResults = BattleResults.decodeDelimited(
// Buffer.from(battleResultsString),
// );
// console.log(decodedBattleResults.toJSON());
}
}
const replay = await readFile('temp/test3.wotbreplay');
const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer);
stream.wotbReplay();
+5 -1
View File
@@ -8,9 +8,13 @@
"lodash": "^4.17.21",
"@prisma/client": "^5.19.1",
"@octokit/plugin-throttling": "^8.2.0",
"lz4js": "^0.2.0"
"protobufjs": "^7.4.0",
"lz4js": "^0.2.0",
"pickleparser": "^0.2.1",
"adm-zip": "^0.5.16"
},
"devDependencies": {
"@types/adm-zip": "^0.5.5",
"@types/lodash": "^4.17.7",
"@types/lz4js": "^0.2.1"
}
+20
View File
@@ -6,3 +6,23 @@ export const TANK_CLASSES = [
'heavyTank',
'AT-SPG',
] as const;
export const emptyAllStats: BlitzStats = {
battles: 0,
capture_points: 0,
damage_dealt: 0,
damage_received: 0,
dropped_capture_points: 0,
frags: 0,
frags8p: 0,
hits: 0,
losses: 0,
max_frags: 0,
max_xp: 0,
shots: 0,
spotted: 0,
survived_battles: 0,
xp: 0,
win_and_survived: 0,
wins: 0,
};
+1
View File
@@ -1,3 +1,4 @@
export * from './constants';
export * from './idToRegion';
export * from './permanentSkills';
export * from './resolveNearPenetration';
@@ -0,0 +1,13 @@
import { ProvisionDefinitions } from '@blitzkit/core/src/blitzkit/provisionDefinitions';
import { checkConsumableProvisionInclusivity } from './checkConsumableProvisionInclusivity';
import { GunDefinition, TankDefinition } from './tankDefinitions';
export function availableProvisions(
tank: TankDefinition,
gun: GunDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
return Object.values(provisionDefinitions).filter((provision) =>
checkConsumableProvisionInclusivity(provision, tank, gun),
);
}
@@ -0,0 +1,21 @@
import { Samples } from '.';
import { emptyAllStats } from '../../blitz/constants';
export const emptyAverageDefinitionsAllStats: BlitzkitStats = {
...emptyAllStats,
battle_life_time: 0,
};
export const averageDefinitionsAllStatsKeys = Object.keys(
emptyAverageDefinitionsAllStats,
) as (keyof BlitzkitStats)[];
export const emptySamples: Samples = {
d_1: 0,
d_7: 0,
d_30: 0,
d_60: 0,
d_90: 0,
d_120: 0,
total: 0,
};
@@ -0,0 +1,73 @@
import { BlitzkitStats } from '../../../../../src/core/statistics/compositeStats/constants';
import { decodeProtobuf } from '../../protobuf/decodeProtobuf';
import { asset } from '../asset';
export interface AverageDefinitionsEntry {
mu: BlitzkitStats;
sigma: BlitzkitStats;
r: BlitzkitStats;
samples: Samples;
}
export interface AverageDefinitionsEntrySubPartial {
mu: Partial<BlitzkitStats>;
sigma: Partial<BlitzkitStats>;
r: Partial<BlitzkitStats>;
samples: Samples;
}
export interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
id: number;
}
export interface AverageDefinitions {
averages: Record<number, AverageDefinitionsEntry>;
samples: Samples;
time: number;
}
export interface Samples {
d_1: number;
d_7: number;
d_30: number;
d_60: number;
d_90: number;
d_120: number;
total: number;
}
export interface AverageDefinitionsManifest {
version: 1;
/**
* epoch in days
*/
latest: number;
}
export const averageDefinitions = fetch(asset('averages/manifest.json'), {
cache: 'no-store',
})
.then((response) => response.json() as Promise<AverageDefinitionsManifest>)
.then((manifest) => fetch(asset(`averages/${manifest.latest}.pb`)))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decodeProtobuf<AverageDefinitions>(
'average_definitions',
'blitzkit.AverageDefinitions',
new Uint8Array(buffer),
),
);
export const averageDefinitionsArray = averageDefinitions.then((data) =>
Object.entries(data.averages)
.map(
([key, value]) =>
({
id: Number(key),
...(value as AverageDefinitionsEntry),
} satisfies AverageDefinitionsEntryWithId),
)
.filter((tank) => tank.mu.battles > 0),
);
export * from './constants';
@@ -0,0 +1,56 @@
import { ConsumableEntry } from '@blitzkit/core/src/blitzkit/consumableDefinitions';
import { ProvisionEntry } from '@blitzkit/core/src/blitzkit/provisionDefinitions';
import { GunDefinition, TankDefinition } from './tankDefinitions';
export function checkConsumableProvisionInclusivity(
consumableProvision: ConsumableEntry | ProvisionEntry,
tank: TankDefinition,
gun: GunDefinition,
) {
const included =
!consumableProvision.gameMode &&
consumableProvision.include?.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded =
consumableProvision.gameMode ||
consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
return gun.type !== 'regular';
}
});
}
});
return (
!consumableProvision.gameMode &&
consumableProvision.include?.length > 0 &&
included &&
!excluded
);
}
@@ -0,0 +1,3 @@
export function coefficient(...effects: [boolean, number][]) {
return effects.reduce((accumulator, b) => accumulator + (b[0] ? b[1] : 0), 1);
}
@@ -0,0 +1,47 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export type TankFilterDefinitionCategory = 'clip';
export type TankFilterDefinition =
| {
type: 'tier';
min: number;
max: number;
}
| {
type: 'ids';
ids: number[];
}
| {
type: 'category';
categories: TankFilterDefinitionCategory[];
}
| {
type: 'nation';
nations: string[];
};
export type ConsumableEntry = {
id: number;
name: string;
cooldown: number;
duration?: number;
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ConsumableDefinitions {
[key: string]: ConsumableEntry;
}
export const consumableDefinitions = fetchCdonLz4<ConsumableDefinitions>(
asset('definitions/consumables.cdon.lz4'),
);
@@ -0,0 +1,14 @@
import { SkillDefinitions } from '@blitzkit/core/src/blitzkit/skillDefinitions';
import { permanentSkills } from '../blitz/permanentSkills';
export function createDefaultSkills(skillDefinitions: SkillDefinitions) {
const skills: Record<string, number> = {};
Object.values(skillDefinitions.classes).forEach((tankSkills) => {
tankSkills.forEach((skill) => {
skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
});
});
return skills;
}
@@ -0,0 +1,28 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
type EquipmentOptions = [number, number];
export type EquipmentRow = [
EquipmentOptions,
EquipmentOptions,
EquipmentOptions,
];
export type EquipmentPreset = [EquipmentRow, EquipmentRow, EquipmentRow];
export interface EquipmentDefinitions {
presets: {
[key: string]: EquipmentPreset;
};
equipments: {
[key: number]: {
name: string;
description: string;
};
};
}
export const equipmentDefinitions = fetchCdonLz4<EquipmentDefinitions>(
asset('definitions/equipment.cdon.lz4'),
);
@@ -0,0 +1,14 @@
export class EventManager<Type> {
private callbacks: Set<(data: Type) => void> = new Set();
on(callback: (data: Type) => void) {
this.callbacks.add(callback);
}
off(callback: (data: Type) => void) {
return this.callbacks.delete(callback);
}
emit(data: Type) {
this.callbacks.forEach((callback) => callback(data));
}
}
+18
View File
@@ -1,12 +1,30 @@
export * from './assertSecret';
export * from './asset';
export * from './availableProvisions';
export * from './averageDefinitions';
export * from './camouflageDefinitions';
export * from './checkConsumableProvisionInclusivity';
export * from './coefficient';
export * from './consumableDefinitions';
export * from './createDefaultSkills';
export * from './equipmentDefinitions';
export * from './eventManager';
export * from './fetchCdonLz4';
export * from './filterTank';
export * from './gameDefinitions';
export * from './isExplosive';
export * from './modelDefinitions';
export * from './pose';
export * from './provisionDefinitions';
export * from './resolvePenetrationCoefficient';
export * from './reviews';
export * from './skillDefinitions';
export * from './sum';
export * from './superCompress';
export * from './superDecompress';
export * from './tankCharacteristics';
export * from './tankDefinitions';
export * from './tankIcon';
export * from './tankNames';
export * from './uniqueGuns';
export * from './youtubers';
@@ -0,0 +1,7 @@
import { ShellDefinition, ShellType } from '@blitzkit/core';
export function isExplosive(
type: ShellDefinition['type'],
): type is ShellType.HEAT | ShellType.HE {
return type === ShellType.HEAT || type === ShellType.HE;
}
+9
View File
@@ -0,0 +1,9 @@
import { EventManager } from './eventManager';
export enum Pose {
HullDown,
FaceHug,
Default,
}
export const poseEvent = new EventManager<Pose>();
@@ -0,0 +1,26 @@
import { TankFilterDefinition } from '@blitzkit/core/src/blitzkit/consumableDefinitions';
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export type ProvisionEntry = {
id: number;
name: string;
crew?: number;
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ProvisionDefinitions {
[key: string]: ProvisionEntry;
}
export const provisionDefinitions = fetchCdonLz4<ProvisionDefinitions>(
asset('definitions/provisions.cdon.lz4'),
);
@@ -0,0 +1,16 @@
import { ShellType } from '../blitzkit/tankDefinitions';
export function resolvePenetrationCoefficient(
calibratedShells: boolean,
type: ShellType,
) {
if (!calibratedShells) return 1;
return type === 'ap'
? 1.08
: type === 'ap_cr'
? 1.05
: type === 'hc'
? 1.13
: 1.08;
}
+26
View File
@@ -0,0 +1,26 @@
import { asset } from '@blitzkit/core/src/blitzkit/asset';
import { decodeProtobuf } from '../protobuf';
export interface Reviews {
reviews: Record<number, Review>;
}
interface Review {
last_updated: number;
videos?: Video[];
}
export interface Video {
id: string;
author: string;
}
export const reviews = fetch(asset('definitions/reviews.pb'))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decodeProtobuf<Reviews>(
'reviews',
'blitzkit.Reviews',
new Uint8Array(buffer),
),
);
@@ -0,0 +1,11 @@
import { asset } from '@blitzkit/core/src/blitzkit/asset';
import { fetchCdonLz4 } from '@blitzkit/core/src/blitzkit/fetchCdonLz4';
import { TankClass } from '../../../../src/components/Tanks';
export interface SkillDefinitions {
classes: Record<TankClass, string[]>;
}
export const skillDefinitions = fetchCdonLz4<SkillDefinitions>(
asset('definitions/skills.cdon.lz4'),
);
+3
View File
@@ -0,0 +1,3 @@
export function sum(...effects: [boolean, number][]) {
return effects.reduce((accumulator, b) => accumulator + (b[0] ? b[1] : 0), 0);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { compress } from 'lz4js';
import { CdonWriteStream } from '../../../../src/core/streams/cdon';
import { CdonWriteStream } from '../streams';
export function superCompress(object: any) {
const write = new CdonWriteStream().cdon(object);
@@ -1,5 +1,5 @@
import { decompress } from 'lz4js';
import { CdonReadStream } from '../../../../src/core/streams/cdon';
import { CdonReadStream } from '../streams';
export function superDecompress<Type>(buffer: ArrayBuffer) {
const decompressed = decompress(new Uint8Array(buffer)).buffer as ArrayBuffer;
@@ -0,0 +1,544 @@
import { EquipmentDefinitions } from '@blitzkit/core/src/blitzkit/equipmentDefinitions';
import { ModelDefinition } from '@blitzkit/core/src/blitzkit/modelDefinitions';
import { ProvisionDefinitions } from '@blitzkit/core/src/blitzkit/provisionDefinitions';
import { EquipmentMatrix } from '../../../website/src/stores/duel';
import { resolveNearPenetration } from '../blitz/resolveNearPenetration';
import { coefficient } from './coefficient';
import { isExplosive } from './isExplosive';
import { resolvePenetrationCoefficient } from './resolvePenetrationCoefficient';
import { sum } from './sum';
import {
EngineDefinition,
GunDefinition,
ShellDefinition,
TankDefinition,
TrackDefinition,
TurretDefinition,
} from './tankDefinitions';
export type TankCharacteristics = ReturnType<typeof tankCharacteristics>;
export function tankCharacteristics(
{
tank,
turret,
gun,
shell,
consumables,
equipmentMatrix,
crewMastery,
provisions,
engine,
crewSkills,
camouflage,
track,
stockEngine,
stockTrack,
stockTurret,
stockGun,
applyReactiveArmor,
applyDynamicArmor,
applySpallLiner,
}: {
tank: TankDefinition;
turret: TurretDefinition;
gun: GunDefinition;
shell: ShellDefinition;
track: TrackDefinition;
engine: EngineDefinition;
stockEngine: EngineDefinition;
stockTrack: TrackDefinition;
stockTurret: TurretDefinition;
stockGun: GunDefinition;
consumables: number[];
equipmentMatrix: EquipmentMatrix;
crewMastery: number;
provisions: number[];
crewSkills: Record<string, number>;
camouflage: boolean;
applyReactiveArmor: boolean;
applyDynamicArmor: boolean;
applySpallLiner: boolean;
},
{
tankModelDefinition,
equipmentDefinitions,
provisionDefinitions,
}: {
tankModelDefinition: ModelDefinition;
equipmentDefinitions: EquipmentDefinitions;
provisionDefinitions: ProvisionDefinitions;
},
) {
const presetRows = equipmentDefinitions.presets[tank.equipment];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
function equipment(id: number) {
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;
});
});
}
function consumable(id: number) {
return consumables.includes(id);
}
function provision(id: number) {
return provisions.includes(id);
}
const hasGunRammer = equipment(100);
const hasImprovedVentilation = equipment(102);
const hasCalibratedShells = equipment(103);
const hasEnhancedGunLayingDrive = equipment(104);
const hasSupercharger = equipment(107);
const hasVerticalStabilizer = equipment(105);
const hasRefinedGun = equipment(106);
const hasImprovedAssembly = equipment(111);
const hasImprovedOptics = equipment(114);
const hasImprovedControl = equipment(116);
const hasEngineAccelerator = equipment(117);
const hasCamouflageNet = equipment(115);
const hasImprovedVerticalStabilizer = equipment(122);
const hasImprovedSuspension = equipment(123);
const hasTungsten = consumable(45);
const hasAdrenaline = consumable(18);
const hasShellReloadBoost = consumable(29);
const hasEnginePowerBoost = consumable(14);
const hasImprovedEnginePowerBoost = consumable(27);
const hasReticleCalibration = consumable(28);
const hasReducedEnginePowerBoost = consumable(80);
const hasStandardFuel = provision(18);
const hasImprovedFuel = provision(19);
const hasProtectiveKit = provision(22);
const hasSandbagArmor = provision(28);
const hasEnhancedSandbagArmor = provision(29);
const hasImprovedGunPowder = provision(46);
const hasGearOil = provision(44);
const hasImprovedGearOil = provision(45);
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const provisionCrewBonus =
provisions.reduce(
(total, provision) =>
provisionDefinitions[provision].crew
? total + provisionDefinitions[provision].crew! / 100
: total,
0,
) + (hasImprovedVentilation ? 0.08 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const loaderMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'loader') ? 1.1 : 1.05);
const gunnerMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'gunner') ? 1.1 : 1.05);
const driverMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'driver') ? 1.1 : 1.05);
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const damageCoefficient =
coefficient([hasTungsten, 0.15]) *
coefficient(
[applyReactiveArmor, -0.27],
[applyDynamicArmor, -0.1],
[applySpallLiner && shell.type === 'he', -0.2],
);
const reloadCoefficient =
(coefficient([hasGunRammer, -0.05]) *
coefficient([true, degressiveStat(loaderMastery)])) /
coefficient([hasAdrenaline && gun.type === 'regular', 0.17]);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const healthCoefficient = coefficient(
[hasSandbagArmor, 0.03],
[hasEnhancedSandbagArmor, 0.06],
[hasImprovedAssembly, 0.04],
);
const shellVelocityCoefficient = coefficient(
[hasSupercharger, 0.35],
[hasImprovedGunPowder, 0.3],
);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.6,
]);
const aimTimeCoefficient =
coefficient(
[hasEnhancedGunLayingDrive, -0.15],
[hasReticleCalibration, -0.3],
) * coefficient([true, degressiveStat(gunnerMastery)]);
const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.15], [hasReticleCalibration, -0.3]) *
coefficient([true, degressiveStat(gunnerMastery)]);
const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer,
-0.12,
]);
const dispersionHullTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turn / 100],
);
const dispersionAfterShotCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.soft_recoil / 100],
);
const dispersionMovingCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_driving / 100],
);
const dispersionTurretTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turret * (2 / 100)],
);
const enginePowerCoefficient = coefficient(
[
hasEngineAccelerator,
tank.class === 'heavyTank' || tank.class === 'AT-SPG' ? 0.05 : 0.04,
],
[hasReducedEnginePowerBoost, 0.1],
[hasEnginePowerBoost, 0.2],
[hasImprovedEnginePowerBoost, 0.35],
[hasStandardFuel, 0.03],
[hasImprovedFuel, 0.1],
[hasGearOil, 0.03],
[hasImprovedGearOil, 0.06],
);
const turretTraverseCoefficient =
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
coefficient([true, progressiveStat(gunnerMastery)]);
const hullTraverseCoefficient =
coefficient(
[hasImprovedControl, 0.11],
[hasImprovedEnginePowerBoost, 0.01],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(driverMastery)]);
const resistanceHardCoefficient = coefficient([hasImprovedSuspension, -0.2]);
const resistanceMediumCoefficient = coefficient([
hasImprovedSuspension,
-0.15,
]);
const resistanceSoftCoefficient = coefficient([hasImprovedSuspension, -0.3]);
const viewRangeCoefficient =
coefficient([
hasImprovedOptics,
tank.class === 'heavyTank'
? 0.06
: tank.class === 'mediumTank'
? 0.08
: tank.class === 'lightTank'
? 0.11
: 0.04,
]) * coefficient([true, progressiveStat(commanderMastery)]);
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
const speedForwardsSum = sum(
[hasImprovedEnginePowerBoost, 4],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const speedBackwardsSum = sum(
[hasImprovedEnginePowerBoost, 8],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const camouflageCoefficientMoving = sum(
[
true,
coefficient([
camouflage,
tank.class === 'AT-SPG'
? 0.04
: tank.class === 'heavyTank'
? 0.03
: 0.02,
]),
],
[
hasCamouflageNet,
tank.class === 'heavyTank'
? 0.02
: tank.class === 'mediumTank'
? 0.04
: tank.class === 'lightTank'
? 0.07
: 0.1,
],
);
const camouflageCoefficientStill = sum(
[
true,
coefficient(
[
camouflage,
tank.class === 'AT-SPG'
? 0.04
: tank.class === 'heavyTank'
? 0.03
: 0.02,
],
[true, crewSkills.camouflage * (3 / 100)],
),
],
[
hasCamouflageNet,
tank.class === 'heavyTank'
? 0.04
: tank.class === 'mediumTank'
? 0.08
: tank.class === 'lightTank'
? 0.14
: 0.2,
],
);
const size = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.boundingBox,
turretModelDefinition.boundingBox,
),
);
const weightKg =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const weightTons = weightKg / 1000;
const stockWeight =
tank.weight +
stockEngine.weight +
stockTrack.weight +
stockTurret.weight +
stockGun.weight;
const resolvedEnginePower = engine.power * enginePowerCoefficient;
const dpm = resolveDpm(
gun,
shell,
damageCoefficient,
reloadCoefficient,
intraClipCoefficient,
);
const intraClip =
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
const mostOptimalShellIndex =
gun.type === 'autoReloader'
? gun.reload.reduce<null | { index: number; reload: number }>(
(current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient + (index > 0 ? intraClip! : 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
},
null,
)!.index
: undefined;
const damage = shell.damage.armor * damageCoefficient;
const dpmEffective =
gun.type === 'autoReloader'
? gun.reload[0] > gun.reload[1] + gun.intraClip
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(0, -1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(0, -1).length
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient * shell.damage.armor * gun.reload.slice(1).length
: undefined;
const shells = gun.type === 'regular' ? 1 : gun.count;
const shellReloads =
gun.type === 'autoReloader'
? gun.reload.map((reload) => reload * reloadCoefficient)
: undefined;
const shellReload =
gun.type === 'autoReloader' ? undefined : gun.reload * reloadCoefficient;
const caliber = shell.caliber;
const penetration =
resolveNearPenetration(shell.penetration) * penetrationCoefficient;
const clipDamage = gun.type === 'regular' ? undefined : gun.count * damage;
const moduleDamage = shell.damage.module * damageCoefficient;
const explosionRadius = shell.explosionRadius;
const shellVelocity = shell.speed * shellVelocityCoefficient;
const aimTime = gun.aimTime * aimTimeCoefficient;
const dispersion = gun.dispersion.base * dispersionStillCoefficient;
const maxDispersion =
gun.dispersion.base *
Math.sqrt(
((gun.dispersion.traverse * turret.traverseSpeed) ** 2 +
(track.dispersion.traverse * track.traverseSpeed) ** 2 +
track.dispersion.move * tank.speed.forwards) **
2,
);
const dispersionMoving = track.dispersion.move * dispersionMovingCoefficient;
const dispersionHullTraversing =
track.dispersion.traverse * dispersionHullTraverseCoefficient;
const dispersionTurretTraversing =
gun.dispersion.traverse * dispersionTurretTraverseCoefficient;
const dispersionShooting =
gun.dispersion.shot * dispersionAfterShotCoefficient;
const dispersionGunDamaged =
gun.dispersion.damaged * dispersionDamagedCoefficient;
const gunDepression =
gunModelDefinition.pitch.max +
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0);
const gunElevation =
-gunModelDefinition.pitch.min -
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 4 : 0);
const gunFrontalDepression = gunModelDefinition.pitch.front?.max;
const gunFrontalElevation = gunModelDefinition.pitch.front
? -gunModelDefinition.pitch.front.min
: undefined;
const gunRearDepression = gunModelDefinition.pitch.back?.max;
const gunRearElevation = gunModelDefinition.pitch.back
? -gunModelDefinition.pitch.back.min
: undefined;
const azimuthLeft = turretModelDefinition.yaw
? -turretModelDefinition.yaw.min
: undefined;
const azimuthRight = turretModelDefinition.yaw?.max;
const speedForwards = tank.speed.forwards + speedForwardsSum;
const speedBackwards = tank.speed.backwards + speedBackwardsSum;
const enginePower = resolvedEnginePower;
const hardTerrainCoefficientRaw =
track.resistance.hard * resistanceHardCoefficient;
const hardTerrainCoefficient = 100 / hardTerrainCoefficientRaw;
const mediumTerrainCoefficientRaw =
track.resistance.medium * resistanceMediumCoefficient;
const mediumTerrainCoefficient = 100 / mediumTerrainCoefficientRaw;
const softTerrainCoefficientRaw =
track.resistance.soft * resistanceSoftCoefficient;
const softTerrainCoefficient = 100 / softTerrainCoefficientRaw;
const powerToWeightRatioHardTerrain =
resolvedEnginePower / weightTons / hardTerrainCoefficientRaw;
const powerToWeightRatioMediumTerrain =
resolvedEnginePower / weightTons / mediumTerrainCoefficientRaw;
const powerToWeightRatioSoftTerrain =
resolvedEnginePower / weightTons / softTerrainCoefficientRaw;
const weight = weightTons;
const turretTraverseSpeed = turret.traverseSpeed * turretTraverseCoefficient;
const hullTraverseHardTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard / hardTerrainCoefficientRaw) *
(stockWeight / weightKg);
const hullTraverseMediumTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard / mediumTerrainCoefficientRaw) *
(stockWeight / weightKg);
const hullTraverseSoftTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard / softTerrainCoefficientRaw) *
(stockWeight / weightKg);
const health = (tank.health + turret.health) * healthCoefficient;
const fireChance = engine.fireChance * fireChanceCoefficient;
const viewRange = turret.viewRange * viewRangeCoefficient;
const camouflageStill = tank.camouflage.still * camouflageCoefficientStill;
const camouflageMoving =
tank.class === 'lightTank'
? tank.camouflage.still * camouflageCoefficientStill
: tank.camouflage.moving * camouflageCoefficientMoving;
const camouflageShootingStill =
tank.camouflage.still * gun.camouflageLoss * camouflageCoefficientStill;
const camouflageShootingMoving =
(tank.class === 'lightTank'
? tank.camouflage.still * camouflageCoefficientStill
: tank.camouflage.moving * camouflageCoefficientMoving) *
gun.camouflageLoss;
const camouflageCaughtOnFire =
tank.camouflage.onFire * tank.camouflage.still * camouflageCoefficientStill;
const width = size[2];
const height = size[0];
const length = size[1];
const volume = width * height * length;
const shellNormalization = shell.normalization ?? 0;
const shellRicochet = isExplosive(shell.type) ? undefined : shell.ricochet;
return {
shellNormalization,
shellRicochet,
dpm,
dpmEffective,
shells,
mostOptimalShellIndex,
shellReloads,
shellReload,
intraClip,
caliber,
penetration,
damage,
clipDamage,
moduleDamage,
explosionRadius,
shellVelocity,
aimTime,
dispersion,
maxDispersion,
dispersionMoving,
dispersionHullTraversing,
dispersionTurretTraversing,
dispersionShooting,
dispersionGunDamaged,
gunDepression,
gunElevation,
gunFrontalDepression,
gunFrontalElevation,
gunRearDepression,
gunRearElevation,
azimuthLeft,
azimuthRight,
speedForwards,
speedBackwards,
hardTerrainCoefficient,
hardTerrainCoefficientRaw,
mediumTerrainCoefficient,
mediumTerrainCoefficientRaw,
softTerrainCoefficient,
softTerrainCoefficientRaw,
enginePower,
powerToWeightRatioHardTerrain,
powerToWeightRatioMediumTerrain,
powerToWeightRatioSoftTerrain,
weight,
turretTraverseSpeed,
hullTraverseHardTerrain,
hullTraverseMediumTerrain,
hullTraverseSoftTerrain,
health,
fireChance,
viewRange,
camouflageStill,
camouflageMoving,
camouflageShootingStill,
camouflageShootingMoving,
camouflageCaughtOnFire,
width,
height,
length,
volume,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { forEachRight } from 'lodash';
import { GunDefinition, TurretDefinition } from './tankDefinitions';
export function uniqueGuns(turrets: TurretDefinition[]) {
const gunIds = new Set<number>();
const guns: { turret: TurretDefinition; gun: GunDefinition }[] = [];
forEachRight(turrets, (turret) => {
forEachRight(turret.guns, (gun) => {
if (!gunIds.has(gun.id)) {
gunIds.add(gun.id);
guns.push({ turret, gun });
}
});
});
return guns;
}
+5
View File
@@ -0,0 +1,5 @@
export const youtubers = [
{ id: 'UC9lE09jxmMgMoG13WrNHPmA', name: 'World of Tanks Blitz' },
{ id: 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', name: 'HisRoyalFatness' },
{ id: 'UCxRpZqMElssTdPYDK3x7wyg', name: 'N90' },
];
+5
View File
@@ -1,5 +1,10 @@
export * from './blitz';
export * from './blitzkit';
export * from './imgur';
export * from './math';
export * from './patreon';
export * from './protobuf';
export * from './radix';
export * from './statistics';
export * from './streams';
export * from './zustand';
+3
View File
@@ -0,0 +1,3 @@
export function NaNFallback(value: number, fallback: number) {
return isNaN(value) ? fallback : value;
}
+6
View File
@@ -0,0 +1,6 @@
import { Vector3 } from 'three';
export const ZERO_VECTOR = new Vector3();
export const I_HAT = new Vector3(1, 0, 0);
export const J_HAT = new Vector3(0, 1, 0);
export const K_HAT = new Vector3(0, 0, 1);
+5
View File
@@ -0,0 +1,5 @@
const numberFormat = Intl.NumberFormat('en', { notation: 'compact' });
export function formatCompact(number: number) {
return numberFormat.format(number);
}
+7
View File
@@ -0,0 +1,7 @@
export * from './axis';
export * from './formatCompact';
export * from './isNumber';
export * from './NaNFallback';
export * from './normalizeAngleRad';
export * from './normalToEuler';
export * from './numberToRomanNumeral';
+3
View File
@@ -0,0 +1,3 @@
export default function isNumber(value: any) {
return typeof value === 'number' && isFinite(value);
}
+14
View File
@@ -0,0 +1,14 @@
import { Euler, Quaternion, Vector3 } from 'three';
import { J_HAT } from './axis';
const quaternion = new Quaternion();
const axis = new Vector3();
export function normalToEuler(normal: Vector3, up = J_HAT) {
return new Euler().setFromQuaternion(
quaternion.setFromAxisAngle(
axis.crossVectors(up, normal).normalize(),
up.angleTo(normal),
),
);
}
@@ -0,0 +1,6 @@
export function normalizeAngleRad(angle: number) {
const newAngle = angle % (2 * Math.PI);
if (newAngle > Math.PI) return newAngle - 2 * Math.PI;
if (newAngle < -Math.PI) return newAngle + 2 * Math.PI;
return newAngle;
}
@@ -0,0 +1,32 @@
/**
* Thanks, August!
* https://stackoverflow.com/a/41358305/12294756
*/
const ROMAN = {
M: 1000,
CM: 900,
D: 500,
CD: 400,
C: 100,
XC: 90,
L: 50,
XL: 40,
X: 10,
IX: 9,
V: 5,
IV: 4,
I: 1,
};
const ROMAN_KEYS = Object.keys(ROMAN) as (keyof typeof ROMAN)[];
export default function numberToRomanNumeral(value: number) {
let string = '';
ROMAN_KEYS.forEach((index) => {
const part = Math.floor(value / ROMAN[index]);
value -= part * ROMAN[index];
string += index.repeat(part);
});
return string;
}
@@ -0,0 +1,9 @@
import { lookupProtobufType, ProtoSource } from './lookupProtobufType';
export function decodeProtobuf<Type>(
source: ProtoSource,
type: string,
data: Uint8Array,
) {
return lookupProtobufType(source, type).decode(data).toJSON() as Type;
}
@@ -0,0 +1,14 @@
import { lookupProtobufType, ProtoSource } from './lookupProtobufType';
export function encodeProtobuf(
source: ProtoSource,
type: string,
data: Record<string, any>,
) {
const message = lookupProtobufType(source, type);
const verification = message.verify(data);
if (verification !== null) throw new Error(verification);
return message.encode(message.create(data)).finish();
}
@@ -0,0 +1,11 @@
import { encodeProtobuf } from './encodeProtobuf';
import { ProtoSource } from './lookupProtobufType';
export async function encodeProtobufToBase64(
source: ProtoSource,
type: string,
data: any,
) {
const array = encodeProtobuf(source, type, data);
return Buffer.from(array).toString('base64');
}
+4
View File
@@ -0,0 +1,4 @@
export * from './decodeProtobuf';
export * from './encodeProtobuf';
export * from './encodeProtobufToBase64';
export * from './lookupProtobufType';
@@ -0,0 +1,11 @@
import { parse } from 'protobufjs';
import average_definitions from '../../protos/average_definitions.proto';
import reviews from '../../protos/reviews.proto';
export type ProtoSource = keyof typeof protos;
const protos = { reviews, average_definitions };
export function lookupProtobufType(source: ProtoSource, type: string) {
return parse(protos[source]).root.lookupType(type);
}
+1
View File
@@ -0,0 +1 @@
export * from './var';
+8
View File
@@ -0,0 +1,8 @@
type Name =
| `color-${'background' | `panel-${'solid' | 'translucent'}` | 'surface' | 'overlay'}`
| `radius-${1 | 2 | 3 | 4 | 5 | 6 | 'full'}`
| `space-${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`;
export function Var(name: Name) {
return `var(--${name})`;
}
@@ -0,0 +1,58 @@
import { BlitzStats } from './compositeStats/constants';
/**
* Formula from https://web.archive.org/web/20190105064817/http://wiki.wnefficiency.net/pages/WN8
*
* @param expected what all players on average perform like
* @param average what the player in question performs like
*/
export default function calculateWN8(
expected: BlitzStats,
average: {
damage_dealt: number;
battles: number;
spotted: number;
frags: number;
dropped_capture_points: number;
wins: number;
},
) {
const avgDmg = average.damage_dealt / average.battles;
const avgSpot = average.spotted / average.battles;
const avgFrag = average.frags / average.battles;
const avgDef = average.dropped_capture_points / average.battles;
const avgWinRate = average.wins / average.battles;
const expDmg = expected.damage_dealt / expected.battles;
const expSpot = expected.spotted / expected.battles;
const expFrag = expected.frags / expected.battles;
const expDef = expected.dropped_capture_points / expected.battles;
const expWinRate = expected.wins / expected.battles;
const rDAMAGE = avgDmg / expDmg;
const rSPOT = avgSpot / expSpot;
const rFRAG = avgFrag / expFrag;
const rDEF = avgDef / expDef;
const rWIN = avgWinRate / expWinRate;
const rWINc = Math.max(0, (rWIN - 0.71) / (1 - 0.71));
const rDAMAGEc = Math.max(0, (rDAMAGE - 0.22) / (1 - 0.22));
const rFRAGc = Math.max(
0,
Math.min(rDAMAGEc + 0.2, (rFRAG - 0.12) / (1 - 0.12)),
);
const rSPOTc = Math.max(
0,
Math.min(rDAMAGEc + 0.1, (rSPOT - 0.38) / (1 - 0.38)),
);
const rDEFc = Math.max(0, Math.min(rDAMAGEc + 0.1, (rDEF - 0.1) / (1 - 0.1)));
const WN8 =
980 * rDAMAGEc +
210 * rDAMAGEc * rFRAGc +
155 * rFRAGc * rSPOTc +
75 * rDEFc * rFRAGc +
145 * Math.min(1.8, rWINc);
return WN8;
}
@@ -0,0 +1,200 @@
export const blitzStatsKeys = [
'battles',
'capture_points',
'damage_dealt',
'damage_received',
'dropped_capture_points',
'frags',
'frags8p',
'hits',
'losses',
'max_frags',
'max_xp',
'shots',
'spotted',
'survived_battles',
'win_and_survived',
'wins',
'xp',
] as const;
export const blitzkitStatsKeys = [
...blitzStatsKeys,
'battle_life_time',
] as const;
export const blitzPlayerStatsKeys = [
...blitzStatsKeys,
'max_frags_tank_id',
'max_xp_tank_id',
] as const;
export const compositeStatsKeysBase = [
...blitzkitStatsKeys,
'damage_ratio',
'kills_to_death_ratio',
'accuracy',
'inaccuracy',
'misses',
'deaths',
'wn8',
] as const;
const normalizedBlitzStatsKeys = [
// 'battles', will result in 1
// 'capture_points', not all battles are encounter mode
'damage_dealt',
'damage_received',
// 'dropped_capture_points', not all battles are encounter mode
'frags',
// 'frags8p', null on most tanks and not all battles have tier 8 and above tanks
'hits',
'losses',
// 'max_frags', counts the max; not cumulative
// 'max_xp', counts the max; not cumulative
'shots',
'spotted',
'survived_battles',
'win_and_survived',
'wins',
'xp',
] satisfies (typeof blitzStatsKeys)[number][];
const normalizedBlitzkitStatsKeys = [
...normalizedBlitzStatsKeys,
'battle_life_time',
] satisfies (typeof blitzkitStatsKeys)[number][];
const cumulativeBlitzStatsKeys = [
'battles',
'capture_points',
'damage_dealt',
'damage_received',
'dropped_capture_points',
'frags',
// 'frags8p', once again, just a painful metric
'hits',
'losses',
// 'max_frags', static, not cumulative
// 'max_xp', static, not cumulative
'shots',
'spotted',
'survived_battles',
'win_and_survived',
'wins',
'xp',
] satisfies (typeof blitzStatsKeys)[number][];
const cumulativeBlitzkitStatsKeys = [
...cumulativeBlitzStatsKeys,
'battle_life_time',
] satisfies (typeof blitzkitStatsKeys)[number][];
const normalizedCompositeStatsKeys = [
...normalizedBlitzkitStatsKeys,
// 'damage_ratio', is a dimensionless ratio
// 'kills_to_death_ratio', also a dimensionless ratio
// 'accuracy', dimensionless ratio
// 'inaccuracy', dimensionless ratio
'misses',
'deaths',
// 'wn8', is based on a sample of all battles so doesn't make sense to normalize
] satisfies (typeof compositeStatsKeysBase)[number][];
const cumulativeCompositeStatsKeys = [
...cumulativeBlitzkitStatsKeys,
'damage_ratio',
'kills_to_death_ratio',
'accuracy',
'inaccuracy',
'misses',
'deaths',
'wn8',
] satisfies (typeof compositeStatsKeysBase)[number][];
export const compositeStatsKeys = [
...normalizedCompositeStatsKeys.map((key) => `normalized_${key}`),
...cumulativeCompositeStatsKeys.map((key) => `cumulative_${key}`),
] as CompositeStatsKey[];
type BlitzStatsKey = (typeof blitzStatsKeys)[number];
export type BlitzStats = Record<BlitzStatsKey, number>;
type BlitzkitStatsKey = (typeof blitzkitStatsKeys)[number];
export type BlitzkitStats = Record<BlitzkitStatsKey, number>;
type BlitzPlayerStatsKey = (typeof blitzPlayerStatsKeys)[number];
export type BlitzPlayerStats = Record<BlitzPlayerStatsKey, number>;
export type CompositeStatsKey =
| `normalized_${(typeof normalizedCompositeStatsKeys)[number]}`
| `cumulative_${(typeof cumulativeCompositeStatsKeys)[number]}`;
export type CompositeStats = Record<CompositeStatsKey, number>;
export const compositeStatsFormatting: {
[Key in CompositeStatsKey]: {
unit?: string;
fixed: number;
coefficient?: number;
preview: number;
localeFormat?: boolean;
};
} = {
normalized_damage_dealt: { fixed: 0, preview: 3246, localeFormat: true },
normalized_damage_received: { fixed: 0, preview: 966, localeFormat: true },
normalized_deaths: { fixed: 0, unit: '%', coefficient: 100, preview: 0.09 },
normalized_frags: { fixed: 2, preview: 2.1 },
normalized_hits: { fixed: 2, preview: 12.33 },
normalized_losses: { fixed: 0, unit: '%', coefficient: 100, preview: 0.3 },
normalized_misses: { fixed: 2, preview: 1.02 },
normalized_shots: { fixed: 2, preview: 14.54 },
normalized_spotted: { fixed: 2, preview: 2.02 },
normalized_survived_battles: {
fixed: 0,
unit: '%',
coefficient: 100,
preview: 0.65,
},
normalized_win_and_survived: {
fixed: 0,
unit: '%',
coefficient: 100,
preview: 0.59,
},
normalized_wins: { fixed: 0, unit: '%', coefficient: 100, preview: 0.72 },
normalized_xp: { fixed: 0, preview: 982, localeFormat: true },
normalized_battle_life_time: { fixed: 1, preview: 4.6, coefficient: 1 / 60 },
cumulative_accuracy: { fixed: 0, unit: '%', coefficient: 100, preview: 0.91 },
cumulative_battles: { fixed: 0, preview: 12 },
cumulative_capture_points: { fixed: 2, preview: 0.12 },
cumulative_damage_dealt: { fixed: 0, preview: 45023, localeFormat: true },
cumulative_damage_ratio: { fixed: 2, preview: 2.83 },
cumulative_damage_received: { fixed: 0, preview: 10093, localeFormat: true },
cumulative_deaths: { fixed: 0, preview: 2 },
cumulative_dropped_capture_points: { fixed: 2, preview: 15 },
cumulative_frags: { fixed: 0, preview: 26 },
cumulative_hits: { fixed: 0, preview: 480 },
cumulative_inaccuracy: {
fixed: 0,
unit: '%',
coefficient: 100,
preview: 0.08,
},
cumulative_kills_to_death_ratio: { fixed: 2, preview: 2.3 },
cumulative_losses: { fixed: 0, preview: 3 },
cumulative_misses: { fixed: 0, preview: 13 },
cumulative_shots: { fixed: 0, preview: 309 },
cumulative_spotted: { fixed: 0, preview: 99 },
cumulative_survived_battles: { fixed: 0, preview: 10 },
cumulative_win_and_survived: { fixed: 0, preview: 9 },
cumulative_wins: { fixed: 0, preview: 8 },
cumulative_xp: { fixed: 0, preview: 19403, localeFormat: true },
cumulative_wn8: { fixed: 0, preview: 3103, localeFormat: true },
cumulative_battle_life_time: {
fixed: 0,
preview: 109,
coefficient: 1 / 60,
localeFormat: true,
},
};
@@ -0,0 +1,67 @@
import calculateWN8 from '../calculateWN8';
import {
BlitzkitStats,
CompositeStats,
compositeStatsFormatting,
CompositeStatsKey,
} from './constants';
export function compositeStats(stats: BlitzkitStats, average: BlitzkitStats) {
return {
normalized_damage_dealt: stats.damage_dealt / stats.battles,
normalized_damage_received: stats.damage_received / stats.battles,
normalized_deaths: (stats.battles - stats.survived_battles) / stats.battles,
normalized_frags: stats.frags / stats.battles,
normalized_hits: stats.hits / stats.battles,
normalized_losses: stats.losses / stats.battles,
normalized_misses: (stats.shots - stats.hits) / stats.battles,
normalized_shots: stats.shots / stats.battles,
normalized_spotted: stats.spotted / stats.battles,
normalized_survived_battles: stats.survived_battles / stats.battles,
normalized_win_and_survived: stats.win_and_survived / stats.battles,
normalized_wins: stats.wins / stats.battles,
normalized_xp: stats.xp / stats.battles,
normalized_battle_life_time: stats.battle_life_time / stats.battles,
cumulative_accuracy: stats.hits / stats.shots,
cumulative_battles: stats.battles,
cumulative_capture_points: stats.capture_points,
cumulative_damage_dealt: stats.damage_dealt,
cumulative_damage_ratio: stats.damage_dealt / stats.damage_received,
cumulative_damage_received: stats.damage_received,
cumulative_deaths: stats.battles - stats.survived_battles,
cumulative_dropped_capture_points: stats.dropped_capture_points,
cumulative_frags: stats.frags,
cumulative_hits: stats.hits,
cumulative_inaccuracy: (stats.shots - stats.hits) / stats.shots,
cumulative_kills_to_death_ratio:
stats.frags / (stats.battles - stats.survived_battles),
cumulative_losses: stats.losses,
cumulative_misses: stats.shots - stats.hits,
cumulative_shots: stats.shots,
cumulative_spotted: stats.spotted,
cumulative_survived_battles: stats.survived_battles,
cumulative_win_and_survived: stats.win_and_survived,
cumulative_wins: stats.wins,
cumulative_xp: stats.xp,
cumulative_wn8: calculateWN8(average, stats),
cumulative_battle_life_time: stats.battle_life_time,
} satisfies CompositeStats;
}
export function formatCompositeStat(value: number, key: CompositeStatsKey) {
const formatting = compositeStatsFormatting[key];
const resolvedValue = (formatting.coefficient ?? 1) * value;
return `${
formatting.localeFormat
? resolvedValue.toLocaleString()
: resolvedValue.toFixed(formatting.fixed)
}${formatting.unit ?? ''}`;
}
export function previewCompositeStat(key: CompositeStatsKey) {
const { preview } = compositeStatsFormatting[key];
return formatCompositeStat(preview, key);
}
export * from './constants';
@@ -0,0 +1,18 @@
import { RatingPlayer } from '../../commands/ratingLeaderboard';
import { RatingStats } from '../blitz/getAccountInfo';
import { BkrlSuperset1Entry } from '../streams/bkrl';
export function deltaBkrlBlitzStats(
a: BkrlSuperset1Entry,
b1: RatingStats,
b2: RatingPlayer,
) {
return {
battles: b1.battles - a.battles,
damage: b1.damage_dealt - a.damage,
kills: b1.frags - a.kills,
survived: b1.survived_battles - a.survived,
wins: b1.wins - a.wins,
score: b2.score - a.score,
} satisfies Omit<BkrlSuperset1Entry, 'id'>;
}
@@ -0,0 +1,45 @@
export function deltaTankStats(
tanksA: IndividualTankStats[],
tanksB: IndividualTankStats[],
) {
return tanksB
.map((b) => {
const a =
tanksA.find((a) => a.tank_id === b.tank_id) ?? emptyIndividualTankStats;
return {
account_id: b.account_id,
battle_life_time: b.battle_life_time - a.battle_life_time,
frags: b.frags === null || a.frags === null ? null : b.frags - a.frags,
in_garage: b.in_garage,
in_garage_updated: b.in_garage_updated,
last_battle_time: b.last_battle_time,
mark_of_mastery: b.mark_of_mastery,
max_frags: b.max_frags,
max_xp: b.max_xp,
tank_id: b.tank_id,
all: {
battles: b.all.battles - a.all.battles,
capture_points: b.all.capture_points - a.all.capture_points,
damage_dealt: b.all.damage_dealt - a.all.damage_dealt,
damage_received: b.all.damage_received - a.all.damage_received,
dropped_capture_points:
b.all.dropped_capture_points - a.all.dropped_capture_points,
frags: b.all.frags - a.all.frags,
frags8p: b.all.frags8p - a.all.frags8p,
hits: b.all.hits - a.all.hits,
losses: b.all.losses - a.all.losses,
max_frags: b.all.max_frags,
max_xp: b.all.max_xp,
shots: b.all.shots - a.all.shots,
spotted: b.all.spotted - a.all.spotted,
survived_battles: b.all.survived_battles - a.all.survived_battles,
xp: b.all.xp - a.all.xp,
win_and_survived: b.all.win_and_survived - a.all.win_and_survived,
wins: b.all.wins - a.all.wins,
},
} satisfies IndividualTankStats;
})
.filter((a) => a !== null && a.all.battles > 0) as IndividualTankStats[];
}
@@ -0,0 +1,71 @@
import { tankDefinitions } from '../blitzkit/tankDefinitions';
import calculateWN8 from './calculateWN8';
import sumStats from './sumStats';
export interface StatFilters {
nation?: string;
tier?: number;
tankType?: string;
treeType?: TreeType;
tank?: number;
}
export async function filterStats(
{ diff, order }: DiffedTankStats,
filters: StatFilters,
) {
// tank, if provided, takes priority over all other filters
if (filters.tank) filters = { tank: filters.tank };
const awaitedTankDefinitions = await tankDefinitions;
const awaitedTankAverages = await tankAverages;
const filteredOrder = order.filter((id) => {
const entry = awaitedTankDefinitions[id];
return (
entry &&
(filters.nation === undefined || entry.nation === filters.nation) &&
(filters.tier === undefined || entry.tier === filters.tier) &&
(filters.tankType === undefined || entry.class === filters.tankType) &&
(filters.treeType === undefined ||
(filters.treeType === 'collector' && entry.treeType === 'collector') ||
(filters.treeType === 'premium' && entry.treeType === 'premium') ||
(filters.treeType === 'researchable' &&
entry.treeType === 'researchable')) &&
(filters.tank === undefined || entry.id === filters.tank)
);
});
const stats = sumStats(filteredOrder.map((id) => diff[id]));
const battlesOfTanksWithAverages = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id] ? accumulator + diff[id].battles : accumulator,
0,
);
const battlesOfTanksWithTankDefinition = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankDefinitions[id] ? accumulator + diff[id].battles : accumulator,
0,
);
const supplementary = {
WN8:
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id]
? accumulator +
calculateWN8(awaitedTankAverages[id].all, diff[id]) *
diff[id].battles
: accumulator,
0,
) / battlesOfTanksWithAverages,
tier:
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankDefinitions[id]
? accumulator + awaitedTankDefinitions[id]!.tier * diff[id].battles
: accumulator,
0,
) / battlesOfTanksWithTankDefinition,
} satisfies SupplementaryStats;
return { stats, supplementary, filteredOrder };
}
@@ -0,0 +1,46 @@
export enum BkniPercentile {
SuperMalum,
Malum,
VeryBad,
Bad,
BelowAverage,
Average,
AboveAverage,
Good,
VeryGood,
Unicum,
SuperUnicum,
}
export const BKNI_PERCENTILES = [
{ min: 0, percentile: BkniPercentile.SuperMalum },
{ min: 1, percentile: BkniPercentile.Malum },
{ min: 10, percentile: BkniPercentile.VeryBad },
{ min: 20, percentile: BkniPercentile.Bad },
{ min: 30, percentile: BkniPercentile.BelowAverage },
{ min: 40, percentile: BkniPercentile.Average },
{ min: 60, percentile: BkniPercentile.AboveAverage },
{ min: 70, percentile: BkniPercentile.Good },
{ min: 80, percentile: BkniPercentile.VeryGood },
{ min: 90, percentile: BkniPercentile.Unicum },
{ min: 99, percentile: BkniPercentile.SuperUnicum },
];
export const BKNI_COLORS = {
[BkniPercentile.SuperMalum]: 'tomato',
[BkniPercentile.Malum]: 'orange',
[BkniPercentile.VeryBad]: 'amber',
[BkniPercentile.Bad]: 'yellow',
[BkniPercentile.BelowAverage]: 'lime',
[BkniPercentile.Average]: 'green',
[BkniPercentile.AboveAverage]: 'teal',
[BkniPercentile.Good]: 'cyan',
[BkniPercentile.VeryGood]: 'pink',
[BkniPercentile.Unicum]: 'plum',
[BkniPercentile.SuperUnicum]: 'purple',
} as const;
export default function getBkniPercentile(wn8: number) {
const lastIndex = BKNI_PERCENTILES.findLastIndex(({ min }) => min <= wn8);
return BKNI_PERCENTILES[lastIndex === -1 ? 0 : lastIndex].percentile;
}
@@ -0,0 +1,22 @@
import { TierWeightsRecord } from '../../components/TierWeights';
import { tankDefinitions } from '../blitzkit/tankDefinitions';
import { BlitzStats } from './compositeStats/constants';
export async function getTierWeights(
stats: Record<number, BlitzStats>,
order: number[] = Object.keys(stats).map(parseInt),
) {
const awaitedTankDefinitions = await tankDefinitions;
const tierWeights: TierWeightsRecord = {};
order.forEach((id) => {
const entry = awaitedTankDefinitions[id];
if (!entry) return;
if (!tierWeights[entry.tier]) tierWeights[entry.tier] = 0;
tierWeights[entry.tier]! += stats[id].battles;
});
return tierWeights;
}
@@ -0,0 +1,21 @@
import { Percentile } from './percentiles';
const WN8_PERCENTILES: [number, Percentile][] = [
[0, Percentile.VeryBad],
[300, Percentile.Bad],
[450, Percentile.BelowAverage],
[650, Percentile.Average],
[900, Percentile.AboveAverage],
[1200, Percentile.Good],
[1600, Percentile.VeryGood],
[2000, Percentile.Great],
[2450, Percentile.Unicum],
[2900, Percentile.SuperUnicum],
];
export default function getWN8Percentile(WN8: number) {
const lastIndex = WN8_PERCENTILES.findLastIndex(
([WN8Listing]) => WN8Listing <= WN8,
);
return WN8_PERCENTILES[lastIndex === -1 ? 0 : lastIndex][1];
}
+10
View File
@@ -0,0 +1,10 @@
export * from './calculateWN8';
export * from './compositeStats';
export * from './deltaBkrlBlitzStats';
export * from './deltaTankStats';
export * from './filterStats';
export * from './getBkniPercentile';
export * from './getTierWeights';
export * from './getWN8Percentile';
export * from './percentiles';
export * from './sumStats';
@@ -0,0 +1,13 @@
// http://www.wotinfo.net/en/wn8scale
export enum Percentile {
VeryBad,
Bad,
BelowAverage,
Average,
AboveAverage,
Good,
VeryGood,
Great,
Unicum,
SuperUnicum,
}
+32
View File
@@ -0,0 +1,32 @@
import { BlitzStats } from './compositeStats/constants';
export default function sumStats(allStatsCollection: BlitzStats[]) {
return allStatsCollection.reduce((accumulated, current) => {
function sum(value: (allStats: BlitzStats) => number) {
return value(accumulated) + value(current);
}
function max(value: (allStats: BlitzStats) => number) {
return Math.max(value(accumulated), value(current));
}
return {
battles: sum((a) => a.battles),
wins: sum((a) => a.wins),
capture_points: sum((a) => a.capture_points),
damage_dealt: sum((a) => a.damage_dealt),
damage_received: sum((a) => a.damage_received),
dropped_capture_points: sum((a) => a.dropped_capture_points),
frags: sum((a) => a.frags),
frags8p: sum((a) => a.frags8p),
hits: sum((a) => a.hits),
losses: sum((a) => a.losses),
max_frags: max((a) => a.max_frags),
max_xp: max((a) => a.max_xp),
shots: sum((a) => a.shots),
spotted: sum((a) => a.spotted),
survived_battles: sum((a) => a.survived_battles),
win_and_survived: sum((a) => a.win_and_survived),
xp: sum((a) => a.xp),
} satisfies BlitzStats;
}, emptyAllStats);
}
+142
View File
@@ -0,0 +1,142 @@
import { times } from 'lodash';
import { ReadStream, WriteStream } from './buffer';
export enum BkrlFormat {
Base,
Superset1,
}
export interface BkrlBaseEntry {
id: number;
score: number;
}
export interface BkrlSuperset1Entry {
id: number;
score: number;
battles: number;
wins: number;
survived: number;
damage: number;
kills: number;
}
export type BkrlDiscriminatedEntries =
| {
format: BkrlFormat.Base;
entries: BkrlBaseEntry[];
}
| {
format: BkrlFormat.Superset1;
entries: BkrlSuperset1Entry[];
};
export class BkrlReadStream extends ReadStream {
bkrl() {
this.magic();
const header = this.header();
return this.body(header);
}
body(header: ReturnType<typeof this.header>): BkrlDiscriminatedEntries {
switch (header.format) {
case BkrlFormat.Base: {
return {
format: BkrlFormat.Base,
entries: times(
header.count,
() =>
({
id: this.uint32(),
score: this.uint16(),
}) satisfies BkrlBaseEntry,
),
};
}
case BkrlFormat.Superset1: {
return {
format: BkrlFormat.Superset1,
entries: times(
header.count,
() =>
({
id: this.uint32(),
score: this.uint16(),
battles: this.uint32(),
wins: this.uint32(),
survived: this.uint32(),
damage: this.uint32(),
kills: this.uint32(),
}) satisfies BkrlSuperset1Entry,
),
};
}
}
}
header() {
return {
version: this.uint16(),
format: this.uint8() as BkrlFormat,
count: this.uint32(),
};
}
magic() {
const magic = this.utf8(4);
if (magic !== 'BKRL') {
throw new Error(`Invalid BKRL magic number: "${magic}"`);
}
}
}
export class BkrlWriteStream extends WriteStream {
bkrl(discriminatedEntries: BkrlDiscriminatedEntries) {
this.magic();
this.header(discriminatedEntries);
this.body(discriminatedEntries);
}
body(discriminatedEntries: BkrlDiscriminatedEntries) {
switch (discriminatedEntries.format) {
case BkrlFormat.Base: {
discriminatedEntries.entries.forEach((entry) => {
this.uint32(entry.id);
this.uint16(entry.score);
});
break;
}
case BkrlFormat.Superset1: {
discriminatedEntries.entries.forEach((entry) => {
this.uint32(entry.id);
this.uint16(entry.score);
this.uint32(entry.battles);
this.uint32(entry.wins);
this.uint32(entry.survived);
this.uint32(entry.damage);
this.uint32(entry.kills);
});
break;
}
}
}
header(discriminatedEntries: BkrlDiscriminatedEntries) {
this.uint16(1);
this.uint8(discriminatedEntries.format);
this.uint32(discriminatedEntries.entries.length);
}
magic() {
this.utf8('BKRL');
}
}
+165
View File
@@ -0,0 +1,165 @@
export class ReadStream {
public index = 0;
private dataView: DataView;
private textDecoderAscii = new TextDecoder('ascii');
private textDecoderUtf8 = new TextDecoder('utf8');
constructor(public buffer: ArrayBuffer) {
this.dataView = new DataView(buffer);
}
seek(size: number) {
this.index += size;
}
read(size: number) {
return this.buffer.slice(this.index, this.index + size);
}
consume(size: number) {
const subarray = this.read(size);
this.seek(size);
return subarray;
}
consumeUint8Array(size: number) {
return new Uint8Array(this.consume(size));
}
increment(size: number) {
return (this.index += size) - size;
}
readRemaining() {
return this.read(Number.POSITIVE_INFINITY);
}
consumeRemaining() {
return this.consume(Number.POSITIVE_INFINITY);
}
byte(size: number) {
return this.consume(size);
}
ascii(size: number) {
return this.textDecoderAscii.decode(this.consume(size));
}
utf8(size: number) {
return this.textDecoderUtf8.decode(this.consume(size));
}
int8() {
return this.dataView.getInt8(this.increment(1));
}
int16() {
return this.dataView.getInt16(this.increment(2), true);
}
int32() {
return this.dataView.getInt32(this.increment(4), true);
}
int64() {
return this.dataView.getBigInt64(this.increment(8), true);
}
uint8() {
return this.dataView.getUint8(this.increment(1));
}
uint16() {
return this.dataView.getUint16(this.increment(2), true);
}
uint32() {
return this.dataView.getUint32(this.increment(4), true);
}
uint64() {
return this.dataView.getBigUint64(this.increment(8), true);
}
float32() {
return this.dataView.getFloat32(this.increment(4), true);
}
float64() {
return this.dataView.getFloat64(this.increment(8), true);
}
boolean() {
return this.dataView.getUint8(this.increment(1)) === 1;
}
}
export class WriteStream {
private array: number[] = [];
private textEncoder = new TextEncoder();
get uint8Array() {
return new Uint8Array(this.array);
}
byte(value: Uint8Array) {
this.array.push(...value);
return this;
}
ascii(value: string) {
for (let index = 0; index < value.length; index++) {
this.array.push(value.charCodeAt(index));
}
return this;
}
utf8(value: string) {
this.array.push(...this.textEncoder.encode(value));
return this;
}
private uintN(bytes: number, value: number) {
for (let index = 0; index < bytes; index++) {
this.array.push((value >>> (8 * index)) & 0xff);
}
return this;
}
uint8(value: number) {
this.uintN(1, value);
return this;
}
uint16(value: number) {
this.uintN(2, value);
return this;
}
uint32(value: number) {
this.uintN(4, value);
return this;
}
uint64(value: bigint) {
for (let index = 0n; index < 8n; index++) {
this.uint8(Number((value >> (8n * index)) & 0xffn));
}
return this;
}
private intN(bytes: number, value: number) {
for (let index = 0; index < bytes; index++) {
this.array.push((value >> (8 * index)) & 0xff);
}
return this;
}
int8(value: number) {
this.intN(1, value);
return this;
}
int16(value: number) {
this.intN(2, value);
return this;
}
int32(value: number) {
this.intN(4, value);
return this;
}
int64(value: bigint) {
for (let index = 0n; index < 8n; index++) {
this.int8(Number((value >> (8n * index)) & 0xffn));
}
return this;
}
float32(value: number) {
const array = new Float32Array([value]);
this.array.push(...new Uint8Array(array.buffer));
return this;
}
float64(value: number) {
const array = new Float64Array([value]);
this.array.push(...new Uint8Array(array.buffer));
return this;
}
}
+375
View File
@@ -0,0 +1,375 @@
import { times } from 'lodash';
import { ReadStream, WriteStream } from './buffer';
enum ValueType {
Null,
Boolean,
Uint8,
Uint16,
Uint32,
Uint64,
Int8,
Int16,
Int32,
Int64,
Float32,
Float64,
String,
FastString,
Array,
Object,
}
enum FastStringFormat {
Format8,
Format16,
Format32,
}
export type CdonValue =
| undefined
| null
| boolean
| number
| bigint
| string
| CdonValue[]
| { [key: string]: CdonValue };
export class CdonReadStream extends ReadStream {
cdon() {
this.magic();
const header = this.header();
const body = this.body(header.fastStringFormat, header.stringTable);
return body;
}
magic() {
const magic = this.utf8(4);
if (magic !== 'CDON') {
throw new Error(`Invalid CDON magic number: "${magic}"`);
}
}
header() {
const version = this.uint16();
const fastStringFormat = this.uint8() as FastStringFormat;
return {
version,
fastStringFormat,
stringTable: this.stringTable(fastStringFormat),
};
}
body(
fastStringFormat: FastStringFormat,
stringTable: Record<number, string>,
) {
return this.value(fastStringFormat, stringTable);
}
stringTable(fastStringFormat: FastStringFormat) {
let count: number;
switch (fastStringFormat) {
case FastStringFormat.Format8:
count = this.uint8();
break;
case FastStringFormat.Format16:
count = this.uint16();
break;
case FastStringFormat.Format32:
count = this.uint32();
break;
}
const strings: string[] = [];
for (let index = 0; index < count; index++) {
strings.push(this.string());
}
const table: Record<number, string> = {};
times(count, (index) => (table[index] = strings[index]));
return table;
}
string() {
const length = this.uint32();
const string = this.utf8(length);
return string;
}
value(
fastStringFormat: FastStringFormat,
stringTable: Record<number, string>,
): CdonValue {
const type = this.uint8() as ValueType;
switch (type) {
case ValueType.Null:
return null;
case ValueType.Boolean:
return this.boolean();
case ValueType.Uint8:
return this.uint8();
case ValueType.Uint16:
return this.uint16();
case ValueType.Uint32:
return this.uint32();
case ValueType.Uint64:
return this.uint64();
case ValueType.Int8:
return this.int8();
case ValueType.Int16:
return this.int16();
case ValueType.Int32:
return this.int32();
case ValueType.Int64:
return this.int64();
case ValueType.Float32:
return this.float32();
case ValueType.Float64:
return this.float64();
case ValueType.String:
return this.string();
case ValueType.FastString: {
switch (fastStringFormat) {
case FastStringFormat.Format8:
return stringTable[this.uint8()];
case FastStringFormat.Format16:
return stringTable[this.uint16()];
case FastStringFormat.Format32:
return stringTable[this.uint32()];
}
}
case ValueType.Array:
return times(this.uint32(), () =>
this.value(fastStringFormat, stringTable),
);
case ValueType.Object: {
const count = this.uint32();
const keys = times(
count,
() => this.value(fastStringFormat, stringTable) as string,
);
const values = times(count, () =>
this.value(fastStringFormat, stringTable),
);
const object: Record<string, CdonValue> = {};
keys.forEach((string, index) => (object[string] = values[index]));
return object;
}
default:
throw new Error(`Invalid value type: ${type}`);
}
}
}
export class CdonWriteStream extends WriteStream {
cdon(object: CdonValue) {
this.magic();
const header = this.header(object);
this.body(header.fastStringFormat, object, header.stringTable);
return this;
}
body(
fastStringFormat: FastStringFormat,
object: CdonValue,
stringTable: Map<string, number>,
) {
this.value(fastStringFormat, object, stringTable);
return this;
}
value(
fastStringFormat: FastStringFormat,
object: CdonValue,
stringTable: Map<string, number>,
) {
if (object === null || object === undefined) {
this.uint8(ValueType.Null);
} else if (typeof object === 'boolean') {
this.uint8(ValueType.Boolean);
this.uint8(object ? 1 : 0);
} else if (typeof object === 'number') {
if (object % 1 === 0) {
if (object < 0) {
if (object < -0x80000000) {
this.uint8(ValueType.Int64);
this.int64(BigInt(object));
} else if (object < -0x8000) {
this.uint8(ValueType.Int32);
this.int32(object);
} else if (object < -0x80) {
this.uint8(ValueType.Int16);
this.int16(object);
} else {
this.uint8(ValueType.Int8);
this.int8(object);
}
} else {
if (object > 0x7fffffff) {
this.uint8(ValueType.Uint64);
this.uint64(BigInt(object));
} else if (object > 0x7fff) {
this.uint8(ValueType.Uint32);
this.uint32(object);
} else if (object > 0x7f) {
this.uint8(ValueType.Uint16);
this.uint16(object);
} else {
this.uint8(ValueType.Uint8);
this.uint8(object);
}
}
} else {
if (object < -0x80000000 || object > 0x7fffffff) {
this.uint8(ValueType.Float64);
this.float64(object);
} else {
this.uint8(ValueType.Float32);
this.float32(object);
}
}
} else if (typeof object === 'bigint') {
if (object < 0) {
this.int64(object);
} else {
this.uint64(object);
}
} else if (typeof object === 'string') {
if (stringTable.has(object)) {
this.uint8(ValueType.FastString);
const id = stringTable.get(object)!;
switch (fastStringFormat) {
case FastStringFormat.Format8:
this.uint8(id);
break;
case FastStringFormat.Format16:
this.uint16(id);
break;
case FastStringFormat.Format32:
this.uint32(id);
}
} else {
this.uint8(ValueType.String);
this.string(object);
}
} else if (Array.isArray(object)) {
this.uint8(ValueType.Array);
this.uint32(object.length);
object.forEach((value) =>
this.value(fastStringFormat, value, stringTable),
);
} else {
const entries = Object.entries(object).filter(
([, value]) => value !== undefined,
);
this.uint8(ValueType.Object);
this.uint32(entries.length);
entries.forEach(([key]) =>
this.value(fastStringFormat, key, stringTable),
);
entries.forEach(([, value]) =>
this.value(fastStringFormat, value, stringTable),
);
}
}
header(object: CdonValue) {
this.uint16(1);
return this.stringTable(object);
}
stringTable(object: CdonValue) {
const stringTable = this.buildStringTable(
this.collectStringCounts(object, new Map()),
);
let fastStringFormat: FastStringFormat;
if (stringTable.size <= 0xff) {
fastStringFormat = FastStringFormat.Format8;
this.uint8(FastStringFormat.Format8);
this.uint8(stringTable.size);
} else if (stringTable.size <= 0xffff) {
fastStringFormat = FastStringFormat.Format16;
this.uint8(FastStringFormat.Format16);
this.uint16(stringTable.size);
} else {
fastStringFormat = FastStringFormat.Format32;
this.uint8(FastStringFormat.Format32);
this.uint32(stringTable.size);
}
stringTable.forEach((_index, string) => this.string(string));
return { fastStringFormat, stringTable };
}
buildStringTable(stringCounts: Map<string, number>) {
const stringTable = new Map<string, number>();
stringCounts.forEach((count, string) => {
if (count > 1) stringTable.set(string, stringTable.size);
});
return stringTable;
}
collectStringCounts(object: CdonValue, stringCounts: Map<string, number>) {
if (typeof object === 'string') {
if (stringCounts.has(object)) {
stringCounts.set(object, stringCounts.get(object)! + 1);
} else {
stringCounts.set(object, 1);
}
} else if (object instanceof Array) {
object.forEach((value) => this.collectStringCounts(value, stringCounts));
} else if (object instanceof Object) {
Object.entries(object).forEach((strings) =>
this.collectStringCounts(strings, stringCounts),
);
}
return stringCounts;
}
string(value: string) {
this.uint32(Buffer.byteLength(value));
this.utf8(value);
}
magic() {
this.utf8('CDON');
}
}
+487
View File
@@ -0,0 +1,487 @@
import { times } from 'lodash';
import { Vector3Tuple, Vector4Tuple } from 'three';
import { WindowsReadStream } from './windows';
enum DxgiFormat {
UNKNOWN = 0,
R32G32B32A32_TYPELESS = 1,
R32G32B32A32_FLOAT = 2,
R32G32B32A32_UINT = 3,
R32G32B32A32_SINT = 4,
R32G32B32_TYPELESS = 5,
R32G32B32_FLOAT = 6,
R32G32B32_UINT = 7,
R32G32B32_SINT = 8,
R16G16B16A16_TYPELESS = 9,
R16G16B16A16_FLOAT = 10,
R16G16B16A16_UNORM = 11,
R16G16B16A16_UINT = 12,
R16G16B16A16_SNORM = 13,
R16G16B16A16_SINT = 14,
R32G32_TYPELESS = 15,
R32G32_FLOAT = 16,
R32G32_UINT = 17,
R32G32_SINT = 18,
R32G8X24_TYPELESS = 19,
D32_FLOAT_S8X24_UINT = 20,
R32_FLOAT_X8X24_TYPELESS = 21,
X32_TYPELESS_G8X24_UINT = 22,
R10G10B10A2_TYPELESS = 23,
R10G10B10A2_UNORM = 24,
R10G10B10A2_UINT = 25,
R11G11B10_FLOAT = 26,
R8G8B8A8_TYPELESS = 27,
R8G8B8A8_UNORM = 28,
R8G8B8A8_UNORM_SRGB = 29,
R8G8B8A8_UINT = 30,
R8G8B8A8_SNORM = 31,
R8G8B8A8_SINT = 32,
R16G16_TYPELESS = 33,
R16G16_FLOAT = 34,
R16G16_UNORM = 35,
R16G16_UINT = 36,
R16G16_SNORM = 37,
R16G16_SINT = 38,
R32_TYPELESS = 39,
D32_FLOAT = 40,
R32_FLOAT = 41,
R32_UINT = 42,
R32_SINT = 43,
R24G8_TYPELESS = 44,
D24_UNORM_S8_UINT = 45,
R24_UNORM_X8_TYPELESS = 46,
X24_TYPELESS_G8_UINT = 47,
R8G8_TYPELESS = 48,
R8G8_UNORM = 49,
R8G8_UINT = 50,
R8G8_SNORM = 51,
R8G8_SINT = 52,
R16_TYPELESS = 53,
R16_FLOAT = 54,
D16_UNORM = 55,
R16_UNORM = 56,
R16_UINT = 57,
R16_SNORM = 58,
R16_SINT = 59,
R8_TYPELESS = 60,
R8_UNORM = 61,
R8_UINT = 62,
R8_SNORM = 63,
R8_SINT = 64,
A8_UNORM = 65,
R1_UNORM = 66,
R9G9B9E5_SHAREDEXP = 67,
R8G8_B8G8_UNORM = 68,
G8R8_G8B8_UNORM = 69,
BC1_TYPELESS = 70,
BC1_UNORM = 71,
BC1_UNORM_SRGB = 72,
BC2_TYPELESS = 73,
BC2_UNORM = 74,
BC2_UNORM_SRGB = 75,
BC3_TYPELESS = 76,
BC3_UNORM = 77,
BC3_UNORM_SRGB = 78,
BC4_TYPELESS = 79,
BC4_UNORM = 80,
BC4_SNORM = 81,
BC5_TYPELESS = 82,
BC5_UNORM = 83,
BC5_SNORM = 84,
B5G6R5_UNORM = 85,
B5G5R5A1_UNORM = 86,
B8G8R8A8_UNORM = 87,
B8G8R8X8_UNORM = 88,
R10G10B10_XR_BIAS_A2_UNORM = 89,
B8G8R8A8_TYPELESS = 90,
B8G8R8A8_UNORM_SRGB = 91,
B8G8R8X8_TYPELESS = 92,
B8G8R8X8_UNORM_SRGB = 93,
BC6H_TYPELESS = 94,
BC6H_UF16 = 95,
BC6H_SF16 = 96,
BC7_TYPELESS = 97,
BC7_UNORM = 98,
BC7_UNORM_SRGB = 99,
AYUV = 100,
Y410 = 101,
Y416 = 102,
NV12 = 103,
P010 = 104,
P016 = 105,
'420_OPAQUE' = 106,
YUY2 = 107,
Y210 = 108,
Y216 = 109,
NV11 = 110,
AI44 = 111,
IA44 = 112,
P8 = 113,
A8P8 = 114,
B4G4R4A4_UNORM = 115,
P208 = 130,
V208 = 131,
V408 = 132,
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE,
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE,
FORCE_UINT = 0xffffffff,
}
enum DdsDimension {
UNKNOWN = 0,
BUFFER = 1,
TEXTURE1D = 2,
TEXTURE2D = 3,
TEXTURE3D = 4,
}
enum DdsdFlags {
CAPS = 0x1,
HEIGHT = 0x2,
WIDTH = 0x4,
PITCH = 0x8,
PIXELFORMAT = 0x1000,
MIPMAPCOUNT = 0x20000,
LINEARSIZE = 0x80000,
DEPTH = 0x800000,
}
enum DdpfFlags {
ALPHA = 0x1,
FOURCC = 0x4,
RGB = 0x40,
YUV = 0x200,
LUMINANCE = 0x20000,
}
enum DdsCaps {
COMPLEX = 0x8,
MIPMAP = 0x400000,
TEXTURE = 0x1000,
}
enum DdsCaps2 {
CUBEMAP = 0x200,
CUBEMAP_POSITIVEX = 0x400,
CUBEMAP_NEGATIVEX = 0x800,
CUBEMAP_POSITIVEY = 0x1000,
CUBEMAP_NEGATIVEY = 0x2000,
CUBEMAP_POSITIVEZ = 0x4000,
CUBEMAP_NEGATIVEZ = 0x8000,
VOLUME = 0x200000,
}
export class DdsReadStream extends WindowsReadStream {
async dds() {
this.magicNumber();
const header = this.header();
const dxgiFormat = this.resolveDxgiFormat(header);
switch (dxgiFormat) {
case DxgiFormat.BC1_TYPELESS:
case DxgiFormat.BC1_UNORM:
case DxgiFormat.BC1_UNORM_SRGB: {
return this.iterateBlocks(header.width, header.height, () => {
const colorInterpolations = this.bc1ColorInterpolations();
const colorKeys = this.bc1ColorKeys();
return (index) => colorInterpolations[colorKeys[index]];
});
}
case DxgiFormat.BC2_TYPELESS:
case DxgiFormat.BC2_UNORM:
case DxgiFormat.BC2_UNORM_SRGB: {
return this.iterateBlocks(header.width, header.height, () => {
const alphaInterpolations = this.bc2AlphaInterpolations();
const colorInterpolations = this.bc2ColorInterpolations();
const colorKeys = this.bc1ColorKeys();
return (index) => {
return [
...colorInterpolations[colorKeys[index]],
alphaInterpolations[index],
];
};
});
}
case DxgiFormat.BC3_TYPELESS:
case DxgiFormat.BC3_UNORM:
case DxgiFormat.BC3_UNORM_SRGB: {
return this.iterateBlocks(header.width, header.height, () => {
const alphaInterpolations = this.bc3AlphaInterpolations();
const alphaKeys = this.bc3AlphaKeys();
const colorInterpolations = this.bc2ColorInterpolations();
const colorKeys = this.bc1ColorKeys();
return (index) => [
...colorInterpolations[colorKeys[index]],
alphaInterpolations[alphaKeys[index]],
];
});
}
default:
throw new TypeError(
`Unsupported DXGI format: ${DxgiFormat[dxgiFormat]} (${dxgiFormat})`,
);
}
}
magicNumber() {
const magic = this.dword();
if (magic !== 0x20534444) {
throw new Error(`Invalid DDS magic number: 0x${magic.toString(16)}`);
}
return magic;
}
pixelFormat() {
return {
size: this.dword(),
flags: this.dword() as DdpfFlags,
fourCC: this.ascii(4),
rgbBitCount: this.dword(),
rBitMask: this.dword(),
gBitMask: this.dword(),
bBitMask: this.dword(),
aBitMask: this.dword(),
};
}
header() {
return {
size: this.dword(),
flags: this.dword() as DdsdFlags,
height: this.dword(),
width: this.dword(),
pitchOrLinearSize: this.dword(),
depth: this.dword(),
mipMapCount: this.dword(),
reserved1: times(11, () => this.dword()),
pf: this.pixelFormat(),
caps: this.dword() as DdsCaps,
caps2: this.dword() as DdsCaps2,
caps3: this.dword(),
caps4: this.dword(),
reserved2: times(1, () => this.dword()),
};
}
headerDxt10() {
return {
dxgiFormat: this.dword() as DxgiFormat,
resourceDimension: this.dword() as DdsDimension,
miscFlag: this.dword(),
arraySize: this.dword(),
miscFlags2: this.dword(),
};
}
resolveDxgiFormat(header: ReturnType<typeof this.header>) {
switch (header.pf.fourCC) {
case 'DXT1':
return DxgiFormat.BC1_UNORM;
case 'DXT3':
return DxgiFormat.BC2_UNORM;
case 'DXT5':
return DxgiFormat.BC3_UNORM;
case 'DX10':
return this.headerDxt10().dxgiFormat;
default:
throw new TypeError(`Unsupported FourCC: ${header.pf.fourCC}`);
}
}
assetFactor4(width: number, height: number) {
if (width % 4 !== 0 || height % 4 !== 0) {
throw new RangeError('Width and height must be divisible by 4');
}
}
iterateBlocks(
width: number,
height: number,
blockIterator: () => (index: number) => Vector4Tuple,
) {
this.assetFactor4(width, height);
const data = Buffer.alloc(width * height * 4);
const blocksX = width / 4;
const blocksY = height / 4;
times(blocksX * blocksY, (index) => {
const blockPosX = (index % blocksX) * 4;
const blockPosY = Math.floor(index / blocksX) * 4;
const pixelIterator = blockIterator();
times(16, (pixelIndex) => {
const pixelPosX = pixelIndex % 4;
const pixelPosY = Math.floor(pixelIndex / 4);
const x = blockPosX + pixelPosX;
const y = blockPosY + pixelPosY;
const bufferIndex = 4 * (y * width + x);
pixelIterator(pixelIndex).forEach((channel, index) => {
data[bufferIndex + index] = Math.round(channel * 255);
});
});
});
return { data, width, height, channels: 4 as const };
}
readR5G6B5Int() {
return new DataView(this.read(2)).getUint16(0, true);
}
R5G6B5() {
const buffer = this.consumeUint8Array(2);
const r = (buffer[1] & 0b11111000) >>> 3;
const g = ((buffer[1] & 0b111) << 3) | ((buffer[0] & 0b11100000) >>> 5);
const b = buffer[0] & 0b11111;
return [r / 31, g / 63, b / 31] as Vector3Tuple;
}
R5G6B5A0() {
return [...this.R5G6B5(), 1] as Vector4Tuple;
}
A8() {
return this.uint8() / 255;
}
bc1ColorInterpolations() {
const int0 = this.readR5G6B5Int();
const color0 = this.R5G6B5A0();
const int1 = this.readR5G6B5Int();
const color1 = this.R5G6B5A0();
const alpha = int0 < int1;
const color2 = alpha
? (color0.map(
(channel0, index) => (channel0 + color1[index]) / 2,
) as Vector4Tuple)
: (color0.map(
(channel0, index) => (2 * channel0 + color1[index]) / 3,
) as Vector4Tuple);
const color3 = alpha
? ([0, 0, 0, 0] as Vector4Tuple)
: (color0.map(
(channel0, index) => (channel0 + 2 * color1[index]) / 3,
) as Vector4Tuple);
return {
[0b00]: color0,
[0b01]: color1,
[0b10]: color2,
[0b11]: color3,
} as Record<number, Vector4Tuple>;
}
bc1ColorKeys() {
const buffer = this.consumeUint8Array(4);
const d = (buffer[0] & 0b11000000) >>> 6;
const c = (buffer[0] & 0b110000) >>> 4;
const b = (buffer[0] & 0b1100) >>> 2;
const a = buffer[0] & 0b11;
const h = (buffer[1] & 0b11000000) >>> 6;
const g = (buffer[1] & 0b110000) >>> 4;
const f = (buffer[1] & 0b1100) >>> 2;
const e = buffer[1] & 0b11;
const l = (buffer[2] & 0b11000000) >>> 6;
const k = (buffer[2] & 0b110000) >>> 4;
const j = (buffer[2] & 0b1100) >>> 2;
const i = buffer[2] & 0b11;
const p = (buffer[3] & 0b11000000) >>> 6;
const o = (buffer[3] & 0b110000) >>> 4;
const n = (buffer[3] & 0b1100) >>> 2;
const m = buffer[3] & 0b11;
return [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p];
}
bc2AlphaInterpolations() {
const buffer = this.consumeUint8Array(8);
const d = (buffer[1] & 0b11110000) >>> 4;
const c = buffer[1] & 0b1111;
const b = (buffer[0] & 0b11110000) >>> 4;
const a = buffer[0] & 0b1111;
const h = (buffer[3] & 0b11110000) >>> 4;
const g = buffer[3] & 0b1111;
const f = (buffer[2] & 0b11110000) >>> 4;
const e = buffer[2] & 0b1111;
const l = (buffer[5] & 0b11110000) >>> 4;
const k = buffer[5] & 0b1111;
const j = (buffer[4] & 0b11110000) >>> 4;
const i = buffer[4] & 0b1111;
const p = (buffer[7] & 0b11110000) >>> 4;
const o = buffer[7] & 0b1111;
const n = (buffer[6] & 0b11110000) >>> 4;
const m = buffer[6] & 0b1111;
return [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p].map(
(alpha) => alpha / 15,
);
}
bc2ColorInterpolations() {
const color0 = this.R5G6B5();
const color1 = this.R5G6B5();
const color2 = color0.map(
(channel0, index) => (2 * channel0 + color1[index]) / 3,
) as Vector3Tuple;
const color3 = color0.map(
(channel0, index) => (channel0 + 2 * color1[index]) / 3,
) as Vector3Tuple;
return {
[0b00]: color0,
[0b01]: color1,
[0b10]: color2,
[0b11]: color3,
} as Record<number, Vector3Tuple>;
}
bc3AlphaInterpolations() {
const alpha0 = this.A8();
const alpha1 = this.A8();
const full = alpha0 > alpha1;
return {
[0b000]: alpha0,
[0b001]: alpha1,
...(full
? {
[0b010]: (6 * alpha0 + 1 * alpha1) / 7,
[0b011]: (5 * alpha0 + 2 * alpha1) / 7,
[0b100]: (4 * alpha0 + 3 * alpha1) / 7,
[0b101]: (3 * alpha0 + 4 * alpha1) / 7,
[0b110]: (2 * alpha0 + 5 * alpha1) / 7,
[0b111]: (1 * alpha0 + 6 * alpha1) / 7,
}
: {
[0b010]: (4 * alpha0 + 1 * alpha1) / 5,
[0b011]: (3 * alpha0 + 2 * alpha1) / 5,
[0b100]: (2 * alpha0 + 3 * alpha1) / 5,
[0b101]: (1 * alpha0 + 4 * alpha1) / 5,
[0b110]: 0,
[0b111]: 255,
}),
} as Record<number, number>;
}
bc3AlphaKeys() {
const buffer = this.consumeUint8Array(6);
const h = (buffer[2] & 0b11100000) >>> 5;
const g = (buffer[2] & 0b11100) >>> 2;
const f = ((buffer[2] & 0b11) << 1) | ((buffer[1] & 0b10000000) >>> 7);
const e = (buffer[1] & 0b1110000) >>> 4;
const d = (buffer[1] & 0b1110) >>> 1;
const c = ((buffer[1] & 0b1) << 2) | ((buffer[0] & 0b11000000) >>> 6);
const b = (buffer[0] & 0b111000) >>> 3;
const a = buffer[0] & 0b111;
const p = (buffer[5] & 0b11100000) >>> 5;
const o = (buffer[5] & 0b11100) >>> 2;
const n = ((buffer[5] & 0b11) << 1) | ((buffer[4] & 0b10000000) >>> 7);
const m = (buffer[4] & 0b1110000) >>> 4;
const l = (buffer[4] & 0b1110) >>> 1;
const k = ((buffer[4] & 0b1) << 2) | ((buffer[3] & 0b11000000) >>> 6);
const j = (buffer[3] & 0b111000) >>> 3;
const i = buffer[3] & 0b111;
return [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p];
}
}
+51
View File
@@ -0,0 +1,51 @@
import { times } from 'lodash';
import { ReadStream, WriteStream } from './buffer';
export class DidsReadStream extends ReadStream {
dids() {
this.magic();
this.header();
return this.body();
}
magic() {
const magic = this.utf8(4);
if (magic !== 'DIDS') {
throw new Error(`Invalid DIDS magic number: "${magic}"`);
}
}
header() {
return {
version: this.uint16(),
};
}
body() {
const count = this.uint32();
return times(count, () => this.uint32());
}
}
export class DidsWriteStream extends WriteStream {
dids(ids: number[]) {
this.magic();
this.header();
this.body(ids);
return this;
}
magic() {
this.utf8('DIDS');
}
header() {
this.uint16(1);
}
body(ids: number[]) {
this.uint32(ids.length);
ids.forEach((id) => this.uint32(id));
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from './bkrl';
export * from './buffer';
export * from './cdon';
export * from './dds';
export * from './dids';
export * from './pvr';
export * from './rts';
export * from './sc2';
export * from './scg';
export * from './scpg';
export * from './windows';
+239
View File
@@ -0,0 +1,239 @@
import { clamp, isEqual, times } from 'lodash';
import { ReadStream } from './buffer';
export enum PvrFlags {
NoFlag = 0,
PreMultiplied = 0x02,
}
export enum PvrPixelFormat {
PVRTC_2bpp_RGB = 0,
PVRTC_2bpp_RGBA = 1,
PVRTC_4bpp_RGB = 2,
PVRTC_4bpp_RGBA = 3,
PVRTC_II_2bpp = 4,
PVRTC_II_4bpp = 5,
ETC1 = 6,
DXT1_BC1 = 7,
DXT2 = 8,
DXT3_BC2 = 9,
DXT4 = 10,
DXT5_BC3 = 11,
BC4 = 12,
BC5 = 13,
BC6 = 14,
BC7 = 15,
UYVY = 16,
YUY2 = 17,
BW1bpp = 18,
R9G9B9E5_SharedExponent = 19,
RGBG8888 = 20,
GRGB8888 = 21,
ETC2_RGB = 22,
ETC2_RGBA = 23,
ETC2_RGB_A1 = 24,
EAC_R11 = 25,
EAC_RG11 = 26,
ASTC_4x4 = 27,
ASTC_5x4 = 28,
ASTC_5x5 = 29,
ASTC_6x5 = 30,
ASTC_6x6 = 31,
ASTC_8x5 = 32,
ASTC_8x6 = 33,
ASTC_8x8 = 34,
ASTC_10x5 = 35,
ASTC_10x6 = 36,
ASTC_10x8 = 37,
ASTC_10x10 = 38,
ASTC_12x10 = 39,
ASTC_12x12 = 40,
ASTC_3x3x3 = 41,
ASTC_4x3x3 = 42,
ASTC_4x4x3 = 43,
ASTC_4x4x4 = 44,
ASTC_5x4x4 = 45,
ASTC_5x5x4 = 46,
ASTC_5x5x5 = 47,
ASTC_6x5x5 = 48,
ASTC_6x6x5 = 49,
ASTC_6x6x6 = 50,
BASISU_ETC1S = 51,
BASISU_UASTC = 52,
RGBM = 53,
RGBD = 54,
}
enum PvrColorSpace {
LinearRGB = 0,
sRGB = 1,
}
enum PvrChannelType {
UnsignedByteNormalised = 0,
SignedByteNormalised = 1,
UnsignedByte = 2,
SignedByte = 3,
UnsignedShortNormalised = 4,
SignedShortNormalised = 5,
UnsignedShort = 6,
SignedShort = 7,
UnsignedIntegerNormalised = 8,
SignedIntegerNormalised = 9,
UnsignedInteger = 10,
SignedInteger = 11,
SignedFloat = 12,
UnsignedFloat = 13,
}
enum ResolvedBitRatePixelFormat {
R4G4B4A4,
R5G6B5A0,
}
export class PvrReadStream extends ReadStream {
pvr() {
const header = this.header();
this.metadata();
if (typeof header.pixelFormat === 'number') {
switch (header.pixelFormat) {
default:
throw new TypeError(
`Unhandled pixel format ${PvrPixelFormat[header.pixelFormat]} (${
header.pixelFormat
})`,
);
}
} else {
const resolvedPixelFormat = this.resolveBitRatePixelFormat(
header.pixelFormat,
);
const pixelCount = header.width * header.height;
switch (resolvedPixelFormat) {
case ResolvedBitRatePixelFormat.R4G4B4A4: {
const data = Buffer.alloc(pixelCount * 4);
times(pixelCount, (index) => {
const buffer = this.consumeUint8Array(2);
const bufferIndex = index * 4;
const r = ((buffer[1] & 0b11110000) >>> 4) / 15;
const g = (buffer[1] & 0b1111) / 15;
const b = ((buffer[0] & 0b11110000) >>> 4) / 15;
const a = (buffer[0] & 0b1111) / 15;
data[bufferIndex] = Math.round(clamp(r * 255, 0, 255));
data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255));
data[bufferIndex + 2] = Math.round(clamp(b * 255, 0, 255));
data[bufferIndex + 3] = Math.round(clamp(a * 255, 0, 255));
});
return {
data,
width: header.width,
height: header.height,
channels: 4 as const,
};
}
case ResolvedBitRatePixelFormat.R5G6B5A0: {
const data = Buffer.alloc(pixelCount * 3);
times(pixelCount, (index) => {
const buffer = this.consumeUint8Array(2);
const bufferIndex = index * 4;
const r = ((buffer[1] & 0b11111000) >>> 3) / 31;
const g =
((buffer[1] & 0b111) << 3) |
(((buffer[0] & 0b11100000) >>> 5) / 31);
const b = buffer[0] & 0b11111;
data[bufferIndex] = Math.round(clamp(r * 255, 0, 255));
data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255));
data[bufferIndex + 2] = Math.round(clamp(b * 255, 0, 255));
});
return {
data,
width: header.width,
height: header.height,
channels: 3 as const,
};
}
default:
throw new TypeError(
`Unhandled resolved pixel format ${resolvedPixelFormat} (${header.pixelFormat})`,
);
}
}
}
header() {
const header = {
version: this.ascii(4),
flags: this.uint32() as PvrFlags,
pixelFormat: this.pixelFormat(),
colorSpace: this.uint32() as PvrColorSpace,
channelType: this.uint32() as PvrChannelType,
height: this.uint32(),
width: this.uint32(),
depth: this.uint32(),
surfaces: this.uint32(),
faces: this.uint32(),
mipMaps: this.uint32(),
metadataSize: this.uint32(),
};
if (header.version !== 'PVR\x03') throw new TypeError('Endian mismatch');
return header;
}
pixelFormat() {
this.seek(4);
const usesPresetFormat = this.uint32() === 0;
this.seek(-8);
return usesPresetFormat
? (Number(this.uint64()) as PvrPixelFormat)
: {
order: this.ascii(4),
bitRates: times(4, () => this.uint8()),
};
}
resolveBitRatePixelFormat(
pixelFormat: Exclude<ReturnType<typeof this.pixelFormat>, number>,
) {
if (pixelFormat.order === 'rgba') {
if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) {
return ResolvedBitRatePixelFormat.R4G4B4A4;
} else
throw new TypeError(
`Unhandled rgba bit rate ${pixelFormat.bitRates.join(', ')}`,
);
} else if (pixelFormat.order === 'rgb\0') {
if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) {
return ResolvedBitRatePixelFormat.R5G6B5A0;
} else
throw new TypeError(
`Unhandled rgb bit rate ${pixelFormat.bitRates.join(', ')}`,
);
} else {
throw new TypeError(`Unhandled pixel order ${pixelFormat.order}`);
}
}
metadata() {
const fourCC = this.ascii(4);
const key = this.uint32();
const dataSize = this.uint32();
const data = this.consume(dataSize);
return {
fourCC,
key,
dataSize,
data,
};
}
}
+183
View File
@@ -0,0 +1,183 @@
import { times } from 'lodash';
import { IndividualTankStats } from '../../types/tanksStats';
import { BlitzStats } from '../statistics/compositeStats/constants';
import { ReadStream, WriteStream } from './buffer';
export interface BlitzkitTankStats extends BlitzStats {
id: number;
battle_life_time: number;
last_battle_time: number;
mark_of_mastery: number;
}
export class RtsmReadStream extends ReadStream {
rtsm() {
this.magic();
this.header();
return this.body();
}
magic() {
const magic = this.utf8(4);
if (magic !== 'RTSM') {
throw new Error(`Invalid RTSM magic number: "${magic}"`);
}
}
header() {
return {
version: this.uint16(),
};
}
body() {
return {
time: this.uint32(),
bases: this.uint8(),
};
}
}
export class RtsmWriteStream extends WriteStream {
rtsm(bases: number) {
this.magic();
this.header();
this.body(bases);
return this;
}
magic() {
this.utf8('RTSM');
}
header() {
this.uint16(1);
}
body(base: number) {
this.uint32(Math.round(Date.now() / 1000));
this.uint8(base);
}
}
export class RtscReadStream extends ReadStream {
rtsc() {
this.magic();
const header = this.header();
return this.body(header.playerCount);
}
magic() {
const magic = this.utf8(4);
if (magic !== 'RTSC') {
throw new Error(`Invalid RTSC magic number: "${magic}"`);
}
}
header() {
return {
version: this.uint16(),
playerCount: this.uint32(),
};
}
body(playerCount: number) {
return times(playerCount, () => this.player());
}
player() {
const id = this.uint32();
const tankCount = this.uint16();
const tanks = times(tankCount, () => this.tank());
return {
id,
tankCount,
tanks,
};
}
tank() {
return {
id: this.uint32(),
battle_life_time: this.uint32(),
last_battle_time: this.uint32(),
mark_of_mastery: this.uint8(),
battles: this.uint32(),
capture_points: this.uint32(),
damage_dealt: this.uint32(),
damage_received: this.uint32(),
dropped_capture_points: this.uint32(),
frags: this.uint32(),
frags8p: this.uint32(),
hits: this.uint32(),
losses: this.uint32(),
max_frags: this.uint8(),
max_xp: this.uint16(),
shots: this.uint32(),
spotted: this.uint32(),
survived_battles: this.uint32(),
win_and_survived: this.uint32(),
wins: this.uint32(),
xp: this.uint32(),
} satisfies BlitzkitTankStats;
}
}
export interface RtscWritePlayer {
id: number;
tanks: IndividualTankStats[];
}
export class RtscWriteStream extends WriteStream {
rtsc(players: RtscWritePlayer[]) {
this.magic();
this.header(players.length);
this.body(players);
return this;
}
magic() {
this.utf8('RTSC');
}
header(playerCount: number) {
this.uint16(1);
this.uint32(playerCount);
}
body(players: RtscWritePlayer[]) {
players.forEach((player) => {
this.uint32(player.id);
this.uint16(player.tanks.length);
player.tanks.forEach((tank) => {
this.uint32(tank.tank_id);
this.uint32(tank.battle_life_time);
this.uint32(tank.last_battle_time);
this.uint8(tank.mark_of_mastery);
this.uint32(tank.all.battles);
this.uint32(tank.all.capture_points);
this.uint32(tank.all.damage_dealt);
this.uint32(tank.all.damage_received);
this.uint32(tank.all.dropped_capture_points);
this.uint32(tank.all.frags);
this.uint32(tank.all.frags8p);
this.uint32(tank.all.hits);
this.uint32(tank.all.losses);
this.uint8(tank.all.max_frags);
this.uint16(tank.all.max_xp);
this.uint32(tank.all.shots);
this.uint32(tank.all.spotted);
this.uint32(tank.all.survived_battles);
this.uint32(tank.all.win_and_survived);
this.uint32(tank.all.wins);
this.uint32(tank.all.xp);
});
});
}
}
+321
View File
@@ -0,0 +1,321 @@
import { Vector3Tuple, Vector4Tuple } from 'three';
import { ScpgReadStream } from './scpg';
export interface SC2 {
'#dataNodes': DataNode[];
'#hierarchy': Hierarchy[];
'#sceneComponents': SceneComponents;
}
type DataNode = {
'##name': 'NMaterial';
'#id': ArrayBuffer;
materialName: string;
parentMaterialKey?: bigint;
qualityGroup?: string;
fxName?: string;
textures?: Textures;
} & (
| ({
configCount: number;
} & Record<`configArchive_${number}`, ConfigArchive>)
| {
configCount: undefined;
}
);
interface ConfigArchive {
configName: string;
fxName: string;
properties: Properties;
textureSampleStates: TextureSampleStates;
textures: Textures;
customCullMode?: number;
enabledPresets?: EnabledPresets;
flags?: Flags;
}
interface EnabledPresets {
AlphaTest: boolean;
}
interface Flags {
AMBIENT_ATTENUATION_BOX: number;
BLENDING: number;
}
interface Properties {
alphatestThreshold: ArrayBuffer;
attenuationBoxHalfSize: ArrayBuffer;
attenuationBoxSmoothness: ArrayBuffer;
baseColorFactor?: ArrayBuffer;
decalTileCoordScale?: ArrayBuffer;
inGlossiness: ArrayBuffer;
inSpecularity: ArrayBuffer;
metalFresnelReflectance: ArrayBuffer;
metallicRoughnessFactor?: ArrayBuffer;
}
interface TextureSampleStates {
albedo: number;
baseColorMap: number;
baseNormalMap: number;
baseRMMap: number;
decalmask?: number;
maskMap: number;
miscMap: number;
normalmap: number;
}
export interface Textures {
albedo: string;
baseColorMap?: string;
baseNormalMap?: string;
baseRMMap?: string;
decalmask?: string;
maskMap?: string;
miscMap?: string;
normalmap?: string;
}
export interface Hierarchy {
'##name': 'Entity';
'#hierarchy'?: Hierarchy[];
components: Components;
flags: number;
id: bigint;
name: string;
}
// 0001, 0002, 0003...: Component
type Components = {
count: number;
} & Record<string, Component>;
// 0001, 0002, 0003...
type RoBatches = Record<
string,
{
'##name': 'RenderBatch';
'rb.aabbox': RbAabbox;
'rb.classname': 'RenderBatch';
'rb.datasource': bigint;
'rb.nmatname': bigint;
'rb.sortingKey': number;
}
>;
interface RbAabbox {
minimum: number[];
maximum: number[];
}
export type Component =
| {
'comp.typename': 'TransformComponent';
'tc.localRotation': Vector4Tuple;
'tc.localScale': Vector3Tuple;
'tc.localTranslation': Vector3Tuple;
'tc.worldRotation': Vector4Tuple;
'tc.worldScale': Vector3Tuple;
'tc.worldTranslation': Vector3Tuple;
}
| {
'comp.typename': 'RenderComponent';
'rc.renderObj': {
'##name': 'Mesh';
'ro.batchCount': number;
'ro.batches': RoBatches;
'ro.debugflags': number;
'ro.flags': number;
'ro.notShadowOnly': boolean;
'ro.sOclCull': boolean;
'ro.sOclIndex': number;
} & Record<`rb${number}.${'lodIndex' | 'switchIndex'}`, number>;
}
| {
'comp.typename': 'SlotComponent';
'sc.attachmentRotation': number[];
'sc.attachmentScale': number[];
'sc.attachmentTranslation': number[];
'sc.configFilePath': string;
'sc.slotName': string;
'sc.template': string;
'sc.typeFiltersCount': number;
}
| {
'comp.typename': 'LodComponent';
'lc.loddist': Record<`distance${number}`, number>;
}
| {
'comp.typename': 'DecorItemComponent';
drawOrder: number;
shouldApplyCamo: boolean;
vanishTargetAlpha: number;
vanishWhenArmorShown: boolean;
}
| {
'comp.typename': 'NewSlotComponent';
slotItemsCount: number;
slots: {
[key: string]: {
configFilePath: string;
name: string;
transform: {
position: Vector3Tuple;
scale: Vector3Tuple;
rotation: Vector4Tuple;
};
};
};
}
| {
'comp.typename': 'ScenarioComponent';
'scenario.scriptId': string;
}
| ({
'comp.typename': 'StateSwitcherComponent';
'ssc.activeState': number;
'ssc.statesCount': number;
} & Record<`ssc.state${number}`, string>)
| {
'comp.typename': 'CustomPropertiesComponent';
'cpc.properties.archive': {
'editor.donotremove': number;
};
}
| ({
'comp.typename': 'ActionComponent';
'ac.actionCount': number;
} & Record<
string, // 0000, 0001, ...
{
'act.delay': number;
'act.delayVariation': number;
'act.entityName': string;
'act.event': number;
'act.eventToTrigger': string;
'act.motionFrameTime': number;
'act.motionSpeed': number;
'act.stateActivated': number;
'act.stopAfterNRepeats': number;
'act.stopWhenEmpty': boolean;
'act.switchIndex': number;
'act.type': number;
'act.userEventId': string;
}
>)
| {
'comp.typename': 'ParticleEffectComponent';
'pe.clearOnRestart': boolean;
'pe.effectDuration': number;
'pe.emitters': {
'##name': string;
'emitter.data': {
'emitter.id': `${number}`;
'emitter.quality': string;
}[];
'emitter.position': Vector3Tuple;
'emitter.rotation': Vector4Tuple;
'emitter.scale': Vector3Tuple;
}[];
'pe.inheritScale': boolean;
'pe.nestedEmitters': boolean;
'pe.repeatsCount': number;
'pe.startFromTime': number;
'pe.stopWhenEmpty': boolean;
'pe.version': number;
'ro.flags': number;
}
| {
'comp.typename': 'TankElementComponent';
isDestructible: boolean;
shouldCastShadow: boolean;
shouldGenerateSilhouette: boolean;
}
| {
'comp.typename': 'MotionComponent';
'motion.filepath': string;
'motion.playbackRate': number;
'simpleMotion.repeatsCount': number;
}
| {
'comp.typename': 'SkeletonComponent';
joints: Record<
string, // 0000, 0001, ...
{
'joint.bbox.max': Vector3Tuple;
'joint.bbox.min': Vector3Tuple;
'joint.bindPose': [
Vector4Tuple,
Vector4Tuple,
Vector4Tuple,
Vector4Tuple,
];
'joint.invBindPose': [
Vector4Tuple,
Vector4Tuple,
Vector4Tuple,
Vector4Tuple,
];
'joint.name': string;
'joint.parentIndex': number;
'joint.uid': string;
}
>;
jointsCount: number;
}
| {
'comp.typename': 'AnimationComponent';
animation: bigint;
animationTimeScale: number;
repeatsCount: number;
}
| {
'comp.typename': 'SkeletonJointBindingComponent';
count: number;
map: Record<
string, // 0000, 0001, ...
{
Uid: string;
jointUid: string;
}
>;
};
interface SceneComponents {
count: number;
}
export class Sc2ReadStream extends ScpgReadStream {
header() {
return {
name: this.ascii(4),
version: this.uint32(),
nodeCount: this.uint32(),
};
}
versionTags() {
return this.ka();
}
descriptor() {
const size = this.uint32();
const fileType = this.uint32();
// other felids that we don't have any info about
this.seek(size - 4);
return { size, fileType };
}
sc2() {
this.header();
this.versionTags();
this.descriptor();
return this.ka() as SC2;
}
}
+135
View File
@@ -0,0 +1,135 @@
import { ScpgReadStream, VertexAttribute } from './scpg';
interface PolygonGroupRaw {
'##name': 'PolygonGroup';
'#id': ArrayBuffer;
cubeTextureCoordCount: number;
indexCount: number;
indexFormat: 0 | 1;
indices: ArrayBuffer;
packing: 0 | 1;
primitiveCount: number;
rhi_primitiveType: 1 | 2 | 10;
textureCoordCount: number;
vertexCount: number;
vertexFormat: number;
vertices: ArrayBuffer;
}
type BlitzkitVertex = { attribute: VertexAttribute; value: number[] }[];
interface BlitzkitPolygonGroup {
vertices: BlitzkitVertex[];
indices: number[];
}
const vertexAttributesArray = Object.values(VertexAttribute).filter(
(value) => typeof value === 'number',
);
export const vertexAttributeVectorSizes = {
[VertexAttribute.VERTEX]: 3,
[VertexAttribute.NORMAL]: 3,
[VertexAttribute.COLOR]: 1,
[VertexAttribute.TEXCOORD0]: 2,
[VertexAttribute.TEXCOORD1]: 2,
[VertexAttribute.TEXCOORD2]: 2,
[VertexAttribute.TEXCOORD3]: 2,
[VertexAttribute.TANGENT]: 3,
[VertexAttribute.BINORMAL]: 3,
[VertexAttribute.HARD_JOINTINDEX]: 1,
[VertexAttribute.CUBETEXCOORD0]: 3,
[VertexAttribute.CUBETEXCOORD1]: 3,
[VertexAttribute.CUBETEXCOORD2]: 3,
[VertexAttribute.CUBETEXCOORD3]: 3,
[VertexAttribute.PIVOT4]: 4,
[VertexAttribute.PIVOT_DEPRECATED]: 3,
[VertexAttribute.FLEXIBILITY]: 1,
[VertexAttribute.ANGLE_SIN_COS]: 2,
[VertexAttribute.JOINTINDEX]: 4,
[VertexAttribute.JOINTWEIGHT]: 4,
} as const;
export class ScgReadStream extends ScpgReadStream {
header() {
return {
name: this.ascii(4),
version: this.uint32(),
nodeCount: this.uint32(),
nodeCount2: this.uint32(),
};
}
scg() {
const header = this.header();
const polygonGroupsRaw: PolygonGroupRaw[] = [];
const polygonGroups = new Map<bigint, BlitzkitPolygonGroup>();
for (let index = 0; index < header.nodeCount; index++) {
const polygonGroupRaw = this.ka() as PolygonGroupRaw;
polygonGroupsRaw.push(polygonGroupRaw);
}
polygonGroupsRaw.forEach((polygonGroupRaw) => {
const indicesStream = new ScpgReadStream(polygonGroupRaw.indices);
const indices: number[] = [];
for (let index = 0; index < polygonGroupRaw.indexCount; index++) {
indices.push(
polygonGroupRaw.indexFormat === 0
? indicesStream.uint16()
: indicesStream.uint32(),
);
}
const verticesStream = new ScpgReadStream(polygonGroupRaw.vertices);
const vertices: BlitzkitVertex[] = [];
const { format, stride } = ScgReadStream.parseVertexFormat(
polygonGroupRaw.vertexFormat,
);
const strideBasedSize = stride * polygonGroupRaw.vertexCount;
if (verticesStream.buffer.byteLength !== strideBasedSize) {
console.warn(
`Vertex stride mismatch; expected ${strideBasedSize}, got ${verticesStream.buffer.byteLength}; skipping...`,
);
return;
}
for (let index = 0; index < polygonGroupRaw.vertexCount; index++) {
vertices[index] = format.map((attribute) => ({
attribute,
value: verticesStream.vectorN(vertexAttributeVectorSizes[attribute]),
}));
}
polygonGroups.set(
new DataView(polygonGroupRaw['#id']).getBigUint64(0, true),
{
vertices,
indices,
},
);
});
return polygonGroups;
}
static parseVertexFormat(formatInt: number) {
const format: VertexAttribute[] = [];
let stride = 0;
vertexAttributesArray.forEach((attributeUntyped) => {
const attribute = attributeUntyped as VertexAttribute;
const mask = 1 << attribute;
if (formatInt & mask) {
format.push(attribute);
stride += vertexAttributeVectorSizes[attribute] * 4;
}
});
return { format, stride };
}
}
+263
View File
@@ -0,0 +1,263 @@
import { times } from 'lodash';
import { ReadStream } from './buffer';
enum KAType {
NONE = 0,
BOOLEAN = 1,
INT32 = 2,
FLOAT = 3,
STRING = 4,
WIDE_STRING = 5,
BYTE_ARRAY = 6,
UINT32 = 7,
KEYED_ARCHIVE = 8,
INT64 = 9,
UINT64 = 10,
VECTOR2 = 11,
VECTOR3 = 12,
VECTOR4 = 13,
MATRIX2 = 14,
MATRIX3 = 15,
MATRIX4 = 16,
COLOR = 17,
FASTNAME = 18,
AABBOX3 = 19,
FILEPATH = 20,
FLOAT64 = 21,
INT8 = 22,
UINT8 = 23,
INT16 = 24,
UINT16 = 25,
ARRAY = 27,
TRANSFORM = 29,
}
export enum VertexAttribute {
VERTEX = 0,
NORMAL = 1,
COLOR = 2,
TEXCOORD0 = 3,
TEXCOORD1 = 4,
TEXCOORD2 = 5,
TEXCOORD3 = 6,
TANGENT = 7,
BINORMAL = 8,
HARD_JOINTINDEX = 9,
PIVOT4 = 10,
PIVOT_DEPRECATED = 11,
FLEXIBILITY = 12,
ANGLE_SIN_COS = 13,
JOINTINDEX = 14,
JOINTWEIGHT = 15,
CUBETEXCOORD0 = 16,
CUBETEXCOORD1 = 17,
CUBETEXCOORD2 = 18,
CUBETEXCOORD3 = 19,
}
export class ScpgReadStream extends ReadStream {
vectorN(size: number) {
return times(size, () => this.float32());
}
vector2() {
return this.vectorN(2);
}
vector3() {
return this.vectorN(3);
}
vector4() {
return this.vectorN(4);
}
matrixN(size: number) {
return times(size, () => this.vectorN(size));
}
matrix2() {
return this.matrixN(2);
}
matrix3() {
return this.matrixN(3);
}
matrix4() {
return this.matrixN(4);
}
kaValue(
type: KAType = this.uint8(),
stringTable?: Record<number, string>,
): unknown {
switch (type) {
case KAType.NONE:
return undefined;
case KAType.FILEPATH:
case KAType.STRING: {
if (stringTable) {
const id = this.uint32();
return stringTable[id];
} else {
const length = this.uint32();
return this.ascii(length);
}
}
case KAType.BYTE_ARRAY: {
const length = this.uint32();
return this.byte(length);
}
case KAType.FLOAT:
return this.float32();
case KAType.ARRAY: {
const length = this.uint32();
const value = [];
for (let index = 0; index < length; index++) {
value.push(this.kaValue(undefined, stringTable));
}
return value;
}
case KAType.KEYED_ARCHIVE: {
this.seek(4); // UInt32 length of the nested archive in bytes
return this.ka(stringTable);
}
case KAType.INT8:
return this.int8();
case KAType.INT16:
return this.int16();
case KAType.INT32:
return this.int32();
case KAType.INT64:
return this.int64();
case KAType.UINT8:
return this.uint8();
case KAType.UINT16:
return this.uint16();
case KAType.UINT32:
return this.uint32();
case KAType.UINT64:
return this.uint64();
case KAType.BOOLEAN:
return this.boolean();
case KAType.VECTOR2:
return this.vector2();
case KAType.VECTOR3:
return this.vector3();
case KAType.VECTOR4:
return this.vector4();
case KAType.COLOR:
return this.vector4(); // as RGBA
case KAType.MATRIX2:
return this.matrix2();
case KAType.MATRIX3:
return this.matrix3();
case KAType.MATRIX4:
return this.matrix4();
case KAType.AABBOX3: {
const minimum = this.vector3();
const maximum = this.vector3();
return { minimum, maximum };
}
case KAType.FASTNAME: {
if (!stringTable) throw new Error('No string table provided');
const index = this.uint32();
return stringTable[index];
}
case KAType.TRANSFORM: {
const position = this.vector3();
const scale = this.vector3();
const rotation = this.vector4();
return {
position,
scale,
rotation,
};
}
default:
throw new TypeError(
`Unhandled KA type: ${type} (${KAType[type] ?? 'unknown type'})`,
);
}
}
ka(stringTable?: Record<number, string>) {
this.seek(2); // "KA"
const version = this.uint16();
const pairs: Record<string, any> = {};
if (version === 0x0001) {
const count = this.uint32();
for (let index = 0; index < count; index++) {
const name = this.kaValue(undefined, stringTable) as string;
const value = this.kaValue(undefined, stringTable);
pairs[name] = value;
}
} else if (version === 0x0002) {
const count = this.uint32();
const strings: string[] = [];
const stringTable: Record<number, string> = {};
for (let index = 0; index < count; index++) {
const length = this.uint16();
const string = this.ascii(length);
strings.push(string);
}
for (let index = 0; index < count; index++) {
const id = this.uint32();
stringTable[id] = strings[index];
}
const nodeCount = this.uint32();
for (let index = 0; index < nodeCount; index++) {
const keyId = this.uint32();
const value = this.kaValue(undefined, stringTable);
const key = stringTable[keyId];
pairs[key] = value;
}
} else if (version === 0x0102) {
if (!stringTable) throw new Error('No string table provided');
const count = this.uint32();
for (let index = 0; index < count; index++) {
const keyIndex = this.uint32();
const key = stringTable[keyIndex];
const valueType = this.uint8();
if (valueType === KAType.STRING) {
const valueIndex = this.uint32();
const value = stringTable[valueIndex];
pairs[key] = value;
} else {
const value = this.kaValue(valueType, stringTable);
pairs[key] = value;
}
}
} else if (version === 0xff02) {
// same as 0x0102 but empty and with no count
return {};
} else throw new RangeError(`Unhandled KA version: ${version}`);
return pairs;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { ReadStream } from './buffer';
export class WindowsReadStream extends ReadStream {
dword() {
return this.uint32();
}
}
@@ -1,3 +1,6 @@
// MIGRATION TODO: i added this use client, it makes stuff work again?
'use client';
import { Draft, produce } from 'immer';
import {
createContext,
@@ -1,3 +1,4 @@
import { permanentSkills } from '@blitzkit/core/src/blitz/permanentSkills';
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
import { debounce, times } from 'lodash';
@@ -21,7 +22,6 @@ import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
import { theme } from '../../../../stitches.config';
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
import { EquipmentMatrix } from '../../../../stores/duel';
import { permanentSkills } from '../../tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { Body } from './Body';
import { InsertionMarker } from './IntersectionMarker';
import { TankCard } from './TankCard';
@@ -101,8 +101,8 @@ export function CompareTable({ stats }: CompareTableProps) {
crewSkills[skill] === 0
? theme.colors.textLowContrast
: permanentSkills.includes(skill)
? theme.colors.textLowContrast_crimson
: theme.colors.textLowContrast_amber,
? theme.colors.textLowContrast_crimson
: theme.colors.textLowContrast_amber,
}}
/>
))}
@@ -1,3 +1,7 @@
import {
checkConsumableProvisionInclusivity,
consumableDefinitions,
} from '@blitzkit/core';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
@@ -9,8 +13,6 @@ import {
} from '@radix-ui/themes';
import { use } from 'react';
import { ConsumablesManager } from '../../../../../../../components/ConsumablesManager';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkit/checkConsumableProvisionInclusivity';
import { consumableDefinitions } from '../../../../../../../core/blitzkit/consumableDefinitions';
import { useEquipment } from '../../../../../../../hooks/useEquipment';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -1,7 +1,7 @@
import { equipmentDefinitions } from '@blitzkit/core';
import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { EquipmentManager } from '../../../../../../../components/EquipmentManager';
import { equipmentDefinitions } from '../../../../../../../core/blitzkit/equipmentDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -1,14 +1,12 @@
import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { ReactNode } from 'react';
import { theme } from '../../../../../../../stitches.config';
export interface InfoProps {
name: ReactNode;
children?: ReactNode;
unit?: string;
indent?: boolean;
highlight?: boolean;
delta?: number;
decimals?: number;
prefix?: string;
@@ -20,7 +18,6 @@ export function Info({
children,
unit,
indent = false,
highlight,
delta,
decimals,
prefix,
@@ -32,7 +29,7 @@ export function Info({
style={{ width: '100%', paddingLeft: indent ? 24 : 0 }}
gap="4"
>
<Text color={highlight ? 'amber' : 'gray'}>
<Text color="gray">
{name}
{unit != undefined && (
<>
@@ -49,9 +46,7 @@ export function Info({
style={{
flex: 1,
height: 1,
backgroundColor: highlight
? theme.colors.componentCallToActionInteractive_amberAlpha
: theme.colors.componentCallToActionInteractive_alpha,
backgroundColor: 'red',
}}
/>
)}
@@ -82,7 +77,7 @@ export function Info({
</Text>
</>
)}
<Text color={highlight ? 'amber' : undefined}>
<Text>
{prefix}
{decimals !== undefined && typeof children === 'number'
? children.toFixed(decimals)
@@ -1,8 +1,8 @@
import { asset } from '@blitzkit/core';
import { Flex, Heading, Slider, TextField } from '@radix-ui/themes';
import { clamp, debounce } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GenericTankComponentButton } from '../../../../../../../components/ModuleButtons/GenericTankComponentButton';
import { asset } from '../../../../../../../core/blitzkit/asset';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -61,10 +61,10 @@ export function Miscellaneous() {
cooldownBooster === 0
? undefined
: cooldownBooster === 1
? '#afb0abc0'
: cooldownBooster === 2
? '#5f72cbc0'
: '#9b3cc0c0'
? '#afb0abc0'
: cooldownBooster === 2
? '#5f72cbc0'
: '#9b3cc0c0'
}
iconStyles={{
transform: 'translate(-50%, -50%) scale(0.8)',
@@ -1,3 +1,10 @@
import {
asset,
formatCompact,
tankDefinitions,
tankIcon,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import {
Button,
ChevronDownIcon,
@@ -8,18 +15,6 @@ import {
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
import {
ModuleDefinition,
ModuleType,
TankDefinition,
tankDefinitions,
Tier,
Unlock,
} from '../../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../../core/blitzkit/tankIcon';
import { formatCompact } from '../../../../../../../core/math/formatCompact';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -222,7 +217,9 @@ export function Modules() {
<div
style={{
backgroundColor: 'currentcolor',
width: `calc(${central ? 100 : 50}% + ${central ? 8 : 4}px)`,
width: `calc(${central ? 100 : 50}% + ${
central ? 8 : 4
}px)`,
height: 1,
position: 'absolute',
right: first ? 0 : undefined,
@@ -262,23 +259,23 @@ export function Modules() {
(unlock.type === 'turret'
? turret.id
: unlock.type === 'gun'
? gun.id
: unlock.type === 'engine'
? engine.id
: unlock.type === 'chassis'
? track.id
: -1) === unlock.id
? gun.id
: unlock.type === 'engine'
? engine.id
: unlock.type === 'chassis'
? track.id
: -1) === unlock.id
}
top={
unlock.type === 'turret'
? module.id == topTurret.id
: unlock.type === 'gun'
? module.id === topGun.id
: unlock.type === 'engine'
? module.id === topEngine.id
: unlock.type === 'chassis'
? module.id === topTrack.id
: false
? module.id === topGun.id
: unlock.type === 'engine'
? module.id === topEngine.id
: unlock.type === 'chassis'
? module.id === topTrack.id
: false
}
onClick={() => setByUnlock(unlock)}
/>
@@ -1,8 +1,7 @@
import { availableProvisions, provisionDefinitions } from '@blitzkit/core';
import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { ProvisionsManager } from '../../../../../../../components/ProvisionsManager';
import { availableProvisions } from '../../../../../../../core/blitzkit/availableProvisions';
import { provisionDefinitions } from '../../../../../../../core/blitzkit/provisionDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
@@ -1,3 +1,4 @@
import { createDefaultSkills, skillDefinitions } from '@blitzkit/core';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
@@ -10,8 +11,6 @@ import {
} from '@radix-ui/themes';
import { use } from 'react';
import { CrewSkillManager } from '../../../../../../../../components/CrewSkillManager';
import { createDefaultSkills } from '../../../../../../../../core/blitzkit/createDefaultSkills';
import { skillDefinitions } from '../../../../../../../../core/blitzkit/skillDefinitions';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
@@ -1,3 +1,14 @@
import {
asset,
coefficient,
CREW_MEMBER_NAMES,
equipmentDefinitions,
GUN_TYPE_NAMES,
isExplosive,
provisionDefinitions,
resolvePenetrationCoefficient,
tankCharacteristics,
} from '@blitzkit/core';
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import {
Flex,
@@ -13,18 +24,6 @@ import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { Ad, AdType } from '../../../../../../components/Ad';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { asset } from '../../../../../../core/blitzkit/asset';
import { coefficient } from '../../../../../../core/blitzkit/coefficient';
import { equipmentDefinitions } from '../../../../../../core/blitzkit/equipmentDefinitions';
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
import { tankCharacteristics } from '../../../../../../core/blitzkit/tankCharacteristics';
import {
CREW_MEMBER_NAMES,
GUN_TYPE_NAMES,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useAdExempt } from '../../../../../../hooks/useAdExempt';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
@@ -463,7 +462,9 @@ export function Characteristics() {
<Fragment key={member.type}>
<InfoWithDelta
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${count > 1 ? ` x ${count}` : ''}`}
name={`${CREW_MEMBER_NAMES[member.type]}${
count > 1 ? ` x ${count}` : ''
}`}
unit="%"
decimals={0}
>
@@ -1,12 +1,9 @@
import { Pose } from '@blitzkit/core';
import { OrbitControls } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { useEffect, useRef, useState } from 'react';
import { Vector3 } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../core/blitzkit/hasEquipment';
import { modelDefinitions } from '../../../../../core/blitzkit/modelDefinitions';
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
import { useAwait } from '../../../../../hooks/useAwait';
import * as Duel from '../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../stores/tankopediaEphemeral';
@@ -1,9 +1,11 @@
import {
asset,
consumableDefinitions,
gameDefinitions,
provisionDefinitions,
} from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../../../../../core/blitzkit/asset';
import { consumableDefinitions } from '../../../../../../core/blitzkit/consumableDefinitions';
import { gameDefinitions } from '../../../../../../core/blitzkit/gameDefinitions';
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
import * as Duel from '../../../../../../stores/duel';
export function GameModeSection() {
@@ -33,7 +35,9 @@ export function GameModeSection() {
direction="column"
style={{
borderRadius: 'var(--radius-3)',
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
background: `url(${asset(
`icons/game_mode_banners/${id}.webp`,
)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
@@ -41,7 +45,9 @@ export function GameModeSection() {
<Box
flexGrow="1"
style={{
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
background: `url(${asset(
`icons/game_mode_banners/${id}.webp`,
)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
@@ -1,11 +1,8 @@
import { resolveNearPenetration, tankDefinitions, Var } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { ThicknessRange } from '../../../../../../components/Armor/components/StaticArmor';
import { classIcons } from '../../../../../../components/ClassIcon';
import { NAVBAR_HEIGHT } from '../../../../../../components/Navbar/index.css';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
import { Var } from '../../../../../../core/blitzkit/var';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import * as Duel from '../../../../../../stores/duel';
import { TankSandbox } from './TankSandbox';
@@ -22,8 +19,8 @@ export function HeroSection() {
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined;
? 'amber'
: undefined;
const awaitedTankDefinitions = use(tankDefinitions);
const thicknessRange = useMemo(() => {
const entries = Object.values(awaitedTankDefinitions);
@@ -1,15 +1,16 @@
import {
assertSecret,
asset,
modelDefinitions,
provisionDefinitions,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
import { Button, Code, Dialog, Flex } from '@radix-ui/themes';
import { use, useState } from 'react';
import { classIcons } from '../../../../../../../components/ClassIcon';
import { Link } from '../../../../../../../components/Link';
import { ScienceIcon } from '../../../../../../../components/ScienceIcon';
import { asset } from '../../../../../../../core/blitzkit/asset';
import { modelDefinitions } from '../../../../../../../core/blitzkit/modelDefinitions';
import { provisionDefinitions } from '../../../../../../../core/blitzkit/provisionDefinitions';
import { assertSecret } from '../../../../../../../core/blitzkit/secret';
import { tankToDuelMember } from '../../../../../../../core/blitzkit/tankToDuelMember';
import strings from '../../../../../../../lang/en-US.json';
import * as App from '../../../../../../../stores/app';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
@@ -132,8 +133,8 @@ export function MetaSection() {
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined
? 'amber'
: undefined
}
>
{strings.common.tree_type[protagonist.treeType]}
@@ -159,7 +160,11 @@ export function MetaSection() {
</Listing>
)}
<Listing
label={`${protagonist.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
label={`${
protagonist.treeType === 'researchable'
? 'Purchase'
: 'Restoration'
} price`}
>
<Flex align="center" gap="1">
{protagonist.price.value.toLocaleString()}
@@ -167,7 +172,9 @@ export function MetaSection() {
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${protagonist.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
@@ -179,7 +186,9 @@ export function MetaSection() {
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${protagonist.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
@@ -9,13 +9,7 @@ import {
import { Armor } from '../../../../../../components/Armor';
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
import {
StaticArmor,
ThicknessRange,
} from '../../../../../../components/Armor/components/StaticArmor';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { StaticArmor } from '../../../../../../components/Armor/components/StaticArmor';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import useOnScreen from '../../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
@@ -1,6 +1,6 @@
import { tankIcon } from '@blitzkit/core';
import { Flex, FlexProps, Progress } from '@radix-ui/themes';
import { useProgress } from '@react-three/drei';
import { tankIcon } from '../../../../../../core/blitzkit/tankIcon';
type TankSandboxLoaderProps = Omit<FlexProps, 'id'> & {
id: number;
@@ -1,11 +1,13 @@
import {
asset,
averageDefinitions,
formatCompact,
tankDefinitions,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { Box, Flex, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { use } from 'react';
import { asset } from '../../../../../../../../core/blitzkit/asset';
import { averageDefinitions } from '../../../../../../../../core/blitzkit/averageDefinitions';
import { tankDefinitions } from '../../../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkit/tankDefinitions/constants';
import { formatCompact } from '../../../../../../../../core/math/formatCompact';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
interface NodeProps {
@@ -1,9 +1,7 @@
import { asset, imgur, tankDefinitions } from '@blitzkit/core';
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons';
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes';
import { Fragment, use, useEffect, useMemo, useRef, useState } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
import { imgur } from '../../../../../../../core/blitzkit/imgur';
import { tankDefinitions } from '../../../../../../../core/blitzkit/tankDefinitions';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
import { Arrow } from './components/Arrow';
@@ -1,6 +1,5 @@
import { IconButton } from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { asset } from '../../../../../../../../../core/blitzkit/asset';
import { useArmor } from '../../../../../../../../../hooks/useArmor';
import * as Duel from '../../../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../../../stores/tankopediaEphemeral';
@@ -1,3 +1,4 @@
import { imgur, uniqueGuns } from '@blitzkit/core';
import {
CameraIcon,
CopyIcon,
@@ -21,17 +22,6 @@ import {
} from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { RefObject, useEffect, useState } from 'react';
import { ThicknessRange } from '../../../../../../../../components/Armor/components/StaticArmor';
import { ModuleButton } from '../../../../../../../../components/ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../../../../../components/SmallTankIcon';
import { resolveNearPenetration } from '../../../../../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../../../../../core/blitz/resolvePenetrationCoefficient';
import { asset } from '../../../../../../../../core/blitzkit/asset';
import { imgur } from '../../../../../../../../core/blitzkit/imgur';
import { Pose, poseEvent } from '../../../../../../../../core/blitzkit/pose';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkit/tankDefinitions/constants';
import { uniqueGuns } from '../../../../../../../../core/blitzkit/uniqueGuns';
import { useArmor } from '../../../../../../../../hooks/useArmor';
import { useEquipment } from '../../../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../../../hooks/useFullScreen';
import { useFullscreenAvailability } from '../../../../../../../../hooks/useFullscreenAvailability';
@@ -1,12 +1,6 @@
import { invalidate, ThreeEvent, useThree } from '@react-three/fiber';
import { memo, useRef } from 'react';
import { Group, Mesh, MeshStandardMaterial, Vector2 } from 'three';
import { ModelTankWrapper } from '../../../../../../../components/Armor/components/ModelTankWrapper';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../../core/blitzkit/jsxTree';
import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTransform';
import { normalizeAngleRad } from '../../../../../../../core/math/normalizeAngleRad';
import { useModel } from '../../../../../../../hooks/useModel';
import { useTankModelDefinition } from '../../../../../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../../../../../hooks/useTankTransform';
@@ -1,12 +1,8 @@
import { modelTransformEvent, ModelTransformEventData } from '@blitzkit/core';
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import {
modelTransformEvent,
ModelTransformEventData,
} from '../../../../../../core/blitzkit/modelTransform';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
@@ -23,11 +19,6 @@ export function QuickInputs() {
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
const antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks =
protagonist.tank.id === antagonist.id
? [protagonist.tank.id]
: [protagonist.tank.id, antagonist.id];
useEffect(() => {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
@@ -1,5 +1,4 @@
import { Flex } from '@radix-ui/themes';
import { ShotDisplayCard } from '../../../../../components/Armor/components/ShotDisplayCard';
import * as TankEphemeral from '../../../../../stores/tankopediaEphemeral';
export function ShotDisplaySection() {
@@ -1,11 +1,9 @@
import { reviews, Var, youtubers } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { use } from 'react';
import { Link } from '../../../../../components/Link';
import PageWrapper from '../../../../../components/PageWrapper';
import { VerifiedIcon } from '../../../../../components/VerifiedIcon';
import { youtubers } from '../../../../../constants/youtubers';
import { reviews } from '../../../../../core/blitzkit/reviews';
import { Var } from '../../../../../core/blitzkit/var';
import * as Duel from '../../../../../stores/duel';
export function VideoSection() {
@@ -1,10 +1,12 @@
import {
modelDefinitions,
provisionDefinitions,
tankDefinitions,
tankIcon,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { ReactNode } from 'react';
import { modelDefinitions } from '../../../../core/blitzkit/modelDefinitions';
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import strings from '../../../../lang/en-US.json';
import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
@@ -1,12 +1,12 @@
'use client';
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
import PageWrapper from '../../../../components/PageWrapper';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import { useAdExempt } from '../../../../hooks/useAdExempt';
import strings from '../../../../lang/en-US.json';
import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { CalloutsSection } from './components/CalloutsSection';
@@ -0,0 +1,189 @@
import { ReloadIcon } from '@radix-ui/react-icons';
import {
Card,
Code,
Flex,
IconButton,
Text,
TextField,
} from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useRef } from 'react';
import { radToDeg } from 'three/src/math/MathUtils';
import * as App from '../../../stores/app';
import * as Duel from '../../../stores/duel';
import * as TankopediaEphemeral from '../../../stores/tankopediaEphemeral';
import { layerTypeNames } from './ShotDisplayCard';
import { ArmorType } from './SpacedArmorScene';
export function ArmorPlateDisplay() {
const highlightArmor = TankopediaEphemeral.use(
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
if (highlightArmor === undefined) return null;
return (
<group position={highlightArmor.point}>
<Html
center
style={{ pointerEvents: highlightArmor.editingPlate ? 'auto' : 'none' }}
>
<Card
mb="4"
style={{
whiteSpace: 'nowrap',
color: highlightArmor.color,
transform: 'translateY(-50%)',
}}
>
<Flex direction="column">
<Text weight="bold">
{layerTypeNames[highlightArmor.type]}{' '}
{highlightArmor.thickness.toFixed(0)}
<Text size="1" weight="regular">
mm
</Text>
</Text>
{highlightArmor.type !== ArmorType.External && (
<Text size="2" color="gray">
{highlightArmor.thicknessAngled.toFixed(0)}
<Text size="1">
mm {radToDeg(highlightArmor.angle).toFixed(0)}°
</Text>
</Text>
)}
{developerMode && (
<>
<Text mt="2" color="gray" size="2">
<b>DEV:</b> <Code>{highlightArmor.name}</Code>
</Text>
</>
)}
{highlightArmor.editingPlate && (
<Flex gap="2" align="center">
<TextField.Root
style={{ width: 96 }}
mt="2"
ref={input}
key={highlightArmor.name}
type="number"
autoFocus
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name);
if (index === undefined) return;
const thickness = event.currentTarget.valueAsNumber;
if (isNaN(thickness) || thickness < 0) return;
if (event.key === 'Enter') {
input.current?.blur();
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
const { protagonist } = duelStore.getState();
const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
console.log(highlightArmor.name);
if (highlightArmor.name.startsWith('hull_')) {
tank.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('turret_')) {
turret.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('gun_')) {
if (highlightArmor.name.includes('_armor_')) {
gun.armor.thickness[index] = thickness;
} else {
gun.thickness = thickness;
}
} else if (highlightArmor.name.startsWith('chassis_')) {
track.thickness = thickness;
}
});
} else if (event.key === 'Escape') {
input.current?.blur();
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}
}}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
defaultValue={highlightArmor.thickness.toFixed(0)}
>
<TextField.Slot side="right">mm</TextField.Slot>
</TextField.Root>
<IconButton
mt="2"
color="red"
onClick={() => {
mutateTankopediaEphemeral((draft) => {
if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name);
if (index === undefined) return;
draft.highlightArmor = undefined;
const initialState =
tankopediaEphemeralStore.getInitialState();
const { protagonist } = duelStore.getState();
const tank = draft.model;
const initialTank = initialState.model;
const turret = tank.turrets[protagonist.turret.id];
const initialTurret =
initialTank.turrets[protagonist.turret.id];
const gun = turret.guns[protagonist.gun.id];
const initialGun = initialTurret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
const initialTrack =
initialTank.tracks[protagonist.track.id];
console.log(highlightArmor.name);
if (highlightArmor.name.startsWith('hull_')) {
tank.armor.thickness[index] =
initialTank.armor.thickness[index];
} else if (highlightArmor.name.startsWith('turret_')) {
turret.armor.thickness[index] =
initialTurret.armor.thickness[index];
} else if (highlightArmor.name.startsWith('gun_')) {
if (highlightArmor.name.includes('_armor_')) {
gun.armor.thickness[index] =
initialGun.armor.thickness[index];
} else {
gun.thickness = initialGun.thickness;
}
} else if (highlightArmor.name.startsWith('chassis_')) {
track.thickness = initialTrack.thickness;
}
});
}}
>
<ReloadIcon />
</IconButton>
</Flex>
)}
</Flex>
</Card>
</Html>
</group>
);
}
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more