Skip to main content
Glama
tanks.ts21.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; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Revenant30102000/wotblitz-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server