tanks.ts•21.3 kB
import { readdir } from 'fs/promises';
import { join } from 'path';
import { readXML } from './xml.js';
import { getString } from './strings.js';
import { parsePair, parseTriple } from '../utils/parsing.js';
import type { Config } from '../config.js';
import type { Tank, TankSummary, TankClass, TankType, Nation, Shell, Gun, Turret, Engine, Tracks, ShellType, GunType, ArmorPlate, ModuleUnlock, CombatRole } from '../data/types.js';
function formatTankTag(tag: string): string {
return tag
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeLocalizationKey(key: string): string {
if (/_vehicles:/.test(key)) {
return key;
}
return key.replace('#', '');
}
function getLocalizedString(key: string, fallback: string): string {
const normalizedKey = normalizeLocalizationKey(key);
const result = getString(normalizedKey);
if (result === normalizedKey) {
return formatTankTag(fallback);
}
return result;
}
interface VehicleListEntry {
id: number;
userString: string;
shortUserString?: string;
description: string;
price: number | { gold: ''; '#text': number };
sellPrice?: { gold: ''; '#text': number };
tags: string;
level: number;
notInShop?: boolean;
combatRole?: Record<string, string>;
}
interface VehicleList {
root: Record<string, VehicleListEntry>;
}
interface VehicleDefinition {
root: {
crew: Record<string, string | string[]>;
speedLimits: { forward: number; backward: number };
invisibility: { moving: number; still: number; firePenalty: number };
consumableSlots: number;
provisionSlots: number;
optDevicePreset: string | string[];
hull: {
armor: Record<string, number | { vehicleDamageFactor: number; '#text': number }>;
maxHealth: number;
weight: number;
turretPositions: { turret: string };
};
chassis: Record<string, ChassisDefinition>;
engines: Record<string, { unlocks?: UnlocksData }>;
turrets0: Record<string, TurretDefinition>;
};
}
interface UnlockEntry {
cost: number | string;
'#text': string | number;
}
interface UnlocksData {
vehicle?: UnlockEntry | UnlockEntry[];
turret?: UnlockEntry | UnlockEntry[];
gun?: UnlockEntry | UnlockEntry[];
engine?: UnlockEntry | UnlockEntry[];
chassis?: UnlockEntry | UnlockEntry[];
}
interface ChassisDefinition {
userString: string;
level: number;
weight: number;
rotationSpeed: number;
terrainResistance: string;
shotDispersionFactors: { vehicleMovement: number; vehicleRotation: number };
unlocks?: UnlocksData;
}
interface TurretDefinition {
userString: string;
level: number;
weight: number;
rotationSpeed: number;
circularVisionRadius: number;
maxHealth: number;
armor: Record<string, number | { vehicleDamageFactor?: number; '#text': number }>;
yawLimits?: string | string[];
guns: Record<string, GunInTurret>;
unlocks?: UnlocksData;
}
interface GunInTurret {
reloadTime: number;
aimingTime?: number;
shotDispersionRadius?: number;
shotDispersionFactors?: { turretRotation: number; afterShot: number };
pitchLimits?: string | string[];
maxAmmo?: number;
clip?: { count: number; rate: number };
pumpGunMode?: boolean;
pumpGunReloadTimes?: string;
unlocks?: UnlocksData;
}
interface GunsList {
root: {
ids: Record<string, number>;
shared: Record<string, SharedGunDefinition>;
};
}
interface SharedGunDefinition {
userString: string;
level: number;
weight: number;
rotationSpeed: number;
aimingTime: number;
shotDispersionRadius: number;
shotDispersionFactors: { turretRotation: number; afterShot: number };
maxAmmo: number;
pitchLimits: string;
shots: Record<string, { speed: number; piercingPower: string; maxDistance: number }>;
clip?: { count: number; rate: number };
}
interface ShellsList {
root: Record<string, ShellDefinition>;
}
interface ShellDefinition {
id: number;
userString: string;
kind: 'ARMOR_PIERCING' | 'ARMOR_PIERCING_CR' | 'HIGH_EXPLOSIVE' | 'HOLLOW_CHARGE';
caliber: number;
damage: { armor: number; devices: number };
normalizationAngle?: number;
ricochetAngle?: number;
explosionRadius?: number;
}
interface EnginesList {
root: {
ids: Record<string, number>;
shared: Record<string, { userString: string; level: number; power: number; fireStartingChance: number; weight: number }>;
};
}
interface ChassisList {
root: {
ids: Record<string, number>;
};
}
const SHELL_TYPE_MAP: Record<string, ShellType> = {
ARMOR_PIERCING: 'AP',
ARMOR_PIERCING_CR: 'APCR',
HIGH_EXPLOSIVE: 'HE',
HOLLOW_CHARGE: 'HEAT',
};
const BOT_PATTERN = /(tutorial_bot|TU)$/;
let tanksCache: Map<number, Tank> | null = null;
let summaryCache: TankSummary[] | null = null;
function toUniqueId(nation: string, rawId: number): number {
const nationIndex = (['ussr', 'germany', 'usa', 'china', 'france', 'uk', 'japan', 'european', 'other'] as const).indexOf(nation as Nation);
return (nationIndex << 24) | rawId;
}
function getTankClass(tags: string): TankClass {
if (tags.includes('lightTank')) return 'lightTank';
if (tags.includes('mediumTank')) return 'mediumTank';
if (tags.includes('heavyTank')) return 'heavyTank';
if (tags.includes('AT-SPG')) return 'AT-SPG';
throw new Error(`Unknown tank class in tags: ${tags}`);
}
function getTankType(entry: VehicleListEntry): TankType {
const tags = entry.tags;
if (tags.includes('collectible')) return 'collector';
if (typeof entry.price !== 'number' && 'gold' in entry.price) return 'premium';
if (entry.sellPrice) return 'premium';
return 'researchable';
}
function parseArmorValue(value: number | { '#text': number; vehicleDamageFactor?: number }): { thickness: number; spaced: boolean } {
if (typeof value === 'number') {
return { thickness: value, spaced: false };
}
return { thickness: value['#text'], spaced: value.vehicleDamageFactor === 0 };
}
function parseArmorPlates(armor: Record<string, unknown>): ArmorPlate[] {
return Object.entries(armor)
.filter(([k]) => k.startsWith('armor_'))
.map(([k, v]) => {
const idMatch = k.match(/armor_(\d+)/);
const id = idMatch ? parseInt(idMatch[1], 10) : 0;
const { thickness, spaced } = parseArmorValue(v as number | { '#text': number; vehicleDamageFactor?: number });
return { id, thickness, spaced };
})
.filter(p => p.thickness > 0);
}
function extractPrimaryArmor(armor: Record<string, unknown>): { front: number; side: number; rear: number } {
const plates = parseArmorPlates(armor);
const solidPlates = plates.filter(p => !p.spaced).map(p => p.thickness).sort((a, b) => b - a);
const count = solidPlates.length;
if (count === 0) {
return { front: 0, side: 0, rear: 0 };
}
if (count === 1) {
return { front: solidPlates[0], side: solidPlates[0], rear: solidPlates[0] };
}
if (count === 2) {
return { front: solidPlates[0], side: solidPlates[1], rear: solidPlates[1] };
}
// 3+ plates: front=thickest, side=median, rear=thinnest
const middleIndex = Math.floor(count / 2);
return {
front: solidPlates[0],
side: solidPlates[middleIndex],
rear: solidPlates[count - 1],
};
}
function parseUnlocks(unlocks?: UnlocksData): ModuleUnlock[] {
if (!unlocks) return [];
const result: ModuleUnlock[] = [];
const types: Array<keyof UnlocksData> = ['vehicle', 'turret', 'gun', 'engine', 'chassis'];
for (const moduleType of types) {
const entries = unlocks[moduleType];
if (!entries) continue;
const list = Array.isArray(entries) ? entries : [entries];
for (const entry of list) {
const costValue = typeof entry.cost === 'number' ? entry.cost : parseInt(entry.cost.split(':')[1] || '0', 10);
const costType = typeof entry.cost === 'string' && entry.cost.includes(':') ? 'seasonal' : 'xp';
result.push({
moduleType,
moduleName: String(entry['#text']),
cost: costValue,
costType: costType as 'xp' | 'seasonal',
});
}
}
return result;
}
function parseCombatRoles(combatRole?: Record<string, string>): CombatRole[] {
if (!combatRole) return [];
return Object.entries(combatRole).map(([gameMode, role]) => ({ gameMode, role }));
}
function parsePrice(entry: VehicleListEntry): { type: 'credits' | 'gold'; value: number } | undefined {
if (entry.sellPrice) {
return { type: 'gold', value: entry.sellPrice['#text'] * 2 };
}
if (typeof entry.price === 'number') {
return { type: 'credits', value: entry.price };
}
if ('gold' in entry.price) {
return { type: 'gold', value: entry.price['#text'] * 400 };
}
return undefined;
}
export async function loadTanks(config: Config): Promise<void> {
if (tanksCache) return;
tanksCache = new Map();
summaryCache = [];
const nations = await readdir(config.paths.vehicles);
const filteredNations = nations.filter(n => n !== 'common') as Nation[];
for (const nation of filteredNations) {
const nationPath = join(config.paths.vehicles, nation);
const listPath = join(nationPath, 'list.xml');
const componentsPath = join(nationPath, 'components');
const vehicleList = await readXML<VehicleList>(listPath);
const gunsList = await readXML<GunsList>(join(componentsPath, 'guns.xml'));
const shellsList = await readXML<ShellsList>(join(componentsPath, 'shells.xml'));
const enginesList = await readXML<EnginesList>(join(componentsPath, 'engines.xml'));
const chassisList = await readXML<ChassisList>(join(componentsPath, 'chassis.xml'));
for (const [tankTag, entry] of Object.entries(vehicleList.root)) {
if (BOT_PATTERN.test(tankTag)) continue;
if (typeof entry !== 'object' || !entry.id) continue;
const tankId = toUniqueId(nation, entry.id);
try {
const tankDef = await readXML<VehicleDefinition>(join(nationPath, `${tankTag}.xml`));
const tank = parseTank(nation, tankTag, entry, tankDef, gunsList, shellsList, enginesList, chassisList);
tanksCache.set(tankId, tank);
summaryCache.push({
id: tank.id,
name: tank.name,
nation: tank.nation,
tier: tank.tier,
class: tank.class,
type: tank.type,
});
} catch (err) {
console.error(`Failed to parse tank ${nation}/${tankTag}:`, err);
}
}
}
}
function parseTank(
nation: Nation,
tankTag: string,
entry: VehicleListEntry,
def: VehicleDefinition,
gunsList: GunsList,
shellsList: ShellsList,
enginesList: EnginesList,
chassisList: ChassisList,
): Tank {
const tankId = toUniqueId(nation, entry.id);
const nameKey = entry.shortUserString ?? entry.userString;
const name = getLocalizedString(nameKey, tankTag);
const turrets = parseTurrets(def.root.turrets0, gunsList, shellsList);
const engines = parseEngines(def.root.engines, enginesList, nation);
const tracks = parseTracks(def.root.chassis, chassisList, nation);
const equipmentPreset = Array.isArray(def.root.optDevicePreset)
? def.root.optDevicePreset[def.root.optDevicePreset.length - 1]
: def.root.optDevicePreset;
const hullArmorPlates = parseArmorPlates(def.root.hull.armor);
const combatRoles = parseCombatRoles(entry.combatRole);
const price = parsePrice(entry);
return {
id: tankId,
tag: tankTag,
name,
shortName: entry.shortUserString ? getString(normalizeLocalizationKey(entry.shortUserString)) : undefined,
nation,
tier: entry.level,
class: getTankClass(entry.tags),
type: getTankType(entry),
health: def.root.hull.maxHealth,
hullArmor: extractPrimaryArmor(def.root.hull.armor),
hullArmorPlates: hullArmorPlates.length > 0 ? hullArmorPlates : undefined,
speedForward: def.root.speedLimits.forward,
speedBackward: def.root.speedLimits.backward,
weight: def.root.hull.weight,
camouflageStill: def.root.invisibility.still,
camouflageMoving: def.root.invisibility.moving,
camouflageFiring: def.root.invisibility.firePenalty,
turrets,
engines,
tracks,
consumableSlots: def.root.consumableSlots,
provisionSlots: def.root.provisionSlots,
equipmentPreset,
combatRoles: combatRoles.length > 0 ? combatRoles : undefined,
price,
};
}
function parseTurrets(
turretsData: Record<string, TurretDefinition>,
gunsList: GunsList,
shellsList: ShellsList,
): Turret[] {
return Object.entries(turretsData).map(([turretKey, turret]) => {
const nameKey = turret.userString;
const yawLimits = turret.yawLimits
? parseYawLimits(Array.isArray(turret.yawLimits) ? turret.yawLimits[turret.yawLimits.length - 1] : turret.yawLimits)
: undefined;
const armorPlates = parseArmorPlates(turret.armor);
const unlocks = parseUnlocks(turret.unlocks);
return {
id: 0,
name: getLocalizedString(nameKey, turretKey),
tier: turret.level,
health: turret.maxHealth,
viewRange: turret.circularVisionRadius,
traverseSpeed: turret.rotationSpeed,
yawLimits,
armorPlates: armorPlates.length > 0 ? armorPlates : undefined,
guns: parseGuns(turret.guns, gunsList, shellsList, turret.unlocks),
unlocks: unlocks.length > 0 ? unlocks : undefined,
};
});
}
function parseYawLimits(yawString: string): { min: number; max: number } {
const [min, max] = parsePair(yawString, [0, 0]);
return { min, max };
}
function parseGuns(
gunsData: Record<string, GunInTurret>,
gunsList: GunsList,
shellsList: ShellsList,
turretUnlocks?: UnlocksData,
): Gun[] {
return Object.entries(gunsData).map(([gunKey, gunInTurret]) => {
const sharedGun = gunsList.root.shared[gunKey];
if (!sharedGun) {
throw new Error(`Gun ${gunKey} not found in shared definitions`);
}
const nameKey = sharedGun.userString;
const pitchLimits = gunInTurret.pitchLimits ?? sharedGun.pitchLimits;
const pitchString = Array.isArray(pitchLimits) ? pitchLimits[pitchLimits.length - 1] : pitchLimits;
const [rawElevation, rawDepression] = parsePair(pitchString, [0, 0]);
const elevation = Math.abs(rawElevation);
const depression = rawDepression;
const reloadTime = gunInTurret.reloadTime;
const aimTime = gunInTurret.aimingTime ?? sharedGun.aimingTime;
const accuracy = gunInTurret.shotDispersionRadius ?? sharedGun.shotDispersionRadius;
const dispersionFactors = gunInTurret.shotDispersionFactors ?? sharedGun.shotDispersionFactors;
const shellCapacity = gunInTurret.maxAmmo ?? sharedGun.maxAmmo;
const shells = parseShells(sharedGun.shots, shellsList);
const firstShell = shells[0];
const damage = firstShell?.damage ?? 0;
const penetration = firstShell?.penetration ?? 0;
const dpm = reloadTime > 0 ? Math.round((damage * 60) / reloadTime) : 0;
let gunType: GunType = 'regular';
let clipSize: number | undefined;
let clipReload: number | undefined;
let intraClipReload: number | undefined;
const clip = gunInTurret.clip ?? sharedGun.clip;
if (clip) {
if (gunInTurret.pumpGunMode) {
gunType = 'autoReloader';
clipSize = clip.count;
intraClipReload = 60 / clip.rate;
} else {
gunType = 'autoLoader';
clipSize = clip.count;
clipReload = reloadTime;
intraClipReload = 60 / clip.rate;
}
}
const gunUnlocks = parseUnlocks(gunInTurret.unlocks);
return {
id: gunsList.root.ids[gunKey] ?? 0,
name: getLocalizedString(nameKey, gunKey),
tier: sharedGun.level,
damage,
penetration,
dpm,
reloadTime,
aimTime,
accuracy,
depression,
elevation,
shells,
dispersionMoving: 0,
dispersionTraverse: dispersionFactors.turretRotation,
dispersionAfterShot: dispersionFactors.afterShot,
gunType,
clipSize,
clipReload,
intraClipReload,
shellCapacity,
unlocks: gunUnlocks.length > 0 ? gunUnlocks : undefined,
};
});
}
function parseShells(
shotsData: Record<string, { speed: number; piercingPower: string; maxDistance: number }>,
shellsList: ShellsList,
): Shell[] {
return Object.entries(shotsData).map(([shellKey, shotInfo]) => {
const shell = shellsList.root[shellKey];
if (!shell) {
return {
id: 0,
name: shellKey,
type: 'AP' as ShellType,
damage: 0,
penetration: 0,
velocity: shotInfo.speed,
caliber: 0,
};
}
const nameKey = shell.userString;
const penetrations = shotInfo.piercingPower.split(' ').filter(Boolean).map(Number);
return {
id: shell.id,
name: getLocalizedString(nameKey, shellKey),
type: SHELL_TYPE_MAP[shell.kind] ?? 'AP',
damage: shell.damage.armor,
penetration: penetrations[0] ?? 0,
velocity: shotInfo.speed,
caliber: shell.caliber,
normalization: shell.normalizationAngle,
ricochet: shell.ricochetAngle,
explosionRadius: shell.explosionRadius,
};
});
}
function parseEngines(
enginesData: Record<string, { unlocks?: UnlocksData }>,
enginesList: EnginesList,
nation: string,
): Engine[] {
return Object.entries(enginesData).map(([engineKey, engineData]) => {
const shared = enginesList.root.shared[engineKey];
if (!shared) {
return {
id: 0,
name: engineKey,
tier: 1,
power: 0,
fireChance: 0,
};
}
const nameKey = shared.userString;
const unlocks = parseUnlocks(engineData?.unlocks);
return {
id: enginesList.root.ids[engineKey] ?? 0,
name: getLocalizedString(nameKey, engineKey),
tier: shared.level,
power: shared.power,
fireChance: shared.fireStartingChance,
unlocks: unlocks.length > 0 ? unlocks : undefined,
};
});
}
function parseTracks(
chassisData: Record<string, ChassisDefinition>,
chassisList: ChassisList,
nation: string,
): Tracks[] {
return Object.entries(chassisData).map(([chassisKey, chassis]) => {
const nameKey = chassis.userString;
const [hard, medium, soft] = parseTriple(chassis.terrainResistance, [1, 1, 1]);
const unlocks = parseUnlocks(chassis.unlocks);
return {
id: chassisList.root.ids[chassisKey] ?? 0,
name: getLocalizedString(nameKey, chassisKey),
tier: chassis.level,
traverseSpeed: chassis.rotationSpeed,
dispersionMoving: chassis.shotDispersionFactors.vehicleMovement,
dispersionTraverse: chassis.shotDispersionFactors.vehicleRotation,
terrainResistance: { hard, medium, soft },
unlocks: unlocks.length > 0 ? unlocks : undefined,
};
});
}
export function getTank(id: number): Tank | undefined {
return tanksCache?.get(id);
}
function normalizeTankName(name: string): string {
return name
.toLowerCase()
.replace(/\bobject\b/g, 'obj')
.replace(/\bpanzer\b/g, 'pz')
.replace(/\bpanzerkampfwagen\b/g, 'pz')
.replace(/\bsturmgeschutz\b/g, 'stug')
.replace(/\bjagdpanzer\b/g, 'jpz')
.replace(/\./g, '')
.replace(/-/g, '')
.replace(/\s+/g, '');
}
export function getTankByName(name: string): Tank | undefined {
if (!tanksCache) return undefined;
const lowerName = name.toLowerCase();
const normalizedInput = normalizeTankName(name);
// exact match
for (const tank of tanksCache.values()) {
if (tank.name.toLowerCase() === lowerName || tank.tag.toLowerCase() === lowerName) {
return tank;
}
}
// partial match
for (const tank of tanksCache.values()) {
if (tank.name.toLowerCase().includes(lowerName) || tank.tag.toLowerCase().includes(lowerName)) {
return tank;
}
}
// fuzzy match (handles "obj 140" -> "Object 140", "pz 4" -> "Pz.Kpfw. IV", etc)
for (const tank of tanksCache.values()) {
const normalizedTankName = normalizeTankName(tank.name);
const normalizedTag = normalizeTankName(tank.tag);
if (normalizedTankName === normalizedInput || normalizedTag === normalizedInput) {
return tank;
}
if (normalizedTankName.includes(normalizedInput) || normalizedInput.includes(normalizedTankName)) {
return tank;
}
}
return undefined;
}
export function getAllTanks(): Tank[] {
return tanksCache ? Array.from(tanksCache.values()) : [];
}
export function getTankSummaries(): TankSummary[] {
return summaryCache ?? [];
}
export function searchTanks(options: {
nation?: Nation;
tier?: number;
class?: TankClass;
type?: TankType;
query?: string;
}): TankSummary[] {
let results = getTankSummaries();
if (options.nation) {
results = results.filter(t => t.nation === options.nation);
}
if (options.tier) {
results = results.filter(t => t.tier === options.tier);
}
if (options.class) {
results = results.filter(t => t.class === options.class);
}
if (options.type) {
results = results.filter(t => t.type === options.type);
}
if (options.query) {
const q = options.query.toLowerCase();
const normalizedQuery = normalizeTankName(options.query);
results = results.filter(t => {
const lowerName = t.name.toLowerCase();
const normalizedName = normalizeTankName(t.name);
return lowerName.includes(q) || normalizedName.includes(normalizedQuery);
});
}
return results;
}