Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "./styles/base.css";
import "./styles/home.css";
import "./styles/equipment.css";
import "./styles/chemistry.css";
import "./styles/damage.css";
import "./styles/responsive.css";

createRoot(document.getElementById("root")!).render(
Expand Down
118 changes: 118 additions & 0 deletions src/modules/damage/DamagePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useMemo, useState } from "react";
import { useCatalog } from "../equipment/catalogStore";
import { ItemSprite } from "../equipment/components/ItemSprite";
import { capitalizeName, formatDamage, formatNumber, isMap } from "../equipment/format";
import type { JsonMap } from "../equipment/types";
import { AmmoPicker, ammoProjectiles } from "./components/AmmoPicker";
import { WeaponPicker } from "./components/WeaponPicker";

export function DamagePage() {
const { catalog, error, loading, retry } = useCatalog();
const [selectedWeaponId, setSelectedWeaponId] = useState<string | null>(null);
const [selectedAmmoIndex, setSelectedAmmoIndex] = useState(0);

const selectedWeapon = selectedWeaponId && catalog ? catalog.items[selectedWeaponId] : null;
const ammunition = useMemo(() => {
const raw = selectedWeapon?.weaponStats?.ammunition;
return Array.isArray(raw) ? raw.filter(isMap) : [];
}, [selectedWeapon]);
const selectedAmmo: JsonMap | undefined = ammunition[selectedAmmoIndex];
const projectiles = useMemo(() => ammoProjectiles(selectedAmmo), [selectedAmmo]);

const selectWeapon = (id: string) => {
setSelectedWeaponId(id);
setSelectedAmmoIndex(0);
};

return (
<main className="damage-page">
<section className="damage-hero">
<div>
<p className="eyebrow">USCM // TTK CALCULATOR</p>
<h1>Калькулятор урона</h1>
<p>Оружие, боеприпасы, дистанция и броня цели — расчёт урона и времени до убийства.</p>
</div>
<div className="catalog-meta">
<span>STATUS</span><strong>{loading ? "SYNC" : error ? "ERROR" : "ONLINE"}</strong>
{catalog && <small>BUILD {catalog.gameCommit.slice(0, 8).toUpperCase()}</small>}
</div>
</section>

{loading && !catalog && <div className="status-panel" role="status"><span>DATABASE MESSAGE</span><strong>Синхронизация</strong><p>Загружаю каталог снаряжения…</p></div>}
{error && !catalog && (
<div className="status-panel" role="status">
<span>DATABASE MESSAGE</span><strong>Ошибка загрузки</strong><p>{error}</p>
<button type="button" onClick={retry}>Повторить</button>
</div>
)}

{catalog && (
<div className="damage-layout">
<section className="damage-weapon-column">
<h2>Оружие</h2>
<WeaponPicker catalog={catalog} selectedId={selectedWeaponId} onSelect={selectWeapon} />
</section>

<section className="damage-detail-column">
{!selectedWeapon && <div className="empty-state">Выберите оружие слева.</div>}
{selectedWeapon && (
<>
<div className="damage-weapon-summary">
<ItemSprite item={selectedWeapon} />
<div>
<strong>{capitalizeName(selectedWeapon.name)}</strong>
<small>{selectedWeapon.id}</small>
</div>
</div>

<dl className="stat-grid">
<div>
<dt>Скорострельность</dt>
<dd>
{selectedWeapon.weaponStats?.shotsPerSecond != null
? `${formatNumber(selectedWeapon.weaponStats.shotsPerSecond)} выстр./с`
: "—"}
</dd>
</div>
{selectedWeapon.weaponStats?.damageMultiplier != null && (
<div>
<dt>Множитель урона</dt>
<dd>{`×${formatNumber(selectedWeapon.weaponStats.damageMultiplier)}`}</dd>
</div>
)}
</dl>

{ammunition.length > 0 ? (
<>
<h3>Боеприпас</h3>
<AmmoPicker ammunition={ammunition} selectedIndex={selectedAmmoIndex} onSelect={setSelectedAmmoIndex} />

<div className="projectile-list">
{projectiles.map((projectile, index) => (
<article key={`${String(projectile.projectileId)}:${index}`}>
<strong>{String(projectile.name ?? "Снаряд")}</strong>
<dl className="stat-grid">
<div><dt>Урон</dt><dd>{formatDamage(projectile.effectiveDamage ?? projectile.damage) ?? "—"}</dd></div>
{projectile.armorPiercing != null && (
<div><dt>Бронепробитие</dt><dd>{formatNumber(projectile.armorPiercing)}</dd></div>
)}
</dl>
</article>
))}
</div>
</>
) : (
<p className="muted">У этого оружия нет вариантов боеприпасов в каталоге.</p>
)}

<p className="muted damage-todo-note">
Обвесы, цели и дистанция — в следующих срезах.
</p>
</>
)}
</section>
</div>
)}
</main>
);
}
38 changes: 38 additions & 0 deletions src/modules/damage/components/AmmoPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { isMap } from "../../equipment/format";
import type { JsonMap } from "../../equipment/types";

