2026-10-11 00:43:08 -05:00
|
|
|
|
import {
|
|
|
|
|
|
blitzkitToBlitzShellKind,
|
|
|
|
|
|
isExplosive,
|
|
|
|
|
|
normalizeBoundingBox,
|
|
|
|
|
|
unionBoundingBox,
|
|
|
|
|
|
} from "@blitzkit/core";
|
|
|
|
|
|
import { ShellType, TankClass } from "@blitzkit/protos";
|
2026-10-11 01:36:34 -05:00
|
|
|
|
import { hullTraverseSpeed } from "../tankopedia/hullTraverseSpeed";
|
|
|
|
|
|
import { rawTerrainCoefficient } from "../tankopedia/rawTerrainCoefficient";
|
2026-11-11 22:43:47 -06:00
|
|
|
|
import { tankClassToBlitzTankClass } from "../tankopedia/tankClass";
|
2026-08-31 20:13:31 -05:00
|
|
|
|
import {
|
|
|
|
|
|
CharacteristicType,
|
|
|
|
|
|
type Characteristic,
|
|
|
|
|
|
} from "../types/characteristics";
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
/**
|
|
|
|
|
|
* peek-a-boo assumes at least this many seconds between shots to account for
|
|
|
|
|
|
* peeking out, aiming, and retreating
|
|
|
|
|
|
*/
|
|
|
|
|
|
const PEEKABOO_MIN_CYCLE = 10;
|
|
|
|
|
|
|
2026-08-31 20:13:31 -05:00
|
|
|
|
export const characteristics = {
|
|
|
|
|
|
gun_type: {
|
|
|
|
|
|
type: CharacteristicType.Enum,
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-08-31 20:13:31 -05:00
|
|
|
|
compute({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
damage: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell, environment, equalizer, script, gun }) {
|
|
|
|
|
|
let coefficient = 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (environment.equalize) coefficient *= equalizer.damage;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "TungstenTip", (effect) => {
|
|
|
|
|
|
coefficient *= (effect.minDamageFactor + effect.maxDamageFactor) / 2;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("antagonist", "ShieldKit", (effect) => {
|
|
|
|
|
|
if (shell.type === ShellType.SHELL_TYPE_HE) return;
|
|
|
|
|
|
coefficient *= 1 - effect.shieldCoef;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("antagonist", "ArmorMover", (effect) => {
|
|
|
|
|
|
coefficient *= 1 - effect.bonusValues!.damageReductionPercent / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("antagonist", "AntiHighExplosive", (effect) => {
|
|
|
|
|
|
if (shell.type !== ShellType.SHELL_TYPE_HE) return;
|
|
|
|
|
|
coefficient *= effect.bonusValues!.factor;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (gun.assault_ranges?.types.includes(shell.type)) {
|
|
|
|
|
|
const range = gun.assault_ranges.ranges.find(
|
|
|
|
|
|
({ distance }) => distance >= environment.distance,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
coefficient *= range?.factor ?? 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return coefficient * shell.armor_damage;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
// characteristics prefixed with an underscore are compute time only; they are never rendered
|
2026-10-11 00:43:08 -05:00
|
|
|
|
_reload_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ script, degressive, gun }) {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
let c0 = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => {
|
|
|
|
|
|
c0 += effect.bonusValues!.percent / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const c1 = degressive("loader");
|
|
|
|
|
|
|
|
|
|
|
|
let c2 = 1;
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
// adrenaline only affects regular guns
|
|
|
|
|
|
if (gun.gun_type!.$case === "regular") {
|
|
|
|
|
|
script("protagonist", "Berserk", (effect) => {
|
|
|
|
|
|
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
return (c0 * c1) / c2;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
reload: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case !== "auto_reloader";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ gun, characteristic }): number {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
if (gun.gun_type!.$case === "auto_reloader") return -Infinity;
|
|
|
|
|
|
|
|
|
|
|
|
const c = characteristic("_reload_coefficient") as number;
|
|
|
|
|
|
|
|
|
|
|
|
if (gun.gun_type!.$case === "regular") {
|
|
|
|
|
|
return c * gun.gun_type!.value.reload;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return c * gun.gun_type!.value.clip_reload;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-12 10:47:00 -05:00
|
|
|
|
reloads: {
|
|
|
|
|
|
type: CharacteristicType.NumberList,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case === "auto_reloader";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun, characteristic }) {
|
|
|
|
|
|
if (gun.gun_type!.$case !== "auto_reloader") return [];
|
|
|
|
|
|
|
|
|
|
|
|
const reloadCoefficient = characteristic("_reload_coefficient") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return gun.gun_type!.value.shell_reloads.map(
|
|
|
|
|
|
(reload) => reload * reloadCoefficient,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
intra_clip: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case !== "regular";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun, script }) {
|
|
|
|
|
|
if (gun.gun_type!.$case === "regular") return 0;
|
|
|
|
|
|
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "NewAutoloader", (effect) => {
|
|
|
|
|
|
c = effect.clipReloadTimeFactor;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return c * gun.gun_type!.value.intra_clip;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
// time per shot when continuously firing the fastest shell of an auto reloader
|
|
|
|
|
|
_optimal_shell_cycle: {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun, characteristic }) {
|
2026-11-11 22:43:47 -06:00
|
|
|
|
if (gun.gun_type!.$case !== "auto_reloader") return 0;
|
|
|
|
|
|
|
|
|
|
|
|
const intraClip = characteristic("intra_clip") as number;
|
|
|
|
|
|
const reloads = characteristic("reloads") as number[];
|
|
|
|
|
|
|
|
|
|
|
|
return Math.min(
|
|
|
|
|
|
...reloads.map((reload, index) => reload + (index > 0 ? intraClip : 0)),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dpm: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun, characteristic }): number {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
const alpha = characteristic("damage") as number;
|
|
|
|
|
|
|
|
|
|
|
|
let dps: number;
|
|
|
|
|
|
|
|
|
|
|
|
if (gun.gun_type!.$case === "regular") {
|
|
|
|
|
|
const reload = characteristic("reload") as number;
|
|
|
|
|
|
dps = alpha / reload;
|
|
|
|
|
|
} else if (gun.gun_type!.$case === "auto_loader") {
|
|
|
|
|
|
const damage = alpha * gun.gun_type!.value.shell_count;
|
|
|
|
|
|
|
|
|
|
|
|
const intraClip = characteristic("intra_clip") as number;
|
|
|
|
|
|
let time = characteristic("reload") as number;
|
|
|
|
|
|
|
|
|
|
|
|
if (gun.burst) {
|
|
|
|
|
|
time +=
|
|
|
|
|
|
(gun.gun_type!.value.shell_count / gun.burst.count - 1) * intraClip;
|
|
|
|
|
|
|
|
|
|
|
|
time +=
|
|
|
|
|
|
(gun.gun_type!.value.shell_count / gun.burst.count) *
|
|
|
|
|
|
(gun.burst.count - 1) *
|
|
|
|
|
|
gun.burst.interval;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
time += (gun.gun_type!.value.shell_count - 1) * intraClip;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dps = damage / time;
|
|
|
|
|
|
} else {
|
2026-11-11 22:43:47 -06:00
|
|
|
|
dps = alpha / (characteristic("_optimal_shell_cycle") as number);
|
2026-10-11 00:43:08 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return dps * 60;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
dpm_peekaboo: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case === "regular";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const damage = characteristic("damage") as number;
|
|
|
|
|
|
const reload = characteristic("reload") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return damage * (60 / Math.max(reload, PEEKABOO_MIN_CYCLE));
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dpm_effective: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case === "auto_reloader";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const damage = characteristic("damage") as number;
|
|
|
|
|
|
const reloads = characteristic("reloads") as number[];
|
|
|
|
|
|
const intraClip = characteristic("intra_clip") as number;
|
|
|
|
|
|
|
|
|
|
|
|
// dump every other shell at the intra clip rate, spend the rest of the
|
|
|
|
|
|
// minute cycling the optimal shell; ordering doesn't change the total
|
|
|
|
|
|
const rest = reloads.length - 1;
|
|
|
|
|
|
const cycle = characteristic("_optimal_shell_cycle") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return damage * rest + (damage * (60 - rest * intraClip)) / cycle;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
// damage_range: unavailable,
|
|
|
|
|
|
|
|
|
|
|
|
module_damage: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ shell, script }) {
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "TungstenTip", (effect) => {
|
|
|
|
|
|
c *= (effect.minDamageFactor + effect.maxDamageFactor) / 2;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return c * shell.module_damage;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// module_damage_range: unavailable,
|
|
|
|
|
|
|
2026-08-31 20:13:31 -05:00
|
|
|
|
clip_size: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case !== "regular";
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun }) {
|
|
|
|
|
|
if (gun.gun_type!.$case === "regular") return 1;
|
|
|
|
|
|
return gun.gun_type!.value.shell_count;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
clipping_potential: {
|
2026-08-31 20:13:31 -05:00
|
|
|
|
type: CharacteristicType.Number,
|
2026-10-04 16:42:35 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case !== "regular";
|
|
|
|
|
|
},
|
2026-10-04 16:42:35 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
const clipSize = characteristic("clip_size") as number;
|
|
|
|
|
|
const damage = characteristic("damage") as number;
|
2026-10-04 16:42:35 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
return clipSize * damage;
|
2026-10-01 20:11:12 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-31 20:13:31 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
/**
|
|
|
|
|
|
* 1-indexed so that it can be rendered as-is
|
|
|
|
|
|
*/
|
|
|
|
|
|
optimal_shell: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.gun_type!.$case === "auto_reloader";
|
|
|
|
|
|
},
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const reloads = characteristic("reloads") as number[];
|
|
|
|
|
|
const intraClip = characteristic("intra_clip") as number;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
let best = 0;
|
|
|
|
|
|
let bestTime = Infinity;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
reloads.forEach((reload, index) => {
|
|
|
|
|
|
const time = reload + (index > 0 ? intraClip : 0);
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
if (time < bestTime) {
|
|
|
|
|
|
best = index;
|
|
|
|
|
|
bestTime = time;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return best + 1;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
burst_size: {
|
2026-10-01 20:11:12 -05:00
|
|
|
|
type: CharacteristicType.Number,
|
2026-10-04 17:08:03 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.burst !== undefined;
|
|
|
|
|
|
},
|
2026-10-04 17:08:03 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
compute({ gun }) {
|
|
|
|
|
|
return gun.burst!.count;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
intra_burst: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ gun }) {
|
|
|
|
|
|
return gun.burst !== undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun }) {
|
|
|
|
|
|
return gun.burst!.interval;
|
2026-10-04 17:08:03 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
penetration: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
compute({ shell, environment, equalizer, script }) {
|
|
|
|
|
|
let c = 1;
|
2026-10-04 17:08:03 -05:00
|
|
|
|
|
2026-10-04 17:22:23 -05:00
|
|
|
|
script("protagonist", "CalibratedShells", (effect) => {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
c +=
|
2026-10-04 17:08:03 -05:00
|
|
|
|
effect.piercingFactors![blitzkitToBlitzShellKind[shell.type]] / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
if (environment.equalize) c *= equalizer.penetration;
|
|
|
|
|
|
|
|
|
|
|
|
return c * shell.penetration!.near;
|
2026-08-31 20:13:31 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-10-04 18:16:08 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
penetration_loss_by_distance: {
|
2026-10-04 18:16:08 -05:00
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
compute({ shell, script }) {
|
|
|
|
|
|
let c = 1;
|
2026-10-04 18:16:08 -05:00
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
script("protagonist", "StaticMiscAttrsModifier", (effect) => {
|
|
|
|
|
|
c += effect.bonusValues!.piercingPenaltyFactor500m / 100;
|
2026-10-04 18:16:08 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
return (
|
|
|
|
|
|
c *
|
|
|
|
|
|
100 *
|
|
|
|
|
|
((shell.penetration!.near - shell.penetration!.far) /
|
|
|
|
|
|
shell.penetration!.near)
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-10-05 17:45:56 -05:00
|
|
|
|
|
2026-10-11 13:42:20 -05:00
|
|
|
|
penetration_loss_after_ricochet: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ shell }) {
|
|
|
|
|
|
return !isExplosive(shell.type);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute() {
|
|
|
|
|
|
return 25;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
shell_velocity: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell, script }) {
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "StaticMiscAttrsModifier", (effect) => {
|
|
|
|
|
|
c += effect.bonusValues!.projectileSpeedFactor / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "GunPowder", (effect) => {
|
|
|
|
|
|
c += effect.bonusValues!.projectileSpeedFactor / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return c * shell.velocity;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
shell_range: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell }) {
|
|
|
|
|
|
return shell.range;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
shell_capacity: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ gun }) {
|
|
|
|
|
|
return gun.shell_capacity;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
caliber: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell }) {
|
|
|
|
|
|
return shell.caliber;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
ricochet: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ shell }) {
|
|
|
|
|
|
return !isExplosive(shell.type);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell }) {
|
|
|
|
|
|
return shell.ricochet ?? 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
normalization: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ shell }) {
|
|
|
|
|
|
return !isExplosive(shell.type);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell }) {
|
|
|
|
|
|
return shell.normalization ?? 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
explosion_radius: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
|
|
|
|
|
|
should_render({ shell }) {
|
|
|
|
|
|
return shell.explosion_radius !== undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
compute({ shell }) {
|
|
|
|
|
|
return shell.explosion_radius!;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
speed_forward: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ tank, script }) {
|
|
|
|
|
|
let bias = 0;
|
|
|
|
|
|
let factor = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "GearOil", (effect) => {
|
|
|
|
|
|
bias += effect.bonusValues?.fwdSpeedLimitBias ?? 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "ImprovedAfterburning", (effect) => {
|
|
|
|
|
|
bias += effect.fwdSpeedLimitBias ?? 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "AttributesModifier", (effect) => {
|
|
|
|
|
|
factor *= effect.attributes?.speedLimitsFactor ?? 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (tank.speed_forwards + bias) * factor;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
speed_backward: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ tank, script }) {
|
|
|
|
|
|
let bias = 0;
|
|
|
|
|
|
let factor = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "GearOil", (effect) => {
|
|
|
|
|
|
bias += effect.bonusValues?.bkwdSpeedLimitBias ?? 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "ImprovedAfterburning", (effect) => {
|
|
|
|
|
|
bias += effect.bkwdSpeedLimitBias ?? 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "AttributesModifier", (effect) => {
|
|
|
|
|
|
factor *= effect.attributes?.speedLimitsFactor ?? 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (tank.speed_backwards + bias) * factor;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
aim_time: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ tank, gun, turret, track, degressive, script }): number {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
/*
|
2026-11-11 22:43:47 -06:00
|
|
|
|
Mirrors the game's hangar params builder (wotblitz.exe 11.21, aim time
|
|
|
|
|
|
at 0x19eb300). Consumables such as FireControlSystem are battle-time
|
|
|
|
|
|
only and are not part of it.
|
|
|
|
|
|
|
|
|
|
|
|
T = (A × C) × ln(√(1 + k² × S)) / (1 − p/100)
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
S = (turret rotation speed × turret dispersion factor)²
|
|
|
|
|
|
+ (hull rotation speed × hull dispersion factor)²
|
2026-11-11 22:43:47 -06:00
|
|
|
|
+ (min(raw top speed, 30) × movement dispersion factor)²
|
|
|
|
|
|
raw top speed is the vehicle's unmodified forward speed limit; equipment
|
|
|
|
|
|
and engine modifiers do not affect it here.
|
2026-10-11 00:43:08 -05:00
|
|
|
|
A: the gun’s raw aimingTime—the time for the reticle to shrink by a factor of e ≈ 2.718.
|
2026-11-11 22:43:47 -06:00
|
|
|
|
C: gunner degressive crew factor (the reciprocal of the game's gunner value, which divides the time).
|
2026-10-11 00:43:08 -05:00
|
|
|
|
k: stabilization multiplier; normally 1, reduced by the stabilizer.
|
|
|
|
|
|
p: signed aiming-drive modifier. For −12%, the displayed time is divided by 1.12, reducing it by approximately 10.7%.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const S =
|
|
|
|
|
|
(turret.traverse_speed * gun.dispersion_traverse) ** 2 +
|
|
|
|
|
|
(track.traverse_speed * track.dispersion_traverse) ** 2 +
|
2026-11-11 22:43:47 -06:00
|
|
|
|
(Math.min(tank.speed_forwards, 30) * track.dispersion_move) ** 2;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
const A = gun.aim_time;
|
|
|
|
|
|
const C = degressive("gunner");
|
|
|
|
|
|
|
|
|
|
|
|
let p = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => {
|
2026-11-11 22:43:47 -06:00
|
|
|
|
p += effect.bonusValues!.percent / 100;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let k = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script(
|
|
|
|
|
|
"protagonist",
|
|
|
|
|
|
"miscAttrs/additiveShotDispersionFactor",
|
|
|
|
|
|
(effect) => {
|
|
|
|
|
|
k += effect.bonusValues!.percent / 100;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
const T = (A * C * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p);
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
return T;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ gun, degressive, script }) {
|
2026-10-11 01:36:34 -05:00
|
|
|
|
let coefficient = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "miscAttrs/shotDispersionAngleFactor", (effect) => {
|
|
|
|
|
|
coefficient += effect.bonusValues!.percent / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "FireControlSystem", (effect) => {
|
|
|
|
|
|
coefficient *= effect.shotDispersionAngleFactor;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-11 13:42:20 -05:00
|
|
|
|
return gun.dispersion_base * degressive("gunner") * coefficient;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
_stabilizer_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ script }) {
|
|
|
|
|
|
let coefficient = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script(
|
|
|
|
|
|
"protagonist",
|
|
|
|
|
|
"miscAttrs/additiveShotDispersionFactor",
|
|
|
|
|
|
(effect) => {
|
|
|
|
|
|
coefficient += effect.bonusValues!.percent / 100;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return coefficient;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_camouflage_bonus: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, state }) {
|
|
|
|
|
|
if (!state.camouflage) return 0;
|
|
|
|
|
|
|
|
|
|
|
|
switch (tank.class) {
|
|
|
|
|
|
case TankClass.TANK_CLASS_TANK_DESTROYER:
|
|
|
|
|
|
return 0.04;
|
|
|
|
|
|
case TankClass.TANK_CLASS_HEAVY:
|
|
|
|
|
|
return 0.03;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 0.02;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_camouflage_net_bonus_still: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, script }) {
|
|
|
|
|
|
let bonus = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "CamouflageNet", (effect) => {
|
|
|
|
|
|
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
bonus += base * (effect.activateBonusFactor ?? 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return bonus;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_camouflage_net_bonus_moving: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, script }) {
|
|
|
|
|
|
let bonus = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "CamouflageNet", (effect) => {
|
|
|
|
|
|
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
bonus += base * 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return bonus;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion_moving: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
const skill = (context.environment.skills.smooth_driving ?? 0) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
context.track.dispersion_move *
|
|
|
|
|
|
(1 +
|
|
|
|
|
|
(context.characteristic("_stabilizer_coefficient") as number) -
|
|
|
|
|
|
skill)
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion_hull_traversing: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
const skill = (context.environment.skills.smooth_turn ?? 0) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
context.track.dispersion_traverse *
|
|
|
|
|
|
(1 +
|
|
|
|
|
|
(context.characteristic("_stabilizer_coefficient") as number) -
|
|
|
|
|
|
skill)
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion_turret_traversing: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
const skill = (2 * (context.environment.skills.smooth_turret ?? 0)) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
context.gun.dispersion_traverse *
|
|
|
|
|
|
(1 +
|
|
|
|
|
|
(context.characteristic("_stabilizer_coefficient") as number) -
|
|
|
|
|
|
skill)
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion_shooting: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
const skill = (context.environment.skills.soft_recoil ?? 0) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
context.gun.dispersion_shot *
|
|
|
|
|
|
(1 +
|
|
|
|
|
|
(context.characteristic("_stabilizer_coefficient") as number) -
|
|
|
|
|
|
skill)
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dispersion_gun_damaged: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
return (
|
|
|
|
|
|
context.gun.dispersion_damaged *
|
|
|
|
|
|
(1 + (context.characteristic("_stabilizer_coefficient") as number))
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
// dispersion_angle: unavailable,
|
|
|
|
|
|
|
|
|
|
|
|
gun_depression: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ state, turret, gun, script }) {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
const turretModel = state.model.turrets[turret.id];
|
|
|
|
|
|
const gunModel = turretModel.guns[gun.id];
|
2026-11-11 22:43:47 -06:00
|
|
|
|
let bonus = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
|
|
|
|
|
|
bonus += effect.bonusValues?.lowerPitchLimitIncrease ?? 0;
|
|
|
|
|
|
});
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-11-11 22:43:47 -06:00
|
|
|
|
gunModel.pitch!.max +
|
|
|
|
|
|
(state.model.initial_turret_rotation?.pitch ?? 0) +
|
|
|
|
|
|
bonus
|
2026-10-11 00:43:08 -05:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
gun_elevation: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ state, turret, gun, script }) {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
const turretModel = state.model.turrets[turret.id];
|
|
|
|
|
|
const gunModel = turretModel.guns[gun.id];
|
2026-11-11 22:43:47 -06:00
|
|
|
|
let bonus = 0;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
|
|
|
|
|
|
bonus += effect.bonusValues?.upperPitchLimitIncrease ?? 0;
|
|
|
|
|
|
});
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-11-11 22:43:47 -06:00
|
|
|
|
-gunModel.pitch!.min -
|
|
|
|
|
|
(state.model.initial_turret_rotation?.pitch ?? 0) +
|
|
|
|
|
|
bonus
|
2026-10-11 00:43:08 -05:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:47:00 -05:00
|
|
|
|
gun_frontal_depression: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret, gun }) {
|
2026-10-11 01:36:34 -05:00
|
|
|
|
return (
|
|
|
|
|
|
state.model.turrets[turret.id].guns[gun.id].pitch!.front !== undefined
|
|
|
|
|
|
);
|
2026-10-11 00:47:00 -05:00
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret, gun }) {
|
|
|
|
|
|
return state.model.turrets[turret.id].guns[gun.id].pitch!.front!.max;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
gun_frontal_elevation: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret, gun }) {
|
2026-10-11 01:36:34 -05:00
|
|
|
|
return (
|
|
|
|
|
|
state.model.turrets[turret.id].guns[gun.id].pitch!.front !== undefined
|
|
|
|
|
|
);
|
2026-10-11 00:47:00 -05:00
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret, gun }) {
|
|
|
|
|
|
return -state.model.turrets[turret.id].guns[gun.id].pitch!.front!.min;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
gun_rear_depression: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret, gun }) {
|
2026-10-11 01:36:34 -05:00
|
|
|
|
return (
|
|
|
|
|
|
state.model.turrets[turret.id].guns[gun.id].pitch!.back !== undefined
|
|
|
|
|
|
);
|
2026-10-11 00:47:00 -05:00
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret, gun }) {
|
|
|
|
|
|
return state.model.turrets[turret.id].guns[gun.id].pitch!.back!.max;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
gun_rear_elevation: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret, gun }) {
|
2026-10-11 01:36:34 -05:00
|
|
|
|
return (
|
|
|
|
|
|
state.model.turrets[turret.id].guns[gun.id].pitch!.back !== undefined
|
|
|
|
|
|
);
|
2026-10-11 00:47:00 -05:00
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret, gun }) {
|
|
|
|
|
|
return -state.model.turrets[turret.id].guns[gun.id].pitch!.back!.min;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
azimuth_left: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret }) {
|
|
|
|
|
|
return state.model.turrets[turret.id].yaw !== undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret }) {
|
|
|
|
|
|
return -state.model.turrets[turret.id].yaw!.min;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
azimuth_right: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
should_render({ state, turret }) {
|
|
|
|
|
|
return state.model.turrets[turret.id].yaw !== undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
compute({ state, turret }) {
|
|
|
|
|
|
return state.model.turrets[turret.id].yaw!.max;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
weight: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, engine, track, turret, gun }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
(tank.weight +
|
|
|
|
|
|
engine.weight +
|
|
|
|
|
|
track.weight +
|
|
|
|
|
|
turret.weight +
|
|
|
|
|
|
gun.weight) /
|
|
|
|
|
|
1000
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
_engine_power_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, script }) {
|
|
|
|
|
|
let coefficient = 1;
|
|
|
|
|
|
|
|
|
|
|
|
const tankClass = tankClassToBlitzTankClass[tank.class];
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "RecycledEngine", (effect) => {
|
|
|
|
|
|
coefficient +=
|
|
|
|
|
|
(effect.enginePowerPercentageBonus?.[tankClass] ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "Afterburning", (effect) => {
|
|
|
|
|
|
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "ImprovedAfterburning", (effect) => {
|
|
|
|
|
|
coefficient += (effect.enginePowerIncrease ?? 1) - 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "Fuel", (effect) => {
|
|
|
|
|
|
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "GearOil", (effect) => {
|
|
|
|
|
|
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "AttributesModifier", (effect) => {
|
|
|
|
|
|
coefficient *= effect.attributes?.enginePowerFactor ?? 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return coefficient;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
engine_power: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
// explicit return type breaks the circular inference through characteristic()
|
|
|
|
|
|
compute({ engine, characteristic }): number {
|
|
|
|
|
|
return (
|
|
|
|
|
|
engine.power * (characteristic("_engine_power_coefficient") as number)
|
|
|
|
|
|
);
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
power_to_weight: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const enginePower = characteristic("engine_power") as number;
|
|
|
|
|
|
const weight = characteristic("weight") as number;
|
2026-10-04 18:16:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
return enginePower / weight;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// brake_force: unavailable,
|
2026-10-11 01:03:35 -05:00
|
|
|
|
|
|
|
|
|
|
hard_terrain_coefficient_raw: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ track, degressive, script }) {
|
|
|
|
|
|
return rawTerrainCoefficient(
|
|
|
|
|
|
track.resistance_hard,
|
|
|
|
|
|
"firmGroundPassabilityIncrease",
|
|
|
|
|
|
degressive("driver"),
|
|
|
|
|
|
script,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
hard_terrain_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
2026-10-11 01:03:35 -05:00
|
|
|
|
const raw = characteristic("hard_terrain_coefficient_raw") as number;
|
|
|
|
|
|
return 100 / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
medium_terrain_coefficient_raw: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ track, degressive, script }) {
|
|
|
|
|
|
return rawTerrainCoefficient(
|
|
|
|
|
|
track.resistance_medium,
|
|
|
|
|
|
"mediumGroundPassabilityIncrease",
|
|
|
|
|
|
degressive("driver"),
|
|
|
|
|
|
script,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
medium_terrain_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
2026-10-11 01:03:35 -05:00
|
|
|
|
const raw = characteristic("medium_terrain_coefficient_raw") as number;
|
|
|
|
|
|
return 100 / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
soft_terrain_coefficient_raw: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ track, degressive, script }) {
|
|
|
|
|
|
return rawTerrainCoefficient(
|
|
|
|
|
|
track.resistance_soft,
|
|
|
|
|
|
"softGroundPassabilityIncrease",
|
|
|
|
|
|
degressive("driver"),
|
|
|
|
|
|
script,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
soft_terrain_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ characteristic }): number {
|
2026-10-11 01:03:35 -05:00
|
|
|
|
const raw = characteristic("soft_terrain_coefficient_raw") as number;
|
|
|
|
|
|
return 100 / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
power_to_weight_hard: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const powerToWeight = characteristic("power_to_weight") as number;
|
|
|
|
|
|
const raw = characteristic("hard_terrain_coefficient_raw") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return powerToWeight / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
power_to_weight_medium: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const powerToWeight = characteristic("power_to_weight") as number;
|
|
|
|
|
|
const raw = characteristic("medium_terrain_coefficient_raw") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return powerToWeight / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
power_to_weight_soft: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ characteristic }): number {
|
|
|
|
|
|
const powerToWeight = characteristic("power_to_weight") as number;
|
|
|
|
|
|
const raw = characteristic("soft_terrain_coefficient_raw") as number;
|
|
|
|
|
|
|
|
|
|
|
|
return powerToWeight / raw;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 01:36:34 -05:00
|
|
|
|
hull_traverse_speed_hard: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
return hullTraverseSpeed(
|
|
|
|
|
|
context.characteristic("hard_terrain_coefficient_raw") as number,
|
|
|
|
|
|
context,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
hull_traverse_speed_medium: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
return hullTraverseSpeed(
|
|
|
|
|
|
context.characteristic("medium_terrain_coefficient_raw") as number,
|
|
|
|
|
|
context,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
hull_traverse_speed_soft: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
return hullTraverseSpeed(
|
|
|
|
|
|
context.characteristic("soft_terrain_coefficient_raw") as number,
|
|
|
|
|
|
context,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
_turret_traverse_coefficient: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ progressive, script }) {
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "Fuel", (effect) => {
|
|
|
|
|
|
c += (effect.bonusValues?.turretRotationSpeedIncrease ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return c * progressive("gunner");
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
turret_traverse_speed: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ turret, characteristic }): number {
|
|
|
|
|
|
const c = characteristic("_turret_traverse_coefficient") as number;
|
|
|
|
|
|
return turret.traverse_speed * c;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
gun_traverse_speed: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ gun, characteristic }): number {
|
|
|
|
|
|
const c = characteristic("_turret_traverse_coefficient") as number;
|
|
|
|
|
|
return gun.rotation_speed * c;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
health: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, turret, environment, equalizer, script }) {
|
|
|
|
|
|
let c0 = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "SmallHPStock", (effect) => {
|
|
|
|
|
|
c0 += effect.hpStockPercent / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "HighQualityAssembly", (effect) => {
|
|
|
|
|
|
c0 += effect.maxHpBonusPercent / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let c1 = 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (environment.equalize) {
|
|
|
|
|
|
c1 *= equalizer.health;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let health = tank.health + turret.health;
|
|
|
|
|
|
|
|
|
|
|
|
health *= c0 * c1;
|
|
|
|
|
|
|
|
|
|
|
|
if (environment.equalize) {
|
|
|
|
|
|
health = 50 * Math.round(health / 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return health;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
fire_chance: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ engine, script }) {
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "SafetySet", (effect) => {
|
|
|
|
|
|
c -= (effect.bonusValues?.fireProtectionIncrease ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return 100 * c * engine.fire_chance;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// fire_rate: unavailable,
|
|
|
|
|
|
// ramming_resistance: unavailable,
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
crew_count: {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute({ tank }) {
|
|
|
|
|
|
return tank.crew.length;
|
2026-10-11 00:43:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
view_range: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, turret, progressive, script }) {
|
|
|
|
|
|
let c = 1;
|
|
|
|
|
|
|
|
|
|
|
|
script("protagonist", "CoatedOptics", (effect) => {
|
|
|
|
|
|
c +=
|
|
|
|
|
|
(effect.visionRadiusPercentageBonus?.[
|
|
|
|
|
|
tankClassToBlitzTankClass[tank.class]
|
|
|
|
|
|
] ?? 0) / 100;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return turret.view_range * c * progressive("commander");
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* camouflage characteristics are stored as percentages
|
|
|
|
|
|
*/
|
2026-10-11 00:43:08 -05:00
|
|
|
|
camouflage: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
2026-11-11 22:43:47 -06:00
|
|
|
|
compute(context): number {
|
|
|
|
|
|
const skill = (3 * (context.environment.skills.camouflage ?? 0)) / 100;
|
|
|
|
|
|
const c =
|
|
|
|
|
|
1 +
|
|
|
|
|
|
(context.characteristic("_camouflage_bonus") as number) +
|
|
|
|
|
|
skill +
|
|
|
|
|
|
(context.characteristic("_camouflage_net_bonus_still") as number);
|
2026-10-11 00:43:08 -05:00
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
return 100 * context.tank.camouflage_still * c;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
camouflage_moving: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute(context): number {
|
|
|
|
|
|
// light tanks retain their full camouflage while moving
|
|
|
|
|
|
if (context.tank.class === TankClass.TANK_CLASS_LIGHT) {
|
|
|
|
|
|
return context.characteristic("camouflage") as number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const c =
|
|
|
|
|
|
1 +
|
|
|
|
|
|
(context.characteristic("_camouflage_bonus") as number) +
|
|
|
|
|
|
(context.characteristic("_camouflage_net_bonus_moving") as number);
|
|
|
|
|
|
|
|
|
|
|
|
return 100 * context.tank.camouflage_moving * c;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
camouflage_shooting_still: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ gun, characteristic }): number {
|
|
|
|
|
|
return gun.camouflage_loss * (characteristic("camouflage") as number);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
camouflage_shooting_moving: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ gun, characteristic }): number {
|
2026-10-11 00:43:08 -05:00
|
|
|
|
return (
|
2026-11-11 22:43:47 -06:00
|
|
|
|
gun.camouflage_loss * (characteristic("camouflage_moving") as number)
|
2026-10-11 00:43:08 -05:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-11-11 22:43:47 -06:00
|
|
|
|
camouflage_on_fire: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ tank, characteristic }): number {
|
|
|
|
|
|
return tank.camouflage_on_fire * (characteristic("camouflage") as number);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 00:43:08 -05:00
|
|
|
|
width: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ state, turret }) {
|
|
|
|
|
|
const size = normalizeBoundingBox(
|
|
|
|
|
|
unionBoundingBox(
|
|
|
|
|
|
state.model.bounding_box!,
|
|
|
|
|
|
state.model.turrets[turret.id].bounding_box!,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return size.z;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
height: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ state, turret }) {
|
|
|
|
|
|
const size = normalizeBoundingBox(
|
|
|
|
|
|
unionBoundingBox(
|
|
|
|
|
|
state.model.bounding_box!,
|
|
|
|
|
|
state.model.turrets[turret.id].bounding_box!,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return size.x;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
length: {
|
|
|
|
|
|
type: CharacteristicType.Number,
|
|
|
|
|
|
compute({ state, turret }) {
|
|
|
|
|
|
const size = normalizeBoundingBox(
|
|
|
|
|
|
unionBoundingBox(
|
|
|
|
|
|
state.model.bounding_box!,
|
|
|
|
|
|
state.model.turrets[turret.id].bounding_box!,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return size.y;
|
2026-10-04 18:16:08 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-31 20:13:31 -05:00
|
|
|
|
} satisfies Record<string, Characteristic>;
|