export function AmmoPicker({ ammunition, selectedIndex, onSelect }: {
ammunition: JsonMap[];
selectedIndex: number;
onSelect: (index: number) => void;
}) {
if (!ammunition.length) return null;
return (
<div className="ammo-picker" role="radiogroup" aria-label="Выбор боеприпаса">
{ammunition.map((entry, index) => {
const label = String(
entry.magazineName ?? entry.ammoName ?? entry.magazineId ?? entry.ammoId ?? `Боеприпас ${index + 1}`,
);
const isAp = /ББ|Бронебойн/u.test(label);
return (
<button
type="button"
key={`${String(entry.magazineId ?? entry.ammoId)}:${index}`}
className={`ammo-chip${selectedIndex === index ? " is-selected" : ""}${isAp ? " is-ap" : ""}`}
role="radio"
aria-checked={selectedIndex === index}
onClick={() => onSelect(index)}
>
{label}
{typeof entry.capacity === "number" && <small>{entry.capacity} шт.</small>}
</button>
);
})}
</div>
);
}

export function ammoProjectiles(entry: JsonMap | undefined): JsonMap[] {
if (!entry || !Array.isArray(entry.projectiles)) return [];
return entry.projectiles.filter(isMap);
}
56 changes: 56 additions & 0 deletions src/modules/damage/components/WeaponPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { ItemSprite } from "../../equipment/components/ItemSprite";
import { capitalizeName } from "../../equipment/format";
import type { Catalog, CatalogItem } from "../../equipment/types";

// Category "Оружие" already excludes turrets (Снаряжение), underbarrel
// modules (Обвесы) and the hidden *Empty duplicate prototypes (Скрытые) that
// exist only for map-spawned unloaded guns. These specific ids stay in that
// category but aren't handheld weapons a player picks for a TTK comparison.
const EXCLUDED_WEAPON_IDS = new Set([
"RMCWeaponLauncherM85A1", // Гранатомет M79
"WeaponLauncherM83", // Гранатомет M83
"RMCWeaponFlamerSpec", // Огнеметная установка M240-T
"RMCWeaponFlamer", // Огнеметная установка M240A1
"RMCWeaponTaser", // Тазер
"RMCWeaponPistolM82F", // Сигнальный пистолет M82-F
"RMCWeaponLauncherM5ATL", // M5-ATL
"RMCWeaponLauncherM6HBrute", // M6H-BRUTE
"STWeaponSharpRifle", // Винтовка P9 SHARP
"RMCWeaponRevolverM44Marksman", // Боевой револьвер M44 (Марксманский)
]);

export function WeaponPicker({ catalog, selectedId, onSelect }: {
catalog: Catalog;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const weapons = useMemo(() => (
catalog.publicCatalog.itemIds
.map((id) => catalog.items[id])
.filter((item): item is CatalogItem => (
Boolean(item?.weaponStats)
&& item.category === "Оружие"
&& !EXCLUDED_WEAPON_IDS.has(item.id)
))
.sort((a, b) => a.name.localeCompare(b.name, "ru"))
), [catalog]);

return (
<div className="weapon-grid" role="listbox" aria-label="Выбор оружия">
{weapons.map((item) => (
<button
type="button"
key={item.id}
className={`weapon-card${selectedId === item.id ? " is-selected" : ""}`}
role="option"
aria-selected={selectedId === item.id}
onClick={() => onSelect(item.id)}
>
<ItemSprite item={item} />
<strong>{capitalizeName(item.name)}</strong>
</button>
))}
</div>
);
}
158 changes: 158 additions & 0 deletions src/modules/damage/damageMath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
ammoNeeded,
applyArmorMitigation,
computeHitDamage,
falloffMultiplier,
hitsToKill,
timeToKillSeconds,
} from "./damageMath";
import type { MarineArmor, XenoTargetArmor } from "./damageMath";

// Verified against BulletRifle10x24mm / WeaponRifleM4SPR data pulled from the
// real equipment-catalog.json: effectiveDamage.Piercing = 56, weapon
// falloffMultiplier = 0, thresholds [{range:24, falloff:9999, ignoreModifiers:
// true}, {range:7, falloff:4}].
const AR10_THRESHOLDS = [
{ range: 24, falloff: 9999, ignoreModifiers: true },
{ range: 7, falloff: 4 },
];

describe("falloffMultiplier", () => {
it("is 1 when no threshold has been crossed", () => {
expect(falloffMultiplier(56, 5, AR10_THRESHOLDS, 0)).toBe(1);
});

it("stays 1 past the ramping threshold when the weapon's falloff multiplier is 0", () => {
// This weapon zeroes out normal ramping falloff, relying only on the
// ignoreModifiers threshold as a hard cutoff.
expect(falloffMultiplier(56, 15, AR10_THRESHOLDS, 0)).toBe(1);
});

it("clamps to the 5% floor past the hard ignoreModifiers cutoff", () => {
expect(falloffMultiplier(56, 30, AR10_THRESHOLDS, 0)).toBeCloseTo(0.05, 10);
});

it("applies normal ramping falloff when the weapon's multiplier is nonzero", () => {
const thresholds = [{ range: 10, falloff: 5 }];
// distance 15: pastEffectiveRange 5, extraMult 1 -> (100 - 5*5*1)/100 = 0.75
expect(falloffMultiplier(100, 15, thresholds, 1)).toBeCloseTo(0.75, 10);
});

it("returns 1 for non-positive input damage", () => {
expect(falloffMultiplier(0, 100, AR10_THRESHOLDS, 1)).toBe(1);
});
});

const HELMET_ARMOR: MarineArmor = { kind: "marine", bullet: 20, melee: 20, bio: 20 };
const WARRIOR_ARMOR: XenoTargetArmor = { kind: "xeno", xenoArmor: 20, immuneToArmorPiercing: false };

describe("applyArmorMitigation", () => {
it("matches the verified two-stage Resist formula for a marine target", () => {
// armor after piercing: 20 - 5 = 15; resist = 1.1^(15/5) = 1.331
// 56 / 1.331 = 42.0736...; total (42.07) is above armor*2 (30), stage 2 doesn't fire.
const result = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", HELMET_ARMOR);
expect(result.Piercing).toBeCloseTo(56 / 1.331, 6);
});

it("matches the same Resist formula for a xeno target's innate armor", () => {
const result = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", WARRIOR_ARMOR);
expect(result.Piercing).toBeCloseTo(56 / 1.331, 6);
});

it("mitigates marine Heat damage through the bio stat but leaves xeno Heat untouched", () => {
// This is the easiest spot to silently get wrong: xenos only ever resist
// the Brute group, so Burn-type damage (Heat/Shock/Cold/Caustic) passes
// straight through their armor, while marines resist it via `bio`.
const marineResult = applyArmorMitigation(
{ Piercing: 50, Heat: 10 },
0,
"bullet",
HELMET_ARMOR,
);
const xenoResult = applyArmorMitigation(
{ Piercing: 50, Heat: 10 },
0,
"bullet",
WARRIOR_ARMOR,
);

expect(marineResult.Heat).toBeCloseTo(10 / 1.4641, 6);
expect(xenoResult.Heat).toBe(10);
});

it("ignores armor piercing for a xeno target that is immune to it", () => {
const immuneTarget: XenoTargetArmor = { kind: "xeno", xenoArmor: 20, immuneToArmorPiercing: true };
const withoutPiercing = applyArmorMitigation({ Piercing: 56 }, 0, "bullet", immuneTarget);
const withPiercing = applyArmorMitigation({ Piercing: 56 }, 5, "bullet", immuneTarget);
expect(withPiercing.Piercing).toBeCloseTo(withoutPiercing.Piercing, 10);
});

it("uses melee armor instead of bullet armor for melee weapons", () => {
const lopsided: MarineArmor = { kind: "marine", bullet: 0, melee: 20, bio: 0 };
const bulletHit = applyArmorMitigation({ Piercing: 56 }, 0, "bullet", lopsided);
const meleeHit = applyArmorMitigation({ Piercing: 56 }, 0, "melee", lopsided);
expect(bulletHit.Piercing).toBe(56);
// Stage 1: 56 / 1.1^4 = 38.2488. That total is below armor*2 (40), so the
// stage-2 clamp also fires: (38.2488*4 - 20) / 4 = 38.2488 - 5 = 33.2488.
expect(meleeHit.Piercing).toBeCloseTo(56 / 1.4641 - 5, 6);
});

it("returns damage unchanged when armor is zero or negative after piercing", () => {
const noArmor: MarineArmor = { kind: "marine", bullet: 5, melee: 0, bio: 0 };
const result = applyArmorMitigation({ Piercing: 56 }, 10, "bullet", noArmor);
expect(result.Piercing).toBe(56);
});
});

describe("computeHitDamage", () => {
it("combines falloff and armor in one call", () => {
const result = computeHitDamage({
effectiveDamage: { Piercing: 56 },
distance: 5,
falloffThresholds: AR10_THRESHOLDS,
weaponFalloffMultiplier: 0,
armorPiercing: 5,
weaponCategory: "bullet",
target: HELMET_ARMOR,
});
expect(result.preArmorTotal).toBe(56);
expect(result.totalDamage).toBeCloseTo(56 / 1.331, 6);
});
});

describe("hitsToKill", () => {
it("rounds up to a whole number of hits", () => {
expect(hitsToKill(30, { critical: 150, dead: 200 }, "dead")).toBe(7);
});

it("is Infinity when the target has no critical stage", () => {
expect(hitsToKill(30, { critical: null, dead: 35 }, "critical")).toBe(Infinity);
});

it("is Infinity for zero or negative per-hit damage", () => {
expect(hitsToKill(0, { critical: 150, dead: 200 }, "dead")).toBe(Infinity);
});
});

describe("timeToKillSeconds", () => {
it("divides hits by the fire rate", () => {
expect(timeToKillSeconds(7, 2.86)).toBeCloseTo(2.4476, 3);
});

it("is Infinity when hits is already Infinity", () => {
expect(timeToKillSeconds(Infinity, 2.86)).toBe(Infinity);
});
});

describe("ammoNeeded", () => {
it("rounds up to whole magazines when capacity is known", () => {
expect(ammoNeeded(7, 25)).toEqual({ shots: 7, magazines: 1 });
expect(ammoNeeded(30, 25)).toEqual({ shots: 30, magazines: 2 });
});

it("has no magazine count when capacity is unknown", () => {
expect(ammoNeeded(7, null)).toEqual({ shots: 7, magazines: null });
expect(ammoNeeded(7, undefined)).toEqual({ shots: 7, magazines: null });
});
});
Loading
Loading